Get in Touch

Have a question about the platform, need help with your integration, or want to discuss partnership opportunities and enterprise pricing? Drop us an email — we’ll do our best to get back to you within 3–4 hours.

contact@fiwano.com
Documentation menu

Working with an AI agent? Download the full documentation as a Markdown file to use as context.

Download full .md

Receiving Messages

When 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.

WhatsApp webhook setup and payload

For WhatsApp, enable message.received on the channel and point webhook_url at your public HTTPS endpoint. The webhook envelope has the same shape on all three channels:

  • WhatsApp senders arrive as phone numbers in data.from.
  • Instagram senders arrive as IGSID values in data.from.
  • Facebook Messenger senders arrive as PSID values in 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.

Verifying Signatures

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)

Event Types

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
conversation.referral A returning user clicked an ad or an m.me / ig.me link into an existing conversation without writing; carries the same referral block as message.received and reopens the 24-hour window (beta) Instagram, Facebook
message.sent Your message was accepted by Meta WhatsApp
message.delivered Message delivered to recipient's device WhatsApp, Instagram, Facebook
message.read Message read by recipient * WhatsApp, Instagram, Facebook
message.failed Your message could not be delivered WhatsApp, Instagram, Facebook

* 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.

Delivery Status Tracking

When you send text, media, or a WhatsApp template through any send endpoint, you receive a message_id (a Fiwano UUID). Every status webhook for that message carries the same message_id, in the same format on all channels.

  • Status progression: sent → delivered → read. Each status implies all previous ones.
  • data.recipient is the user identifier: phone number (WhatsApp), IGSID (Instagram), or PSID (Facebook).
  • Read cascading: when a user reads a conversation, Fiwano sends a separate message.read webhook for each unread message you sent to that user — not just the latest one.
  • Statuses can arrive out of order — on Instagram a 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.

Payload Format

All payloads share the same top-level structure:

{
  "event": "message.received",
  "channel_id": "a1b2c3d4e5f67890",
  "channel_type": "whatsapp",
  "timestamp": "2025-01-15T10:30:00Z",
  "data": { ... }
}

message.received (WhatsApp)

