Skip to main content

Inbound webhooks

Inbound webhooks are how messages and delivery receipts from outside systems enter Converger: WhatsApp Cloud API (Meta), Infobip, or your own services posting to a generic webhook channel. Each channel has its own URL, and requests are authenticated by a signature over the raw body, not by an API key. All three endpoints are handled by ConvergerWeb.InboundController. How each channel type parses its payload is covered in channels.

Endpoints​

MethodPathPurposeRate limited
GET/api/v1/channels/:channel_id/inboundWebhook verification handshake (Meta)no
POST/api/v1/channels/:channel_id/inboundMessages, and status updates sent to the same URLinbound bucket
POST/api/v1/channels/:channel_id/statusDelivery and read receipts onlyinbound bucket

:channel_id is the channel's UUID; a malformed id returns 400 bad_request. The channel must exist (404) and be active (400 channel_inactive).

Rate limit: inbound, 500 requests per second per channel by default, counted on the path's channel_id before the channel is loaded or the signature is checked, so a flood is rejected cheaply. Tenants can override it through tenants.limits; see rate limiting.

Which channel types accept which requests:

Channel typeMessages on /inboundSignature scheme
webhookyes (modes inbound, duplex)x-converger-signature
whatsapp_metayes (modes inbound, duplex)x-hub-signature-256 (Meta app secret)
whatsapp_infobipyes (modes inbound, duplex)x-converger-signature
slackyes (modes inbound, duplex); url_verification is answered with its challengex-slack-signature (signing secret, 5 minute window)
telegramyes (modes inbound, duplex)x-telegram-bot-api-secret-token (equal to the channel's secret_token; a missing or wrong header is 401 once one is set)
emailyes (modes inbound, duplex)per inbound provider: SNS signature and topic (ses), payload signature (mailgun), basic auth or x-converger-signature (others); see Email
sms_twilioyes (modes inbound, duplex); form-encoded, answered with an empty TwiML <Response>x-twilio-signature (auth token over the URL and the sorted parameters); see SMS
sms_vonageyes (modes inbound, duplex)Authorization: Bearer JWT (signature secret) with the body hash; see SMS
messenger, instagramyes (modes inbound, duplex)x-hub-signature-256 (Meta app secret); see Messenger
viberyes (modes inbound, duplex)x-viber-content-signature (auth token; a missing or wrong header is always 401); see Viber
discordyes (modes inbound, duplex); PING is answered with PONG, interactions with an interaction responsex-signature-ed25519 and x-signature-timestamp (application public key; always required); see Discord
teamsyes (modes inbound, duplex)Authorization: Bearer Bot Framework JWT (always required); see Microsoft Teams
kafka, rabbitmq, nats, redis_streams, mqttno: 400 with the adapter's message (broker channels consume their topic, queue or stream, see Broker channels)x-converger-signature
echo, websocket, http_pullno: 400 with the adapter's messagex-converger-signature

A channel in outbound mode answers messages with 400 inbound_not_supported. If the same request also carried status updates, those are applied and the request is acknowledged with 200, dropping the messages. The /status endpoint works for every mode, so outbound-only channels can still receive receipts.

Signatures​

Every POST is verified over the exact bytes received. The body is cached by ConvergerWeb.CacheBodyReader for every path under /api/v1/channels/, before JSON parsing. Design and rollout are recorded in ADR-0009.

Generic scheme: x-converger-signature​

Used by every channel type without a provider-native scheme (Converger.Channels.InboundSignature):

x-converger-signature: t=1760011200,v1=5f2c0e8a4b...e91d
  • t is the Unix time in seconds when the request was signed.
  • v1 is the lowercase hex HMAC-SHA256 of "<t>.<raw body>", keyed with the channel secret.
  • Several v1= values may be sent (t=...,v1=<new>,v1=<old>); the request is accepted if any of them matches, which allows rotating the secret without downtime.
  • t must be within 300 seconds of the server clock in either direction (config :converger, :inbound_signature_tolerance_seconds), which limits replay of captured requests.

This is the same format Converger uses to sign its outbound webhooks, so one implementation serves both directions; see outbound webhooks for verification snippets.

Signing a request with openssl:

SECRET='channel-secret'
BODY='{"sender":"crm-42","text":"Hello from the CRM","idempotency_key":"evt-1001"}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')

curl -s -X POST "$CONVERGER/api/v1/channels/$CHANNEL_ID/inbound" \
-H "content-type: application/json" \
-H "x-converger-signature: t=$T,v1=$SIG" \
--data-raw "$BODY"

Sign the exact bytes you send: re-serializing the JSON after signing (key order, whitespace) breaks the signature.

The legacy format x-converger-signature: sha256=<hex HMAC-SHA256 of the raw body> has no timestamp and no replay protection. It is recognised but treated like a missing signature (see below).

WhatsApp Meta: x-hub-signature-256​

