Developer quickstart

Connect. Receive. Reply.

A webhook for incoming WhatsApp messages. One API call to reply.

Base URLhttps://unpoll.ccv1
01

Connect 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).

Set your credentialsShell
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.

02

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.

Start the receiverPython + FastAPI
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
webhook.pyDownload file
"""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.

03

Send a reply

In the shell where you set your API key and agent ID, run:

Your first replyShell
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 unauthorized when 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

v1

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

Request headerHTTP
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 returns 503 with authentication_unavailable.

Google sign-in gives access to the dashboard. Its session cookie does not authenticate API requests.

Send a message

POST/v1/agents/{agent_id}/messages

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

FieldTypeDescription
textRequiredstringMessage text, containing 1–4096 Unicode characters.
reply_tostringOptional inbound wamid. message ID from this conversation. Quotes that message in the reply.
preview_urlbooleanOptional. Request a link preview. Defaults to false.
tostringOptional. Overrides the learned recipient. Copy the exact user:<id> value from an incoming message’s from field.
Quoted replyJSON
{
  "text": "Thanks for your message!",
  "reply_to": "wamid.inbound-example",
  "preview_url": false
}
Message your agent first

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.

200 OKJSON
{
  "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 responseJSON
{
  "error": {"code": "recipient_not_known"},
  "outcome": "not_sent"
}
accepted

WhatsApp accepted the message. Do not resend it.

not_sent

No message was sent. Resolve the cause before a later attempt.

unknown

The message may have been sent. Retrying can create a duplicate.

StatusCode / outcomeWhat to do
200acceptedSave the receipt. Do not retry.
202accepted_without_receipt
accepted
WhatsApp accepted the send, but a usable receipt was unavailable. Do not retry.
400invalid_agent_id, invalid_json, or invalid_message
not_sent
Correct the UUID, JSON, field types, or message values.
401unauthorized
not_sent
Check your Unpoll API key and Bearer header.
409recipient_not_known
not_sent
Send your agent a WhatsApp message and wait for Unpoll to receive it.
429upstream_rate_limited
not_sent
Back off before retrying. Respect Retry-After when present.
502upstream_send_failed
not_sent or unknown
Inspect outcome. Fix rejected requests; avoid automatic retries for unknown results.
503authentication_unavailable, agent_unavailable, or send_unavailable
not_sent
Check the agent and key belong to the same workspace. If unavailable or cooling down, wait before retrying.
Make send retries an explicit decision

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.

Example eventJSON
{
  "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
  }
}
FieldMeaning
schema_versionEnvelope version. Currently 1.
event_idStable UUID for the captured batch. Unchanged across delivery attempts and manual retries.
agent_idYour local Unpoll agent UUID. Use this ID in the send endpoint.
typeCurrently whatsapp.updates.
dataThe 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 headersHTTP
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.

Signature verificationPython · standard library
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.

  1. Verify the signature. Reject requests that fail verification.
  2. Accept work durably. Store event_id atomically with your queued work. If it is already accepted, return a 2xx without repeating that work.
  3. Acknowledge promptly. Return any 2xx after durable acceptance. Process slower work, such as AI generation, asynchronously. The webhook HTTP timeout is 15 seconds.
Deduplicate messages as well as events

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 returnsUnpoll behavior
Any 2xxDelivery completes. The response body is ignored.
408, 425, 429, 5xx, network error, or timeoutRetries 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 statusTerminal 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

LimitCurrent behavior
Outbound messagesText only, 1–4096 Unicode characters. JSON request bodies are limited to 32 KiB.
Send pacingOne 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 outcomeA conservative cooldown of at least 90 seconds. Upstream Retry-After can extend cooldowns.
RecipientThe agent’s creator only, as required by WhatsApp.
Webhook destinationPublic HTTPS only. Private networks, loopback addresses, and redirects are blocked. Use a public HTTPS tunnel for local development.
Webhook payloadUp to 1 MiB per request. Oversized payloads fail delivery.
Webhook timeout15 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.

GET/healthNo authentication

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