{
  "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.

message.received (Instagram)

{
  "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).

message.received (Facebook Messenger)

{
  "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).

message.received — media (Pro)

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: text, image, audio, video, document, share, or unsupported. Route on it. Should a type your integration does not recognize ever appear, handle it like unsupported (see Compatibility). The four media types are exactly the values accepted as the outbound media_type, so no type mapping is needed to forward a media message — only the file has to be re-hosted, because download_url is authenticated (see Downloading inbound media).

Stickers arrive as image on every channel, with media.sticker: true so you can tell them from photos. A WhatsApp sticker is a WebP file (mime_type: "image/webp"); a Messenger sticker also carries Meta's persistent media.sticker_id (369239263222822 is the thumbs up). Instagram does not deliver stickers at all. To send a sticker back use media_type: "sticker" — forwarding a WebP as image is rejected by WhatsApp.

Instagram and Facebook Messenger also use attachments for things that are not a file. A shared post, reel or story mention arrives as type: "share"; a location pin or a product card arrives 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. 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".

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.
sticker bool Present only when the image is a sticker (true) — WhatsApp (WebP) and Facebook Messenger.
sticker_id string Facebook Messenger stickers only — Meta's persistent sticker id. Send it back with media_type: "sticker".
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.voice is an optional WhatsApp-only hint for UI/UX.

Downloading inbound media

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.

message.received — button taps and menu choices (all channels)

When a user picks one of the options you offered, the choice arrives as an ordinary text message whose text is the label they saw — a WhatsApp template quick-reply button, a WhatsApp interactive reply button or list row, an Instagram or Messenger quick reply, and Messenger / Instagram postbacks (Get Started, ice breakers, persistent menu, template buttons):

{
  "event": "message.received",
  "channel_id": "a1b2c3d4e5f67890",
  "channel_type": "whatsapp",
  "timestamp": "2025-01-15T10:31:00Z",
  "data": {
    "message_id": "wamid.yyy",
    "from": "1234567890",
    "from_name": "John Doe",
    "type": "text",
    "text": "Confirm",
    "reply_to": {"message_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}
  }
}

There is no separate event and no machine id for the button: one text handler covers typed answers and taps alike. Which message the button belonged to is in reply_to — on WhatsApp a template tap always quotes the template send, so reply_to.message_id is the UUID you got from send-template.

message.received — shared posts, reels and story mentions

On Instagram and Facebook Messenger a user can share a post or a reel into the conversation, or mention your account in their story. These arrive as type: "share":

{
  "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": "share",
    "share_type": "post",
    "caption": "Sunset at the pier",
    "share": {"url": "https://www.instagram.com/p/ABC123/", "expires_at": null}
  }
}
Field Description
share_type post (a shared post), reel (a shared reel), story_mention (Instagram: the user mentioned you in their story)
share.url Meta's link to the shared content. A post or reel link opens in Instagram / Facebook; a story mention links to the story media itself
share.expires_at story_mention only: the story disappears about 24 hours after it was posted (possibly earlier). null for posts and reels
caption The caption of the shared post or reel, when Meta provides it. Absent for story mentions

Shared content is not downloaded and there is no data.media block: a post belongs to its author, and Meta does not allow apps to store story media. A user's own photo or video sent as a message is still an ordinary image / video event.

Replies and quoted messages — reply_to

When a user replies to a specific message (WhatsApp "Reply", Instagram / Messenger swipe-to-reply, a WhatsApp template button tap), the event carries reply_to next to type:

"reply_to": {"message_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}

reply_to.message_id is the id you already have for the quoted message: the Fiwano UUID if it is a message you sent through Fiwano (or received as message.echo), or the provider id from data.message_id if it is an earlier message from the user. Compare it with the ids you stored and you know which message was quoted; there is no separate origin flag. For a message with several attachments it is the id of the first part. In the rare case where a reply reaches Fiwano before the quoted send is recorded, the provider id is passed through as is.

An Instagram reply to your story carries the story instead of a message id:

"reply_to": {
  "story": {
    "id": "17900000000000009",
    "url": "https://lookaside.fbsbx.com/…/story.jpg",
    "expires_at": "2025-01-16T10:30:00Z",
    "link_url": "https://shop.example/promo"
  }
}

story.id is the media id of your story, story.url a temporary link to its media, story.expires_at the end of its 24-hour lifetime, and story.link_url the link sticker the user tapped, when there was one (otherwise null).

reply_to is present on message.received and on message.echo (an operator replying to a specific message), on all three channels and for every message type. It is absent when the message is not a reply.

Referral context — ads and links (beta)

When a conversation starts from a Click-to-WhatsApp, Click-to-Instagram or Click-to-Messenger ad, or from an m.me / ig.me link with a ref parameter, Meta attaches attribution to the first inbound event. Fiwano passes it on as data.referral on the message.received that follows the click — normally the first message of the conversation; an ice breaker or Get Started tap on Instagram / Messenger arrives as type: "text" and carries it the same way. For a message with several attachments it is on the first part only.

Beta until November 2026. The four normalised keys (source, text, image_url, ref) and the conversation.referral event may be adjusted; raw is guaranteed to stay exactly as it is, so anything built on raw is safe. If you plan to rely on the normalised keys or on conversation.referral, tell us at support@fiwano.com: should anything change, we will let you know before it does.

"referral": {
  "source": "ad",
  "text": "Chat with us\nSummer Succulents are here!",
  "image_url": "https://scontent.xx.fbcdn.net/v/t45.1/...",
  "ref": null,
  "raw": {
    "source_url": "https://fb.me/3cr4Wqqkv",
    "source_id": "120226305854810726",
    "source_type": "ad",
    "headline": "Chat with us",
    "body": "Summer Succulents are here!",
    "media_type": "image",
    "image_url": "https://scontent.xx.fbcdn.net/v/t45.1/...",
    "ctwa_clid": "Aff-n8ZTODiE79d22KtAwQKj9e_mIEOOj27vDVwFjN80dp4...",
    "welcome_message": {"text": "Hi there! Let us know how we can help!"}
  }
}
Field Meaning
source Where the user came from. ad — a paid ad (including Story placements); link — an m.me / ig.me link with ref; product — an Instagram Shop product page. The set is open: another source Meta reports is passed through in lower case, unknown means Meta sent none.
text The ad copy the user saw. WhatsApp: headline and primary text joined by a newline. Instagram / Messenger: the ad title Meta provides. null for links.
image_url The creative as an image: the picture of an image ad or the thumbnail of a video ad. null when Meta sends none.
ref Your own marker from the ref parameter of an m.me / ig.me link or of an Instagram / Messenger ad. Always null on WhatsApp.
raw Meta's referral object exactly as received: source_id / ad_id, source_url, post_id, ctwa_clid, headline / body / ad_title, welcome_message, flow_id, product. Field names differ per channel; see Meta's reference for your channel.

text and image_url are meant for your model directly: the user is replying to an ad that said this and looked like this.

A returning user — conversation.referral (Instagram, Messenger). When a user who already has a conversation with you clicks an ad or an m.me / ig.me link without writing, Meta sends the attribution as a separate event with no message. The click reopens the 24-hour window, so you may reply. Enable conversation.referral in webhook_events to receive it; the payload is data.from, data.from_name and the same referral block. WhatsApp has no equivalent: there the attribution always arrives with a message.

{
  "event": "conversation.referral",
  "channel_id": "a1b2c3d4e5f67890",
  "channel_type": "instagram",
  "timestamp": "2026-09-21T10:30:00Z",
  "data": {
    "from": "17841400000000777",
    "from_name": null,
    "referral": {"source": "link", "text": null, "image_url": null, "ref": "spring_promo", "raw": {"ref": "spring_promo", "source": "SHORTLINKS", "type": "OPEN_THREAD"}}
  }
}

What to expect from Meta:

  • One-shot. The block is attached to the event that follows the click and is not repeated on later messages. Store it on the conversation when it arrives; Fiwano keeps no message history.
  • Creative links are temporary. image_url and the URLs in raw are public signed Meta CDN links that need no token. Meta does not document their lifetime: fetch the image when the event arrives if you want to keep it.
  • Attribution can be incomplete. Meta omits raw.ctwa_clid for ads placed in WhatsApp Status and may omit it for clicks from a web browser, after the ad was deleted, or when the user dismissed the ad context before writing. An absent click id does not mean an organic conversation.
  • Only ads and ref links carry attribution. A message from the profile button, the link in an Instagram bio, a wa.me link or a QR code is an ordinary message without referral.
  • raw.welcome_message.text (WhatsApp) is the greeting configured in the ad. WhatsApp shows it in the chat before the user writes; it is not sent through the API, so there is no outbound message and no echo for it.

Fiwano does not call Meta's Conversions API. To attribute a sale to a Click-to-WhatsApp ad, store raw.ctwa_clid and raw.source_id when the conversation starts and send the conversion event yourself within Meta's 7-day window (action_source: business_messaging, messaging_channel: whatsapp, user_data.ctwa_clid unhashed, user_data.whatsapp_business_account_id). On Instagram and Messenger use raw.ad_id for your own reporting; Meta has no click id for these channels.

message.received — unsupported type (all channels)

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
WhatsApp location, contacts, order (catalog order), system (for example a number change), edit and revoke (a message edited or deleted in the WhatsApp Business app), nfm_reply (a WhatsApp Flow response), poll_creation, poll_update, gif, group_invite, and any other type WhatsApp reports
Instagram template (a product or card shared from a catalog), ephemeral (a view-once photo or video — Meta does not deliver the content), unsupported (Instagram itself could not deliver the content)
Facebook Messenger template, location, appointment_booking, fallback (a shared link that came without a URL), unsupported

Any type not listed here arrives the same way, so an unfamiliar unsupported_type is still just unsupported content. Message reactions, message edits and deletions, and messages in WhatsApp groups are not delivered as webhook events. A Messenger link preview never becomes unsupported: the message text with the link is delivered as text, and a forwarded link without text arrives as text containing the URL.

message.echo — messages sent outside Fiwano

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 WhatsApp Business App or a linked device, on a Coexistence number
Instagram 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.
  • Who exactly sent the message (which operator, device, or app) is not exposed — Meta does not provide a reliable identity for it.

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; a sticker is image with media.sticker: true) 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); a shared post or reel arrives as type: "share" and other non-file attachments as type: "unsupported" with unsupported_type — same as message.received. An echo of a reply carries reply_to: when an operator answers a specific customer message, reply_to.message_id is that message's provider id.

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_id before acting on an echo.

message.delivered / message.read (all channels)

{
  "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.

message.failed (all channels)

{
  "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"}]
  }
}

