Webhooks
Each incoming message or status update becomes one event for a signed POST to your endpoint. Delivery is at least once, so the same event can be posted more than once.
Each agent has one webhook endpoint, configured in the dashboard. Its URL must use https:// on port 443 with a public host. Private, loopback, link-local, and metadata addresses are refused; destination addresses are checked at connection time, and redirects are never followed.
An event ingested without an enabled endpoint is stored but has no delivery. Adding or enabling the endpoint later does not backfill it, and it cannot be redelivered. Configure your endpoint before you start messaging the agent.
Headers
Header names are case-insensitive.
| Header | Value | Meaning |
|---|---|---|
Content-Type | application/json | UTF-8 event JSON. |
X-Unpoll-Event-Id | UUID | Event ID, stable across retries and manual redeliveries. |
X-Unpoll-Delivery-Id | UUID | Delivery ID shown in the dashboard, also stable across retries and redeliveries. |
X-Unpoll-Timestamp | Unix seconds | Time this HTTP attempt was signed, as canonical nonnegative decimal. |
X-Unpoll-Signature | v1=<lowercase hex> | HMAC-SHA256 of the timestamp, a period, and the exact raw body bytes. |
The MAC authenticates the timestamp and body only, not the separate ID headers. Deduplicate on the authenticated body's id. If you use X-Unpoll-Event-Id, compare it with the verified body; do not treat X-Unpoll-Delivery-Id as authenticated data.
Verify the signature before parsing the event →
Body
{
"id": "0199f0c6-2c3d-7e4f-8a50-61728394a5b6",
"version": "2026-10-01",
"type": "message.received",
"agent_id": "0199f0c4-1b2c-7d3e-8f40-516273849abc",
"created_at": "2026-10-19T09:10:02.345678Z",
"data": {
"from": "user:50972923564215",
"id": "wamid.HBgONTA5NzI5MjM1NjQyMTUVEgASGBQzQTVGOUMyRTFCN0Q0MDY4QUYzMQA=",
"timestamp": "1792401001",
"type": "text",
"text": { "body": "Hello agent" }
}
}
The envelope has exactly these six fields:
| Field | Type | Meaning |
|---|---|---|
id | UUID string | Stable Unpoll event ID. Deduplicate on it. |
version | string | Body schema version, currently 2026-10-01. A different envelope shape gets a new version. |
type | string | message.received or message.status. |
agent_id | UUID string | Unpoll agent the event belongs to. |
created_at | timestamp | When Unpoll stored the event, as RFC 3339 UTC with any stored fractional seconds. WhatsApp's occurrence time remains in data.timestamp. |
data | object | WhatsApp's single message or status object, passed through unchanged, including unknown future provider fields. Not the complete polling response. |
Retries and manual redelivery reuse the exact same body bytes, event ID, and delivery ID, including after a restart. Each attempt uses its current signing timestamp and endpoint secret; the timestamp and signature can change. Attempts in the same second with the same key and body can legitimately have identical signatures.
The envelope contains no WhatsApp token, webhook secret, or media bytes. Message content and unknown provider fields are customer data, not safe diagnostic text: do not log whole payloads.
message.received
The provider's message object in data includes:
| Field | Meaning |
|---|---|
from | Sender as user:<id>. Copy it unchanged into a reply's to field. Treat it as opaque, not as a phone number or permanent person ID. |
id | WhatsApp message ID (wamid). |
timestamp | WhatsApp occurrence time: Unix seconds as a string. |
type | text, image, audio, video, document, reaction, or sticker. |
Object named after type | Type-specific content, for example text.body. |
context | Present when quoting an earlier message; contains its id and from. |
Media objects contain WhatsApp's metadata and media ID, never the file. Images, videos, and documents may include a caption; documents may include a filename; audio can include a voice-note flag. Download received files through the media route.
Reactions are receive-only. Their reaction object has message_id and emoji; an empty emoji means the reaction was removed. Unpoll's send route supports text only.
message.status
A delivery or read receipt for a message your agent sent has a data object like this:
{
"id": "wamid.HBgONTA5...",
"status": "read",
"recipient_id": "user:50972923564215",
"timestamp": "1792401060"
}
data.id is the message_id returned by the send API. status is delivered or read; ignore status values you do not know. recipient_id is opaque, and timestamp is the provider's Unix-second string.
Events can arrive out of order. Use the provider's data.timestamp when occurrence time matters, rather than assuming delivery order or Unpoll's storage time is the message order.