Developer quickstart
Connect. Receive. Reply.
A webhook for incoming WhatsApp messages. One API call to reply.
https://unpoll.ccv1Connect your agent
Connect an agent with your WhatsApp agent token. Stop any other poller for that agent first.
In your workspace, open Manage API keys, then Create API key. Copy the agent UUID from the end of your agent’s details page URL (/agents/AGENT_UUID).
export UNPOLL_API_KEY='YOUR_UNPOLL_API_KEY'
export UNPOLL_AGENT_ID='YOUR_LOCAL_AGENT_UUID'
Keep the API key on your server. It is different from your WhatsApp token.
Receive a message
Expose local port 3000 through a public HTTPS tunnel. In your agent’s settings, set the webhook URL to https://YOUR-TUNNEL/webhook and save the signing secret shown once.
curl --fail --output webhook.py https://unpoll.cc/docs/webhook.py
python3 -m venv .venv
. .venv/bin/activate
python -m pip install fastapi uvicorn
export UNPOLL_WEBHOOK_SECRET='YOUR_WEBHOOK_SIGNING_SECRET'
python -m uvicorn webhook:app --port 3000
View receiver code · webhook.py
"""Local webhook demo. Run: python -m uvicorn webhook:app --port 3000.
Set UNPOLL_WEBHOOK_SECRET to the signing secret from your agent's settings.
This demo prints events and acknowledges them without durable storage or
deduplication. Before production, store event IDs atomically with queued work
and deduplicate messages by agent and message ID before processing them.
"""
import hashlib
import hmac
import json
import os
import re
import time
from fastapi import FastAPI, HTTPException, Request, Response
app = FastAPI()
secret = os.environ["UNPOLL_WEBHOOK_SECRET"].encode("utf-8")
if not secret:
raise ValueError("UNPOLL_WEBHOOK_SECRET must not be empty")
@app.post("/webhook")
async def receive(request: Request) -> Response:
body = bytearray()
async for chunk in request.stream():
body.extend(chunk)
if len(body) > 1024 * 1024:
raise HTTPException(413, "Payload too large")
timestamps = request.headers.getlist("webhook-timestamp")
signatures = request.headers.getlist("webhook-signature")
if len(timestamps) != 1 or len(signatures) != 1:
raise HTTPException(401, "Missing or duplicate signature headers")
timestamp, signature = timestamps[0], signatures[0]
if (
not re.fullmatch(r"[0-9]{1,20}", timestamp)
or not re.fullmatch(r"v1=[0-9a-f]{64}", signature)
or abs(int(time.time()) - int(timestamp)) > 300
):
raise HTTPException(401, "Invalid signature or timestamp")
signed = timestamp.encode("ascii") + b"." + bytes(body)
expected = "v1=" + hmac.new(secret, signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(401, "Invalid signature")
try:
event = json.loads(body)
if event["schema_version"] != 1 or event["type"] != "whatsapp.updates":
raise ValueError("Unsupported event")
print(f"Event {event['event_id']} for agent {event['agent_id']}", flush=True)
for entry in event["data"]["entry"]:
for change in entry["changes"]:
value = change["value"]
for message in value.get("messages", []):
if message["type"] == "text":
print("Message:", message["text"]["body"], flush=True)
for status in value.get("statuses", []):
print("Receipt:", status["id"], status["status"], flush=True)
except (ValueError, KeyError, TypeError, AttributeError):
raise HTTPException(400, "Invalid event") from None
return Response(status_code=204)
Send your agent “Hello” from its creator’s WhatsApp account. Your terminal will print Message: Hello. The receiver verifies signatures for you.
This local demo prints and acknowledges events. Before production, durably queue work and deduplicate events and messages; see Delivery & retries below.
Send a reply
In the shell where you set your API key and agent ID, run:
curl --request POST \
"https://unpoll.cc/v1/agents/$UNPOLL_AGENT_ID/messages" \
--header "Authorization: Bearer $UNPOLL_API_KEY" \
--header 'Content-Type: application/json' \
--data '{"text":"Hello from my application!"}'
"outcome": "accepted" means your reply is on its way. Delivery and read receipts arrive at the same webhook.
WhatsApp currently allows agents to message only their creator. If a send times out or returns unknown, avoid automatic retries: the message may already have been sent.
Something not working?
- Agent won’t connect
- Send it a WhatsApp message from its creator account, then try connecting again.
- No webhook arriving
- Check that your HTTPS tunnel points to port 3000 and the URL ends in
/webhook. Send a new message after saving the webhook; earlier captures are not automatically delivered. Check the agent’s delivery page for failures. 401 unauthorizedwhen sending- Use your Unpoll API key, not the WhatsApp token or webhook secret.
409 recipient_not_known- Send the agent a message from its creator and wait for the webhook before replying.
503 send_unavailable- Wait at least five seconds between completed sends. A send with an unknown result requires a longer cooldown.
API reference
v1Open a section when you need the details.
Authentication
Authenticate API requests with an Unpoll customer API key in the Authorization header. This is separate from the WhatsApp token used to connect your agent and the signing secret used to verify webhooks.
Authorization: Bearer YOUR_API_KEY
A key can send through every agent in its workspace. Workspace owners create and revoke keys in the dashboard. The complete key is shown only at creation and cannot be retrieved later.
- Keep keys in server-side environment variables or a secret manager. Never put them in browser code, URLs, or logs.
- To rotate a key, create a replacement, update your application, then revoke the old key.
- Missing, invalid, or revoked keys return
401. An unavailable authentication service returns503withauthentication_unavailable.
Google sign-in gives access to the dashboard. Its session cookie does not authenticate API requests.
Send a message
/v1/agents/{agent_id}/messagesSend a text message using the local agent UUID and a key from the same workspace. Use Content-Type: application/json. Unknown JSON fields are rejected.
| Field | Type | Description |
|---|---|---|
textRequired | string | Message text, containing 1–4096 Unicode characters. |
reply_to | string | Optional inbound wamid. message ID from this conversation. Quotes that message in the reply. |
preview_url | boolean | Optional. Request a link preview. Defaults to false. |
to | string | Optional. Overrides the learned recipient. Copy the exact user:<id> value from an incoming message’s from field. |
{
"text": "Thanks for your message!",
"reply_to": "wamid.inbound-example",
"preview_url": false
}
Without to, Unpoll uses the recipient learned from an incoming message. Until that happens, the API returns 409 recipient_not_known without sending. WhatsApp currently allows an agent to message only its creator.
Successful response
200 OK means WhatsApp accepted the message. Save receipt.messages[0].id to match later delivery and read receipts.
{
"outcome": "accepted",
"receipt": {
"messaging_product": "whatsapp",
"contacts": [
{
"input": "user:50972923564215",
"wa_id": "user:50972923564215"
}
],
"messages": [{"id": "wamid.outbound-example"}]
}
}
Accepted does not mean delivered or read. Those receipts arrive through your webhook.
Responses & errors
Use both the HTTP status and outcome to decide what to do next. API errors have this shape:
{
"error": {"code": "recipient_not_known"},
"outcome": "not_sent"
}
acceptedWhatsApp accepted the message. Do not resend it.
not_sentNo message was sent. Resolve the cause before a later attempt.
unknownThe message may have been sent. Retrying can create a duplicate.
| Status | Code / outcome | What to do |
|---|---|---|
200 | accepted | Save the receipt. Do not retry. |
202 | accepted_without_receiptaccepted | WhatsApp accepted the send, but a usable receipt was unavailable. Do not retry. |
400 | invalid_agent_id, invalid_json, or invalid_messagenot_sent | Correct the UUID, JSON, field types, or message values. |
401 | unauthorizednot_sent | Check your Unpoll API key and Bearer header. |
409 | recipient_not_knownnot_sent | Send your agent a WhatsApp message and wait for Unpoll to receive it. |
429 | upstream_rate_limitednot_sent | Back off before retrying. Respect Retry-After when present. |
502 | upstream_send_failednot_sent or unknown | Inspect outcome. Fix rejected requests; avoid automatic retries for unknown results. |
503 | authentication_unavailable, agent_unavailable, or send_unavailablenot_sent | Check the agent and key belong to the same workspace. If unavailable or cooling down, wait before retrying. |
Unpoll makes one upstream send attempt and has no idempotency-key support or durable send receipt ledger. A lost response, connection failure, or client timeout leaves the outcome uncertain, even if the message was accepted. Do not automatically resend after an unknown result.
Routing errors, such as an unsupported path or method, may return plain text instead of this JSON error format.
Receive webhooks
Unpoll sends an HTTPS POST to your configured endpoint with Content-Type: application/json. Each whatsapp.updates event contains a batch of incoming messages, delivery/read receipts, or both.
{
"schema_version": 1,
"event_id": "0194d893-15d0-4a5d-9298-f36c98b8844a",
"agent_id": "5a302db8-dc32-48f2-9319-0b2f38c2b8ac",
"type": "whatsapp.updates",
"data": {
"object": "whatsapp_agent_platform",
"entry": [{
"id": "123456789",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"contacts": [],
"messages": [{
"from": "user:50972923564215",
"id": "wamid.inbound-example",
"timestamp": "1790726400",
"type": "text",
"text": {"body": "Hello!"}
}],
"statuses": [{
"id": "wamid.outbound-example",
"status": "delivered",
"recipient_id": "user:50972923564215",
"timestamp": "1790726401"
}]
}
}]
}],
"next_offset": 1287
}
}
| Field | Meaning |
|---|---|
schema_version | Envelope version. Currently 1. |
event_id | Stable UUID for the captured batch. Unchanged across delivery attempts and manual retries. |
agent_id | Your local Unpoll agent UUID. Use this ID in the send endpoint. |
type | Currently whatsapp.updates. |
data | The upstream update envelope, including messages, receipts, and unknown fields. Unpoll manages next_offset; you do not need to poll. |
Processing messages and receipts
Walk data.entry[].changes[].value. Handle messages[] and statuses[] independently: either array can be empty. For a text message, read messages[].text.body and save its id if you want to quote a reply.
Match statuses[].id to your saved outbound message ID. The status is delivered or read. Timestamps are Unix seconds encoded as strings.
Inbound message types also include image, audio, video, document, sticker, and reaction. Check type before reading a text body, and tolerate new fields. The send API currently supports text only; Unpoll has no media download or upload endpoint.
Verify signatures
Verify every webhook before parsing its JSON or performing work. Each attempt includes two headers:
Webhook-Timestamp: 1790726400
Webhook-Signature: v1=HMAC_SHA256_HEX_DIGEST
The signature is lowercase hexadecimal HMAC-SHA256 over timestamp + "." + raw_body. Use the exact UTF-8 signing-secret string as the key; do not base64-decode it or reconstruct the JSON.
import hashlib
import hmac
import re
import time
def verify_webhook(secret: str, timestamp: str,
signature: str, body: bytes) -> bool:
if not re.fullmatch(r"[0-9]{1,20}", timestamp):
return False
if not re.fullmatch(r"v1=[0-9a-f]{64}", signature):
return False
if abs(int(time.time()) - int(timestamp)) > 300:
return False
signed = timestamp.encode("ascii") + b"." + body
digest = hmac.new(
secret.encode("utf-8"), signed, hashlib.sha256
).hexdigest()
return hmac.compare_digest("v1=" + digest, signature)
Pass the raw request bytes and exactly one value for each header. Your HTTP handler must reject missing or duplicate timestamp/signature headers before calling this function. Keep your server clock synchronized; the accepted timestamp window is five minutes in either direction.
Rotating your webhook secret
Saving a webhook configuration generates a new secret, even when the URL stays the same. Already queued events keep their original URL and signing secret. Retain old verification secrets while their deliveries drain; try each retained secret against the same signed bytes.
Delivery & retries
Webhooks use at-least-once delivery with a bounded retry policy. Duplicate events can arrive, and delivery order is not guaranteed.
- Verify the signature. Reject requests that fail verification.
- Accept work durably. Store
event_idatomically with your queued work. If it is already accepted, return a2xxwithout repeating that work. - Acknowledge promptly. Return any
2xxafter durable acceptance. Process slower work, such as AI generation, asynchronously. The webhook HTTP timeout is 15 seconds.
An event ID identifies a batch. Upstream messages can recur in different batches, so also deduplicate by the WhatsApp message ID within your agent. A new timestamp or signature does not make a retried event new.
| Your endpoint returns | Unpoll behavior |
|---|---|
Any 2xx | Delivery completes. The response body is ignored. |
408, 425, 429, 5xx, network error, or timeout | Retries with exponential backoff and jitter. The delay ceiling starts at five seconds, doubles to one hour, and each delay is randomly chosen between half the ceiling and the ceiling. |
| Redirect or another status | Terminal failure. Redirects are not followed. This includes 400, 401, and 403. |
On retryable responses, Retry-After accepts seconds or an HTTP date and sets a minimum delay, even above the normal backoff. Invalid values are ignored; a delay above 365 days causes terminal failure.
Delivery stops after 12 claimed attempts unless an attempt succeeds. A worker restart can consume an attempt before a request is sent. Oversized payloads and blocked destinations also fail without sending. Fix the receiver, then use Retry delivery on the agent’s delivery page to restart a failed job. Its event ID, destination, and signing secret stay the same.
Disabling webhook delivery pauses new attempts; polling and event queueing continue. Re-enabling resumes pending delivery. A request already in flight may still arrive.
Limits & capabilities
| Limit | Current behavior |
|---|---|
| Outbound messages | Text only, 1–4096 Unicode characters. JSON request bodies are limited to 32 KiB. |
| Send pacing | One send at a time per agent, with at least five seconds after a completed attempt before the next. At most 12 sends/minute; slower responses reduce throughput. An active cooldown returns 503 send_unavailable without queueing a message. |
| Unknown send outcome | A conservative cooldown of at least 90 seconds. Upstream Retry-After can extend cooldowns. |
| Recipient | The agent’s creator only, as required by WhatsApp. |
| Webhook destination | Public HTTPS only. Private networks, loopback addresses, and redirects are blocked. Use a public HTTPS tunnel for local development. |
| Webhook payload | Up to 1 MiB per request. Oversized payloads fail delivery. |
| Webhook timeout | 15 seconds for HTTP; 20 seconds for the whole attempt, including DNS. |
Agent connection, key management, webhook configuration, and failed-delivery retries are available in the dashboard. Public JSON management, media, typing-indicator, and mark-as-read endpoints are not currently available.
/healthNo authenticationThe health endpoint returns 200 OK with an empty body when the app is running. It checks process liveness, not database connectivity, polling progress, or webhook delivery.