Inbound Channel Webhooks

Provider webhook reference for inbound SMS, WhatsApp, and generic-channel messages, plus the MTA mailbox and credential callbacks.

These are the server-to-server webhook endpoints that feed non-email customer messages into Owlat. Each one accepts an inbound payload from an external provider (Twilio, Meta, or your own system), verifies it cryptographically, and turns it into a unified-inbox message. They are not part of the authenticated /api/v1/* surface and are not called from a browser — you configure them in the provider's dashboard and they post directly to your deployment.

Inbound only

These endpoints ingest incoming messages. Outbound SMS/WhatsApp/generic sending is wired end-to-end (real Twilio/Meta/webhook adapters), but it runs only through the AI-agent reply path or a manual send from a contact's Unified Timeline — there is no per-message reply button in the Team Inbox. See Communication Channels for what each channel can do today. This page covers the receiving side.

Overview: how inbound channels feed the unified inbox

All three channel webhooks share one pipeline (apps/api/convex/webhooks/pipeline.ts). The shell is identical for every provider; only signature verification and payload parsing differ, and those live in per-provider adapters under apps/api/convex/webhooks/adapters/.

The pipeline runs these steps in order:

  1. Reject non-POST with 405.
  2. Rate-limit by client IP (webhookIngestion: token bucket, 50/sec, burst capacity 100). Over the limit returns 429 with a Retry-After header.
  3. Verify the signature via the provider adapter. Fail-closed — see Required env secrets.
  4. Audit-store the raw payload (best-effort; never fails the request).
  5. Parse the payload into a normalized channel.received event. A parse error returns 400; a payload that carries no actionable message (e.g. a Meta status update) is acknowledged with 200 { "success": true, "ignored": true }.
  6. Dispatch the event. The dispatcher (apps/api/convex/webhooks/dispatcher.ts) routes channel.received to the processInboundChannel internal mutation, which resolves or creates the contact, finds or reopens the conversation thread, and inserts an inbound unifiedMessages row.

Contacts are resolved per channel, not by email: an inbound SMS or WhatsApp message creates a contact keyed on the sender's phone number or handle through contactIdentities, and these contacts have no email. Each channel is its own identity keyspace — a generic-channel john@example.com is a different contact from an email-channel one. Cross-channel unification is an explicit merge operation, not automatic.

Base URL

All paths below are relative to your deployment's HTTP URL, e.g. https://<your-deployment>.convex.site/webhooks/sms. Configure that full URL in the provider's webhook settings.

POST /webhooks/sms (Twilio)

Inbound SMS from Twilio's Programmable Messaging webhook.

PropertyValue
MethodPOST
Content typeapplication/x-www-form-urlencoded (Twilio's standard)
Signature headerX-Twilio-Signature
Signature schemeHMAC-SHA1, Base64-encoded
Secret env varTWILIO_AUTH_TOKEN
Success responseTwiML empty <Response></Response> (text/xml)

The adapter reconstructs Twilio's canonical validation string — the full request URL followed by every form parameter concatenated in alphabetical order (key immediately followed by value, no separator) — then compares the HMAC-SHA1 against X-Twilio-Signature in constant time. This matches Twilio's request-validation spec.

Parsed fields:

Form fieldMaps to
Fromsender identifier (required)
Bodymessage text (required)
MessageSidexternalMessageId
MediaUrl0first media URL (optional)
FromCity, FromState, FromCountrystored as metadata

If From or Body is missing, parsing fails and the endpoint returns 400.

POST /webhooks/sms
X-Twilio-Signature: <base64 HMAC-SHA1>
Content-Type: application/x-www-form-urlencoded

From=%2B15551234567&Body=Hello&MessageSid=SM123&FromCity=Berlin

On success Twilio receives the empty-Response TwiML envelope (no auto-reply is sent from this endpoint):

<?xml version="1.0" encoding="UTF-8"?><Response></Response>
No replay protection

Twilio does not include a timestamp in its signature, so this layer cannot detect replays. The worst case is a duplicate inbound message, not a forged state transition — acceptable for an inbound channel, but do not reuse this scheme for anything that mutates billing or auth.

POST and GET /webhooks/whatsapp (Meta verification challenge)

WhatsApp Business messages arrive from Meta's Cloud API. The same path handles two methods: POST for inbound messages and GET for the one-time subscription verification challenge.

POST — inbound message

PropertyValue
MethodPOST
Content typeapplication/json
Signature headerX-Hub-Signature-256
Signature schemeHMAC-SHA256 over the raw body, hex-encoded, prefixed sha256=
Secret env varMETA_APP_SECRET
Success response200 OK (plain text body OK)

The adapter strips the sha256= prefix from the header and compares the hex HMAC-SHA256 of the raw body in constant time, per Meta's webhook docs.

It reads the first message from the nested entry[0].changes[0].value.messages[0] envelope:

Source fieldMaps to
messages[0].fromsender identifier (required)
messages[0].text.bodymessage text
messages[0].idexternalMessageId
messages[0].image.url or document.urlmedia URL (optional)
contacts[0].profile.namestored as metadata (profileName)

Meta also posts delivery/read status updates that contain no messages. Those are acknowledged with 200 { "success": true, "ignored": true } and produce no inbox message.

GET — verification challenge

When you activate a webhook subscription, Meta sends a GET request with hub.mode, hub.verify_token, and hub.challenge query parameters. The handler runs out-of-band, before the inbound pipeline:

  • If META_VERIFY_TOKEN is unset, it returns 503.
  • If hub.mode is subscribe and hub.verify_token matches META_VERIFY_TOKEN (constant-time compare), it echoes hub.challenge back with 200.
  • Otherwise it returns 403 Verification failed.
GET /webhooks/whatsapp?hub.mode=subscribe&hub.verify_token=<your-token>&hub.challenge=12345
→ 200
12345

Set META_VERIFY_TOKEN to any string you choose and enter the same value in Meta's webhook configuration as the verify token.

POST /webhooks/channel (generic shared-secret envelope)

A provider-agnostic endpoint for piping messages from any system that can post JSON with a shared secret. This is the lowest-trust channel — it uses a static shared secret rather than a per-request HMAC, so the pipeline's IP rate limit is your primary abuse control.

PropertyValue
MethodPOST
Content typeapplication/json
Auth headerX-Webhook-Secret, or Authorization: Bearer <secret>
Auth schemeconstant-time string equality
Secret env varGENERIC_WEBHOOK_SECRET
Success response200 { "success": true, "kind": "channel.received" }

The JSON envelope is forgiving — fields fall back through several aliases:

Envelope fieldAliases / fallbackMaps to
fromsender, else literal "webhook"sender identifier
textmessage, content.textmessage text
htmlcontent.htmlHTML body (optional)
subjectcontent.subjectsubject (optional)
idmessageIdexternalMessageId (optional)
metadataobject of string values (optional)
POST /webhooks/channel
X-Webhook-Secret: <your-secret>
Content-Type: application/json
{
  "from": "user-42",
  "text": "Hi, is anyone there?",
  "id": "ext-9001",
  "metadata": { "source": "intercom" }
}

Required env secrets and fail-closed behavior

Every adapter reads its secret through apps/api/convex/lib/env.ts. If the secret is unset, the endpoint rejects with 503 — it never accepts an unsigned request "for now."

EndpointRequired secretMissing →Bad/absent signature →
POST /webhooks/smsTWILIO_AUTH_TOKEN503401
POST /webhooks/whatsappMETA_APP_SECRET503401
GET /webhooks/whatsappMETA_VERIFY_TOKEN503403
POST /webhooks/channelGENERIC_WEBHOOK_SECRET503401

Common status codes across the channel pipeline:

StatusMeaning
200Accepted (or ignored: true for a no-message payload)
400Body could not be read or parsed
401Missing or invalid signature / shared secret
403Meta verification challenge failed (GET only)
405Method other than POST (channel endpoints)
429Rate limited; honor Retry-After
503Endpoint not configured — required secret is unset

Set these with convex env set (or your deployment's environment configuration). See Environment Variables for the full registry.

MTA inbound routes

Two further webhooks live on the same HTTP router but are callbacks from the bundled mail transfer agent and IMAP services, not customer channels. They authenticate with an HMAC-SHA256 signature keyed on MTA_WEBHOOK_SECRET, sent as X-MTA-Signature over the string <timestamp>.<body>, with the timestamp in X-MTA-Timestamp. Both reject when MTA_WEBHOOK_SECRET is unset (503) and reject a stale or missing timestamp (401).

POST /webhooks/mta-mailbox

Personal-mail (Postbox) inbound delivery from the MTA — distinct from the bounce/complaint callback at POST /webhooks/mta. It accepts the inbound.mailbox.received event, requires the timestamp to be within 300 seconds of now, and forwards the parsed message into the Postbox delivery pipeline. See Postbox Architecture.

POST /webhooks/mta-verify-credential

App-password verification for IMAP and SMTP submission. The MTA/IMAP server posts { address, password, scope: "imap" | "smtp" }; this lets it authenticate clients without holding the Convex admin key. The timestamp window here is tighter — 60 seconds. On a match it returns { ok: true, mailboxId, appPasswordId, userId, organizationId }; otherwise { ok: false }. See Postbox Architecture.

Outbound webhooks are separate

This page is about webhooks Owlat receives. For the webhooks Owlat sends to your endpoints (delivery events, contact and topic changes), see Webhooks and the Webhook Payloads contract.