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.
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:
- Reject non-
POSTwith405. - Rate-limit by client IP (
webhookIngestion: token bucket, 50/sec, burst capacity 100). Over the limit returns429with aRetry-Afterheader. - Verify the signature via the provider adapter. Fail-closed — see Required env secrets.
- Audit-store the raw payload (best-effort; never fails the request).
- Parse the payload into a normalized
channel.receivedevent. A parse error returns400; a payload that carries no actionable message (e.g. a Meta status update) is acknowledged with200 { "success": true, "ignored": true }. - Dispatch the event. The dispatcher (
apps/api/convex/webhooks/dispatcher.ts) routeschannel.receivedto theprocessInboundChannelinternal mutation, which resolves or creates the contact, finds or reopens the conversation thread, and inserts an inboundunifiedMessagesrow.
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.
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.
| Property | Value |
|---|---|
| Method | POST |
| Content type | application/x-www-form-urlencoded (Twilio's standard) |
| Signature header | X-Twilio-Signature |
| Signature scheme | HMAC-SHA1, Base64-encoded |
| Secret env var | TWILIO_AUTH_TOKEN |
| Success response | TwiML 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 field | Maps to |
|---|---|
From | sender identifier (required) |
Body | message text (required) |
MessageSid | externalMessageId |
MediaUrl0 | first media URL (optional) |
FromCity, FromState, FromCountry | stored 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>
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
| Property | Value |
|---|---|
| Method | POST |
| Content type | application/json |
| Signature header | X-Hub-Signature-256 |
| Signature scheme | HMAC-SHA256 over the raw body, hex-encoded, prefixed sha256= |
| Secret env var | META_APP_SECRET |
| Success response | 200 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 field | Maps to |
|---|---|
messages[0].from | sender identifier (required) |
messages[0].text.body | message text |
messages[0].id | externalMessageId |
messages[0].image.url or document.url | media URL (optional) |
contacts[0].profile.name | stored 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_TOKENis unset, it returns503. - If
hub.modeissubscribeandhub.verify_tokenmatchesMETA_VERIFY_TOKEN(constant-time compare), it echoeshub.challengeback with200. - 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.
| Property | Value |
|---|---|
| Method | POST |
| Content type | application/json |
| Auth header | X-Webhook-Secret, or Authorization: Bearer <secret> |
| Auth scheme | constant-time string equality |
| Secret env var | GENERIC_WEBHOOK_SECRET |
| Success response | 200 { "success": true, "kind": "channel.received" } |
The JSON envelope is forgiving — fields fall back through several aliases:
| Envelope field | Aliases / fallback | Maps to |
|---|---|---|
from | sender, else literal "webhook" | sender identifier |
text | message, content.text | message text |
html | content.html | HTML body (optional) |
subject | content.subject | subject (optional) |
id | messageId | externalMessageId (optional) |
metadata | — | object 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."
| Endpoint | Required secret | Missing → | Bad/absent signature → |
|---|---|---|---|
POST /webhooks/sms | TWILIO_AUTH_TOKEN | 503 | 401 |
POST /webhooks/whatsapp | META_APP_SECRET | 503 | 401 |
GET /webhooks/whatsapp | META_VERIFY_TOKEN | 503 | 403 |
POST /webhooks/channel | GENERIC_WEBHOOK_SECRET | 503 | 401 |
Common status codes across the channel pipeline:
| Status | Meaning |
|---|---|
200 | Accepted (or ignored: true for a no-message payload) |
400 | Body could not be read or parsed |
401 | Missing or invalid signature / shared secret |
403 | Meta verification challenge failed (GET only) |
405 | Method other than POST (channel endpoints) |
429 | Rate limited; honor Retry-After |
503 | Endpoint 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.
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.