Working with an AI agent? Download the full documentation as a Markdown file to use as context.
Download full .mdWhen a user messages your connected channel, Fiwano delivers the message — and later its delivery statuses — to your channel's webhook_url as a POST request. This page covers verifying webhooks, the payload formats per channel, downloading inbound media, and looking up a sender's profile.
Set webhook_url and choose which webhook_events to receive when you connect a channel (see Channels); by default no events are enabled. If the channel has a webhook_secret, each delivery is signed so you can verify it came from Fiwano — strongly recommended. Until you set a secret, deliveries are sent unsigned.
For WhatsApp, enable message.received on the channel and point webhook_url at
your public HTTPS endpoint. Fiwano receives the original Meta webhook from the
WhatsApp Cloud API, resolves the connected channel, normalizes the payload, signs
it if you configured a webhook_secret, and delivers it to you.
The useful difference from wiring Meta directly is that the webhook envelope is the same shape across all three channels:
data.from.data.from.data.from.The top-level fields (event, channel_id, channel_type, timestamp, data)
stay stable, so one receiver can handle WhatsApp webhooks, Instagram webhooks and
Messenger webhooks without three separate Meta parsers.
When the channel has a webhook_secret, every webhook request includes an X-Webhook-Signature header:
X-Webhook-Signature: sha256=<hmac_hex>
To verify: compute HMAC-SHA256 of the raw request body using your webhook_secret as the key, then compare the hex digest. (If no secret is configured, this header is absent — set one to enable verification.)
import hmac, hashlib
def verify_signature(body: bytes, secret: str, header: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
received = header.replace("sha256=", "")
return hmac.compare_digest(expected, received)
Each channel type supports a specific set of webhook events. Only events you explicitly enable via webhook_events are delivered. By default, no events are enabled — you must configure them after connecting a channel.
| Event | Description | Channels |
|---|---|---|
message.received |
Incoming message from a user | All |
message.echo |
Copy of a message your business sent outside Fiwano (WhatsApp Business App, Instagram inbox, Facebook Page Inbox, Meta Business Suite, another integration) | All |
message.sent |
Your message was accepted by Meta | |
message.delivered |
Message delivered to recipient's device | WhatsApp, Instagram, Facebook |
message.read |
Message read by recipient * | WhatsApp, Instagram, Facebook |
message.failed |
Message delivery failed |
* message.read depends on the recipient's privacy settings on WhatsApp and Instagram — if they have disabled read receipts, the read status will never arrive. Treat delivered as a terminal success state.
New event types may be added over time; they are never enabled on an existing channel until you add them to webhook_events. Ignore payload fields you do not know — see Compatibility.
When you send text, media, or a WhatsApp template through any send endpoint, you
receive a message_id (UUID). All subsequent status webhooks reference this same
UUID; Meta's provider ID remains internal to Fiwano.
message_id is always present in all status events — it's a UUID generated by Fiwano, not a Meta internal ID.sent → delivered → read. Each status implies all previous ones.data.recipient is the user identifier: phone number (WhatsApp), IGSID (Instagram), or PSID (Facebook).message.read webhook for each unread message — not just the latest one. Facebook and Instagram read receipts are thread-level (Meta reports "read up to this moment", Instagram naming only the last message), so Fiwano resolves them against every message you sent to that user; WhatsApp reports each message on its own.read may reach you before the delivered of the same message. Treat the highest status seen as the current one and upsert by message_id.All payloads share the same top-level structure:
{
"event": "message.received",
"channel_id": "a1b2c3d4e5f67890",
"channel_type": "whatsapp",
"timestamp": "2025-01-15T10:30:00Z",
"data": { ... }
}
{
"event": "message.received",
"channel_id": "a1b2c3d4e5f67890",
"channel_type": "whatsapp",
"timestamp": "2025-01-15T10:30:00Z",
"data": {
"message_id": "wamid.xxx",
"from": "1234567890",
"from_name": "John Doe",
"type": "text",
"text": "Hello!"
}
}
data.from — sender's phone number without +. Use directly as recipient when replying.
{
"event": "message.received",
"channel_id": "b2c3d4e5f6789012",
"channel_type": "instagram",
"timestamp": "2025-01-15T10:30:00Z",
"data": {
"message_id": "mid.xxx",
"from": "6543217890123456",
"from_name": null,
"type": "text",
"text": "Hi there!"
}
}
data.from — IGSID. Use as recipient when replying. from_name is always null (Meta does not include sender name in IG webhooks).
{
"event": "message.received",
"channel_id": "c3f8a1b2e4d56789",
"channel_type": "facebook",
"timestamp": "2025-01-15T10:30:00Z",
"data": {
"message_id": "mid.xxx",
"from": "7890123456789012",
"from_name": null,
"type": "text",
"text": "Hello from Messenger!"
}
}
data.from — PSID. Use as recipient when replying. from_name is always null (Meta does not include sender name in FB webhooks).
With a Pro license, media messages include the file content. The media file is downloaded from Meta and stored temporarily. Use the download_url to fetch the file before it expires.
WhatsApp image example:
{
"event": "message.received",
"channel_id": "a1b2c3d4e5f67890",
"channel_type": "whatsapp",
"timestamp": "2025-01-15T10:30:00Z",
"data": {
"message_id": "wamid.xxx",
"from": "1234567890",
"from_name": "John Doe",
"type": "image",
"caption": "Check this photo",
"media": {
"media_id": "m1b2c3d4e5f67890",
"mime_type": "image/jpeg",
"file_size": 245760,
"filename": null,
"sha256": "abc123...",
"duration_ms": null,
"download_url": "https://fiwano.com/api/v1/media/m1b2c3d4e5f67890",
"expires_at": "2025-01-15T11:30:00Z"
}
}
}
WhatsApp voice message example:
{
"event": "message.received",
"channel_id": "a1b2c3d4e5f67890",
"channel_type": "whatsapp",
"timestamp": "2025-01-15T10:30:00Z",
"data": {
"message_id": "wamid.xxx",
"from": "1234567890",
"from_name": "John Doe",
"type": "audio",
"media": {
"media_id": "m2b3c4d5e6f78901",
"voice": true,
"mime_type": "audio/ogg; codecs=opus",
"file_size": 12345,
"filename": null,
"sha256": "def456...",
"duration_ms": 5200,
"download_url": "https://fiwano.com/api/v1/media/m2b3c4d5e6f78901",
"expires_at": "2025-01-15T11:30:00Z"
}
}
}
Instagram and Facebook Messenger deliver the same data.media block; only data.from differs (IGSID or PSID instead of a phone number).
data.type is the message type on every channel and always comes from one fixed set: text, image, audio, video, document, sticker (WhatsApp only), or unsupported. Route on it. The four media types are exactly the values accepted as the outbound media_type, so an inbound media event can be forwarded without a mapping table — except sticker, which is inbound-only and has to be re-encoded to go out as image.
Instagram and Facebook Messenger also use attachments for things that are not a file, such as a shared post or a location pin. Those arrive as type: "unsupported" with no data.media block — see unsupported type below.
When Meta includes text together with media, Fiwano exposes that accompanying text as data.caption on the media event. Plain text messages continue to use data.text. This rule is the same across WhatsApp, Instagram and Facebook Messenger.
Multiple attachments: every attachment is delivered as its own message.received webhook and its own HTTP POST; Fiwano never sends an array of webhook events. All files from the source message are prepared before the first event is delivered, then the events are sent in Meta's attachment order. The first event keeps Meta's message ID and carries the caption, if present. Later events use deterministic IDs with .2, .3, and so on, and omit the caption:
mid.xxx image + caption
mid.xxx.2 image
mid.xxx.3 video
Treat inbound message_id as an opaque idempotency key; do not parse the suffix or pass the ID to Meta. Delivery retries remain independent per event, so a failing client endpoint can still observe a later part before a retried earlier part. A failed media download does not suppress the other attachments: its event has media.download_url: null and media.error.
Fiwano preserves Meta's original file format and does not transcode media. media.mime_type describes the downloaded file bytes, not the message semantics. For example, Facebook Messenger voice-style clips commonly download as OGG/Opus (audio/ogg), while Instagram audio messages can download as audio-only MP4 served with video/mp4. In both cases the message type is still data.type: "audio".
For inbound WhatsApp only, Meta provides a reliable voice-message flag. Fiwano exposes it as media.voice: true when present. Instagram and Facebook Messenger do not expose an equivalent reliable voice flag through the webhook payload, so media.voice is omitted for those channels.
The download_url is authenticated; fetch it with your X-API-Key. Do not pass it directly as an outbound media_url because Meta will not send your API key header — re-host the bytes behind a public or signed HTTPS URL first.
Media payload fields:
| Field | Type | Description |
|---|---|---|
media_id |
string | Media file ID — use in GET /api/v1/media/{media_id} to download |
voice |
bool | Present only for WhatsApp voice messages (true). Omitted for IG/FB because Meta does not provide a reliable voice flag there. |
mime_type |
string | MIME type (e.g. image/jpeg, audio/ogg; codecs=opus) |
file_size |
int | File size in bytes |
filename |
string|null | Original filename (documents only) |
sha256 |
string|null | SHA-256 hash from Meta (WhatsApp only) |
duration_ms |
int|null | Duration in milliseconds (audio/video only) |
download_url |
string|null | Authenticated download URL. null if download from Meta failed. |
error |
string | Present only when download failed — describes the error |
expires_at |
string | ISO 8601 timestamp — file is deleted after this time |
Note: Treat voice messages as audio messages.
data.type: "audio"is the stable cross-channel value for routing and forwarding.media.voiceis an optional WhatsApp-only hint for UI/UX.
Fetch the file from data.media.download_url (which is GET /api/v1/media/{media_id}) with your X-API-Key:
curl https://fiwano.com/api/v1/media/m1b2c3d4e5f67890 \
-H "X-API-Key: YOUR_API_KEY" \
--output photo.jpg
The response is the raw file bytes with the original Content-Type (and a Content-Disposition filename when known). Files expire about 60 minutes after Fiwano retrieves them from Meta; media.expires_at is authoritative. Download promptly and re-host anything you need to keep; after expiry the URL returns 410 Gone. Sizes are in Capabilities; status codes in the API Reference.
A message arrives as type: "unsupported" when Fiwano cannot give you the content as a file. unsupported_type says what it was, and there is no data.media block. There are two reasons, and upgrade_required tells them apart.
Media on a Starter license. The file exists but your tier does not include it. unsupported_type is the media type Pro would have delivered, and upgrade_required names the tier that unlocks it:
{
"event": "message.received",
"channel_id": "a1b2c3d4e5f67890",
"channel_type": "whatsapp",
"timestamp": "2025-01-15T10:30:00Z",
"data": {
"message_id": "wamid.xxx",
"from": "1234567890",
"from_name": "John Doe",
"type": "unsupported",
"unsupported_type": "image",
"upgrade_required": "pro"
}
}
Upgrade via the Billing page in the portal to receive full media content.
Content that is not a file. No tier delivers these, so upgrade_required is absent. unsupported_type carries Meta's own name for the content:
| Channel | unsupported_type values |
|---|---|
location, contacts, and other non-media message types |
|
| Instagram, Facebook Messenger | share and ig_reel (a shared post or reel), story_mention, location, fallback (a shared link), template, unsupported |
Any type not listed here arrives the same way, so an unfamiliar unsupported_type is still just unsupported content. Message reactions are ignored and are not delivered as webhook events.
When someone on your side answers a customer without going through Fiwano, Meta echoes that message back — and Fiwano can deliver you a copy, so your system sees the whole conversation, not just its own half. Sources per channel:
| Channel | Where the message was sent from |
|---|---|
| WhatsApp Business App or a linked device, on a Coexistence number | |
| Instagram app inbox, Meta Business Suite, or another integration | |
| Facebook Messenger | Facebook Page Inbox, Meta Business Suite, or another integration |
Enable it per channel by adding message.echo to webhook_events (off by default,
available on every plan). Messages sent through Fiwano never arrive as echoes — you
already have them.
{
"event": "message.echo",
"channel_id": "b2c3d4e5f6789012",
"channel_type": "instagram",
"timestamp": "2026-09-01T10:30:00Z",
"data": {
"message_id": "550e8400-e29b-41d4-a716-446655440000",
"recipient": "6543217890123456",
"status": "sent",
"type": "text",
"text": "Operator reply"
}
}
message_id — a Fiwano UUID, exactly like the one you get when sending through
the API. It is stable: if Meta redelivers the same echo, you receive the same
UUID, so deduplicate on it.recipient — the user the message was sent to, in the same format the send
endpoints accept (phone number for WhatsApp, IGSID for Instagram, PSID for
Facebook). You can reply to recipient directly.status: "sent" — the initial lifecycle state. An echo confirms the message
exists in the conversation, not that it reached the recipient's device. No
separate message.sent event is emitted for echoes.Status tracking for echoes. By default an echo is a one-off copy: no
delivered/read follow-ups. Set the channel's echo_statuses field to true
(via PATCH /api/v1/channels/{id} or the Portal) and echoed messages get the same
status lifecycle as messages you send through Fiwano: subsequent
message.delivered / message.read / message.failed webhooks reference the same
echo message_id and are filtered by your webhook_events exactly like ordinary
statuses.
Delivered and read statuses for WhatsApp echoes are delivered the same way as for messages sent through Fiwano. Meta does not formally guarantee status delivery for messages sent from the WhatsApp Business App, so treat a missing status as normal, not as an error.
Instagram has no delivery receipt; the echo itself is the equivalent of the
synthetic delivered Fiwano emits for your own Instagram sends, so no separate
message.delivered follows an Instagram echo. An Instagram read receipt covers
the whole thread: one message.read follows for every echoed message the user
had not read yet, the same way as for messages sent through Fiwano.
Statuses and echoes are delivered independently and at-least-once: a status can
occasionally arrive before the echo it belongs to. Correlate by message_id and
upsert rather than relying on arrival order.
Media in echoes is not delivered. An echoed media message keeps its real
data.type (image, audio, video, document, sticker) and a caption when
present, but the file itself is skipped — data.media arrives with no download:
{
"data": {
"message_id": "550e8400-e29b-41d4-a716-446655440000",
"recipient": "6543217890123456",
"status": "sent",
"type": "image",
"caption": "Invoice photo",
"media": {"media_id": null, "download_url": null, "unavailable": "echo_media_not_supported", "kind": "image"}
}
}
The rule you already apply to inbound media — check media.download_url before
fetching — covers this case with no extra code, and keeps your handler compatible
if echo media becomes available later. Instagram/Messenger multi-attachment
messages are split into separate message.echo events per attachment (each with
its own message_id), and non-file attachments arrive as type: "unsupported"
with unsupported_type — same as message.received.
Not delivered as echoes: reactions, message edits, and message deletions
(unsend). They are changes to an existing message, not new messages, and are
silently skipped. On WhatsApp, echoes exist only for Coexistence numbers — a
channel connected purely through the Cloud API has no source of external messages,
so message.echo never fires there.
Warning: never mirror an echo back into the same conversation automatically. Your reply would generate no echo (Fiwano sends are filtered out), but a bot on the other side — or a second integration mirroring echoes too — can create a loop. Always deduplicate by
message_idbefore acting on an echo.
{
"event": "message.read",
"channel_id": "b2c3d4e5f6789012",
"channel_type": "instagram",
"timestamp": "2025-01-15T10:30:10Z",
"data": {
"message_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "read",
"recipient": "6543217890123456"
}
}
Same format for all channels and all statuses (sent, delivered, read). message_id is the UUID from the send response.
{
"event": "message.failed",
"channel_id": "a1b2c3d4e5f67890",
"channel_type": "whatsapp",
"timestamp": "2025-01-15T10:30:05Z",
"data": {
"message_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"recipient": "1234567890",
"status": "failed",
"error": "Message undeliverable",
"errors": [{"code": 131047, "title": "Message undeliverable"}]
}
}
If your webhook URL returns a non-2xx status or is unreachable, the system retries automatically:
Important: Your endpoint must respond with HTTP 2xx within 5 seconds. Non-2xx responses or timeouts trigger the retry queue. Fiwano does not retain successfully delivered webhook payloads for relay; after a failed initial delivery, the encrypted payload is stored temporarily for automatic retries.
Tip: Only enable the webhook events you actually handle. Unhandled events that receive non-2xx responses will fill your retry queue unnecessarily.
WhatsApp includes the sender's name inline in every webhook (data.from_name) — no extra call needed. Instagram and Facebook do not (data.from_name is always null); to get a name or avatar, call the profile endpoint:
GET /api/v1/channels/{channel_id}/profile/{user_id}
Pass the data.from value (IGSID for Instagram, PSID for Facebook) as user_id. It returns:
username, name, profile_pic, follower_count, is_verified_userfirst_name; last_name and profile_pic when available from MetaWhatsApp is not supported (the name is already in the webhook). Successful results are cached for 5 minutes; unavailable results are cached briefly so a newly indexed conversation can be retried soon. The response's cached flag tells you if it was a cache hit. Full request/response and status codes are in the API Reference.
Tip: call this once when you first see a new
data.from, then cache the result on your side — no need to call it on every message.
Set a webhook_url on the connected WhatsApp channel and enable message.received in webhook_events. Fiwano then sends each inbound WhatsApp message to your endpoint as a signed POST with a normalized JSON payload.
The top level always includes event, channel_id, channel_type, timestamp and data. For WhatsApp text messages, data includes message_id, from, from_name, type and text. Media messages include a temporary authenticated download_url on Pro.
Yes. Fiwano normalizes WhatsApp, Instagram DM and Facebook Messenger into the same event envelope. The sender identifier differs by channel — phone number, IGSID or PSID — but the webhook shape stays consistent.
Set a webhook_secret on the channel, compute HMAC-SHA256 over the raw request body with that secret and compare it to the X-Webhook-Signature header.