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

  • 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
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 Message delivery failed WhatsApp

* 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 (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.
  • 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).
  • All channels use the exact same webhook format.
  • Read cascading: when a user reads a conversation, Fiwano sends a separate 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.
  • 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 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.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 — 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, 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.

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, 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_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 (WhatsApp)

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

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 encrypted at rest during retry and cleared after delivery or expiry

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.

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:

  • Instagramusername, 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). 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.

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.