whatsapp_meta channels verify Meta's own header, x-hub-signature-256: sha256=<hex HMAC-SHA256 of the raw body>, keyed with the channel config's app_secret. The generic header is not accepted in its place. A channel with require_signature: true cannot be saved without an app_secret.

Enforcement: require_signature​

Each channel has a require_signature flag (default true for new channels; toggled per channel in the admin panel).

Requestrequire_signature: truerequire_signature: false
Valid current signatureacceptedaccepted
Signature present but invalid (wrong secret, tampered body, malformed header, timestamp out of tolerance)401401
No signature (or Meta channel without app_secret)401accepted, logged as DEPRECATED
Legacy sha256= generic signature401accepted, logged as DEPRECATED

A rejected signature returns:

{ "error": { "code": "unauthorized", "message": "Unauthorized", "details": null, "request_id": "GBHDfGZ0bW5Sl2YAAAEB" } }

require_signature: false exists for channels created before signatures were enforced. Unsigned requests will be rejected in a future release, so sign requests and enable the flag.

Meta verification handshake​

GET /api/v1/channels/:channel_id/inbound

When you register the callback URL in the Meta app dashboard, Meta sends a GET with hub.mode, hub.verify_token and hub.challenge. For a whatsapp_meta channel, Converger compares hub.verify_token with the channel config's verify_token (constant-time):

CaseResponse
Token matches200, body is the hub.challenge value verbatim (plain text)
Token missing or different403 forbidden: Verification failed
Any other channel type200, body ok
curl -s "$CONVERGER/api/v1/channels/$CHANNEL_ID/inbound?hub.mode=subscribe&hub.verify_token=my-verify-token&hub.challenge=1158201444"
# 1158201444

The handshake is not signed and not rate limited. hub.mode is not checked.

Messages: POST /inbound​

The channel's adapter parses the body into a list of messages (and, for providers that mix them, status updates). Messages are then processed in order; see idempotency and batches.

Generic webhook payload​

A webhook channel carries one message per request (Converger.Channels.Adapters.Webhook):