Sent when a message you sent is not delivered after the send call returned:

  • the send returned queued and Fiwano could not complete it — Meta kept rejecting it until the retries ran out, rejected it permanently on a retry, never confirmed it, or the channel was disconnected while the message waited;
  • on WhatsApp, also when Meta accepted the message and reports later that it could not deliver it.

A send that returns failed right away produces no webhook — the response already tells you. error is a short text reason, always present. errors is an array with Meta's full error details — code, title, message, and error_subcode / error_data.details when Meta gave them — present only when Meta caused the failure. message.failed is final: no other status follows it for the same message_id.

Retry Policy

If your webhook URL returns a non-2xx status or is unreachable, the system retries automatically:

  • 7 retry attempts with exponential backoff: 30s, 1m, 2m, 2m, 2m, 2m, 2m (~12 minutes total)
  • 20-minute hard deadline — after which the delivery is marked as permanently failed
  • Email warning sent after the 3rd failed attempt (retries still in progress)
  • Email alert sent when all retries are exhausted (permanent failure)
  • Payloads are stored encrypted only while a delivery is being retried, and deleted once it succeeds or expires
  • In the portal, the channel's Delivery problems page (Webhooks tab) lists the last 7 days of deliveries that are retrying, failed, or recovered after 3+ attempts, with your endpoint's error (payloads are never shown)

Important: Your endpoint must respond with HTTP 2xx within 5 seconds. Non-2xx responses or timeouts trigger the retries above.

Respond first, then process. Do slow work, such as generating an AI reply, after you return 2xx (on serverless platforms, with the platform's background-task mechanism). A slow response is retried and delivers the same event again — deduplicate message.received on message_id.

Tip: Only enable the webhook events you actually handle. Unhandled events that receive non-2xx responses will fill your retry queue unnecessarily.

Sender Profile

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:

  • Instagram — username, name, profile_pic, follower_count, is_verified_user
  • Facebook — display name in first_name; last_name and profile_pic when available from Meta

WhatsApp is not supported (the name is already in the webhook). Results are cached for up to 5 minutes; 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.

Frequently asked questions

How do I receive WhatsApp messages with a webhook?

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.

What does a WhatsApp webhook payload look like?

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.

Does the same webhook format work for Instagram and Messenger?

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.

How do I verify a Fiwano webhook signature?

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.