FieldTypeDefaultNotes
textstringAliases: message, body (first present wins)
senderstring"external"Alias: from. Stored as the activity's sender.
typestringmessageActivity type, validated like any activity
attachmentsarray[]Attachment objects, validated as for activities: each needs a contentType (attachment schema)
reply_to_idUUIDAn activity of the same conversation this message replies to, or that a messageReaction / messageUpdate / messageDelete targets (references)
metadataobject{}
idempotency_keystringMakes re-delivery safe; see below
conversation_idUUIDPost into this conversation (it must belong to the channel's tenant)
external_idstringThe external party's id. Without conversation_id, messages with the same external_id join that participant's active conversation.
display_namestringParticipant display name, stored with external_id

A message with neither text nor attachments is rejected with 422:

{ "error": "Inbound message has no text and no attachments" }

A body that contains status together with delivery_id or provider_message_id is treated as a status update instead of a message (see status updates).

curl -s -X POST "$CONVERGER/api/v1/channels/$CHANNEL_ID/inbound" \
-H "content-type: application/json" \
-H "x-converger-signature: t=$T,v1=$SIG" \
--data-raw '{"sender":"crm-42","text":"Hello from the CRM","external_id":"customer-981","display_name":"Ada","idempotency_key":"evt-1001"}'

Conversation resolution​

For each message, in order of precedence:

  1. An explicit conversation_id in the request (tenant-scoped). Unknown: 404. Closed: 409 conversation_closed.
  2. A participant (external_id for generic webhooks; the sender's phone number for WhatsApp): the participant's active conversation on this channel, or a new one. A closed conversation is never reused; the next message starts a new conversation for the same participant. See ADR-0016 and participants.
  3. Otherwise, a new conversation without participant, with metadata: {"source": "inbound_webhook"}. Every such message starts its own conversation.

Provider payloads​

whatsapp_meta accepts Cloud API webhook bodies ({"object": "whatsapp_business_account", "entry": [...]}) and whatsapp_infobip accepts Infobip's {"results": [...]} bodies. One request can carry several messages, and for Meta also statuses alongside them. The sender is the phone number, the provider message id (wamid... for Meta, messageId for Infobip) becomes the activity's idempotency key, and media arrive as attachment stubs. Field mapping is documented per adapter in WhatsApp. A body the adapter cannot parse at all returns 400 with a message such as bad_request: unable to parse WhatsApp Meta webhook payload. A well-formed body without messages (for example a statuses-only Meta webhook) is acknowledged with 200.

email channels accept the inbound mail webhooks of Amazon SES (through SNS), SendGrid Inbound Parse, Mailgun routes and Postmark, or a raw message/rfc822 body (text/plain and other bodies that are neither JSON, form nor multipart are read raw, up to the upload size limit plus 1 MB). A reply joins the conversation of the email it answers. See Email.

Response​

{
"status": "accepted",
"activity_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"activity_ids": ["a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"],
"duplicates": 0,
"rejected": 0,
"receipts_processed": 0
}
FieldMeaning
activity_idFirst accepted activity (created or duplicate), null if none
activity_idsAll accepted activities, in message order. Duplicates report the id of the existing activity.
duplicatesMessages already known by their idempotency key
rejectedMessages rejected permanently (invalid activity or participant) and skipped
receipts_processedStatus updates in the same request that matched a known delivery

Status codes:

StatusWhen
201 CreatedGeneric channel types, at least one new activity created
200 OKGeneric channel types with only duplicates; whatsapp_meta, whatsapp_infobip, slack, telegram, email, sms_twilio, sms_vonage, messenger, instagram, viber, discord and teams always, once the request has been handled (also when messages were rejected), because those providers retry any non-200 response
200 OK with only status and receipts_processedStatus-only request
422Generic channel types where every message was rejected: the first rejection's changeset errors, for example invalid_message with details {"type": ["is invalid"]}; or the empty-message error above
503A transient failure (delivery jobs could not be enqueued): unavailable. The provider should re-deliver.

Status updates​

Delivery receipts update the status of an outbound delivery. They arrive either at POST /status or, for providers that use one URL for everything (WhatsApp), mixed into POST /inbound. Status updates in an /inbound request are applied best-effort before its messages.

Generic payload (webhook channels, and the shape for /status on every generic-signed channel type):

FieldRequiredNotes
statusyessent, delivered, read or failed
provider_message_idone of the twoThe id the provider returned when Converger sent the message, scoped to this channel
delivery_idone of the twoConverger's delivery id, as sent in the outbound x-converger-delivery-id header
timestampnoISO 8601 or Unix seconds; sets sent_at / delivered_at / read_at
errornoStored as the delivery's last_error for failed
BODY='{"delivery_id":"6b1d...","status":"read","timestamp":"2026-10-09T12:07:00Z"}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')

curl -s -X POST "$CONVERGER/api/v1/channels/$CHANNEL_ID/status" \
-H "content-type: application/json" \
-H "x-converger-signature: t=$T,v1=$SIG" \
--data-raw "$BODY"

200 OK:

{ "status": "accepted", "receipts_processed": 1 }

Statuses only move forward (pending, sent, delivered, read): a delivered that arrives after read is ignored, and failed is applied unless the delivery was already read. A receipt for an unknown message is not an error; it is just not counted, so receipts_processed can be 0 with a 200. Ignored stale receipts for a known delivery are counted as processed.

warning

Send /status only bodies the channel's adapter recognises as status updates. A body that is not one (for a generic channel: missing status or both identifiers) is currently not handled gracefully and results in a server error.

Idempotency and batches​

Providers retry webhooks (Meta for days) and batch several messages into one request. Converger therefore treats a batch as a sequence of independent, idempotent messages rather than as one transaction (ADR-0015):

  • Each message is processed in order, in its own transaction (activity plus its delivery jobs, ADR-0001).
  • Before anything else, a message's idempotency key is looked up across all conversations of the channel. A known key counts as handled (duplicates) and reports the existing activity id, even if that conversation has been closed since.
  • A message rejected permanently (invalid activity or participant) is logged and skipped (rejected); retrying it would never succeed.
  • A transient failure stops processing and returns an error, so the provider re-delivers the whole batch. Messages committed before the failure are recognised as duplicates on re-delivery and the remaining ones are created, in order.

Idempotency keys:

Channel typeKey
whatsapp_metaThe message id (wamid...)
whatsapp_infobipmessageId
slack"<channel>:<ts>" for messages (so a message and an app_mention of the same message are one activity); edits, deletes and reactions have their own keys, see Slack
telegram"<chat_id>:<message_id>" for messages; edits, button presses and reactions have their own keys, see Telegram
emailThe Message-ID header, without angle brackets
sms_twilioMessageSid
sms_vonagemessage_uuid
messenger, instagramThe message mid; edits, unsends and reactions have their own keys, see Messenger
vibermessage_token
discordinteraction:<id>
teams"<conversation hash>:<activity id>", see Microsoft Teams
webhookThe optional idempotency_key body field. Without it, a re-delivered request creates a second activity.

Re-sending the same generic message with the same idempotency_key returns 200 (instead of 201) with duplicates: 1 and the original activity_id.

Errors​

StatusError (code: message)Cause
400bad_requestchannel_id is not a UUID
400channel_inactiveChannel disabled
400inbound_not_supportedChannel mode is outbound
400bad_request: <adapter message>Payload not parseable by the channel type, or a channel type without inbound support
401unauthorizedSignature invalid, or missing / legacy on a require_signature channel
403forbidden: Verification failedMeta handshake with a wrong hub.verify_token
404not_foundUnknown channel, or unknown conversation_id
409conversation_closedExplicit conversation_id of a closed conversation
422invalid_message: Inbound message has no text and no attachments or changeset errorsInvalid generic message
429rate_limited + retry-afterChannel's inbound limit exceeded
503unavailableTransient failure; re-deliver