Skip to content

Developers

Webhooks: signed, ordered and retried.

UPPU sends every event as a signed POST to your HTTPS endpoint: one event per request, at least once and in order per contact. Verify the signature on the raw body, drop repeats and answer 2xx fast.

  • Standard Webhooks v1 · HMAC-SHA256
  • 12 attempts over about 76 hours
  • Sent from 91.216.36.26

Set up an endpoint

The workspace owner manages endpoints in the panel, under team settings, with password confirmation. A workspace can have up to 5 endpoints. The API must be on for your workspace.

  1. Create the endpoint

    An https URL on port 443 with a public host name. Pick the events (on by default: message.received, message.status, contact.identity_changed, contact.erased, channel.status_changed), an optional channel filter, the ordering (best_effort by default, or strict) and max_in_flight (1 to 32, default 8).

  2. Store the secret

    The signing secret, whsec_ followed by base64 of 32 bytes, is shown once. Put it in your secret store.

  3. Deploy your receiver with signature verification

    Verify every request before you parse it (code below). A request with a bad signature gets 401.

  4. Send a test event

    «Test olayı gönder» (Send test event) posts a signed sample with "test": true. Your endpoint must answer 2xx.

  5. Run the wrong-signature probe

    «Yanlış imzalı deneme» (Wrong-signature probe) posts a webhook.test signed with a wrong secret. Your endpoint must answer with a status other than 2xx; a connection error doesn't count.

  6. Activate

    Both checks must have passed within the last 60 minutes. Events recorded before activation aren't delivered to this endpoint: fetch them with GET /v1/events.

Secret rotation: after you rotate the secret in the panel («Sırrı yenile»), the old one keeps signing for 24 hours and every request carries both signatures, newest first. Move your receiver to the new secret within that time.

Request format

Request headers
HeaderValue
Content-Typeapplication/json; charset=utf-8
User-AgentUPPU-Webhooks/1.0
webhook-idThe event ID (ULID). The same on every retry, resend and endpoint; it equals the body's id
webhook-timestampUnix seconds, new on every attempt
webhook-signaturev1,<base64>; during a rotation v1,<new> v1,<old>, separated by one space
Uppu-Event-TypeThe event type, for example message.status
Uppu-Delivery-IdThe delivery to this endpoint; a resend keeps it
Uppu-AttemptStarts at 1; a resend starts again at 1
  • One event per request, as compact UTF-8 JSON of at most 262,144 bytes. A larger event keeps the top-level values of data (long text is shortened); nested objects shrink to {"id": …} or drop out, and "truncated": true is added. Then fetch the full message with GET /v1/messages/{id}.
  • Requests come from 91.216.36.26; a change is announced 30 days ahead. Treat the signature, not the IP address, as the real check.
  • UPPU connects within 5 seconds and waits at most 10 seconds for your answer. Redirects aren't followed: a 3xx counts as a failure.
  • The body is serialised once when the event is recorded and sent byte for byte the same on every attempt.

Event envelope

Every event has the same envelope; data reuses the API's resources. This is a message.received event with made-up IDs.

message.received
{"id": "01jace0e1v2m4n6p8q0r2s4t6a",
 "object": "event",
 "type": "message.received",
 "api_version": "v1",
 "created_at": "2026-10-20T09:15:01.020Z",
 "team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
 "channel": {"id": "01j9z0ch5k2m4n6p8q0r2s4t6v", "type": "whatsapp"},
 "test": false,
 "data": {
   "message": {"object": "message", "id": "01jab3t6ms2m4n6p8q0r2s4t6v",
               "channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v", "channel_type": "whatsapp",
               "conversation_id": "01jab39zcv2m4n6p8q0r2s4t6v",
               "contact_id": "01j8x7ct5k2m4n6p8q0r2s4t6v", "direction": "in",
               "author": {"type": "contact", "label": "Ayşe Y.", "api_key_prefix": null},
               "content": {"type": "text", "text": {"body": "Hello, where is my order?"}},
               "context": null, "template": null, "preview": "Hello, where is my order?",
               "status": "received", "status_changed_at": null, "error": null,
               "client_reference": null, "provider_message_id": "wamid.HBgMOTA1NTUwMDAwMDAxFQIAEhgg",
               "sent_at": "2026-10-20T09:15:00.000Z", "received_at": "2026-10-20T09:15:01.000Z",
               "created_at": "2026-10-20T09:15:01.000Z"},
   "contact": {"id": "01j8x7ct5k2m4n6p8q0r2s4t6v", "display_name": "Ayşe Y.",
               "identities": {"bsuid": "TR.13491208655302741918", "phone": "+905550000001"}},
   "conversation": {"id": "01jab39zcv2m4n6p8q0r2s4t6v", "status": "open", "handling": "api"},
   "service_window": {"open": true, "closes_at": "2026-10-21T09:13:00.000Z"}}}
  • Key people on contact.id. It stays the same when a WhatsApp user ID rotates, and a phone number may be missing.
  • channel is null for template.updated. test is true only for the panel's test events: answer 2xx and do nothing.
  • service_window is computed when the event is recorded. If you process the event later, compare closes_at with the current time.
  • Ignore event types and fields you don't know: v1 may add them.

Event types

Event types
TypeWhenOn by default
message.receivedEvery inbound message, live chat includedYes
message.statusEvery forward status change of an outbound message (accepted, sent, delivered, read, failed, unknown), with previous_status and your client_referenceYes
message.createdEvery outbound message by an agent, the system or the APINo
contact.identity_changedA WhatsApp user ID (BSUID) rotatedYes
contact.consent_changedA consent was recorded or withdrawnNo
contact.erasedA contact was erased with the privacy toolsYes
template.updatedA template's status, category or quality changedNo
channel.status_changedThe channel's status or Meta mode changedYes
channel.health_changedThe channel checklist's health changedNo
webhook.testThe panel's test event and the wrong-signature probeAlways

On message.status, statuses only move forward: queued, then accepted or unknown, sent, failed, delivered, read. Apply a status only if it ranks higher than the one you hold; failed can still turn into delivered later. Messenger has no sent, and Instagram reports only read.

Verify the signature

UPPU signs with the Standard Webhooks scheme. Check the signature on the raw body before anything else.

  1. Key: strip whsec_ from the secret and base64-decode the rest. The key is those raw bytes, not the text.
  2. Signed content: webhook-id + "." + webhook-timestamp + "." + raw body. Never decode and re-encode the JSON first.
  3. Signature: HMAC-SHA256 of the signed content, encoded as standard, padded base64.
  4. Compare: split the header on single spaces; accept if any v1, value matches, using a constant-time comparison.
  5. Reject an empty webhook-id, a timestamp that isn't all digits, or one more than 300 seconds away from your clock (keep it synced with NTP).
  6. Then: drop repeats by webhook-id (keep IDs for at least 35 days), queue the work and answer 2xx within 10 seconds.

PHP

<?php
// PHP 8+. UPPU webhook v1 (Standard Webhooks): raw body, raw key bytes, constant-time compare.
function uppu_verify_webhook(array $secrets, string $id, string $timestamp, string $body,
                             string $signatureHeader, int $tolerance = 300, ?int $now = null): bool
{
    if ($id === '' || !ctype_digit($timestamp) || abs(($now ?? time()) - (int) $timestamp) > $tolerance) {
        return false;
    }
    $signed = $id . '.' . $timestamp . '.' . $body;
    foreach ($secrets as $secret) {
        if (!is_string($secret) || strncmp($secret, 'whsec_', 6) !== 0) {
            continue;
        }
        $key = base64_decode(substr($secret, 6), true);          // raw bytes, not the text
        if ($key === false || $key === '') {
            continue;
        }
        $expected = base64_encode(hash_hmac('sha256', $signed, $key, true));
        foreach (explode(' ', $signatureHeader) as $part) {
            [$version, $signature] = array_pad(explode(',', $part, 2), 2, '');
            if ($version === 'v1' && hash_equals($expected, $signature)) {
                return true;
            }
        }
    }
    return false;
}

// Receiver. Apache and nginx + PHP-FPM pass "webhook-id" as $_SERVER['HTTP_WEBHOOK_ID'].
$header = fn (string $name): string => (string) ($_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $name))] ?? '');
$body = (string) file_get_contents('php://input');               // raw body, unmodified
$secrets = array_filter([getenv('UPPU_WEBHOOK_SECRET') ?: '', getenv('UPPU_WEBHOOK_SECRET_OLD') ?: '']);

if (!uppu_verify_webhook($secrets, $header('webhook-id'), $header('webhook-timestamp'), $body, $header('webhook-signature'))) {
    http_response_code(401);                                     // the activation probe expects a non-2xx
    exit;
}
$event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
// 1) drop repeats by $event['id']  2) queue the work  3) answer quickly
http_response_code(204);

Test vector

Run your verifier offline against these values, with “now” set to the timestamp. The secrets are made up for this page and are not real secrets. The Python code above passes all six cases on every build of this site.

Signature test vector
ItemValue
Secretwhsec_dXBwdS1leGFtcGxlLXNpZ25pbmcta2V5LTMyYnl0ZXM=Decodes to the 32 bytes uppu-example-signing-key-32bytes.
Old secretwhsec_dXBwdS1leGFtcGxlLU9MRC1zaWduaW5nLWtleS0zMmI=
webhook-id01jab3t6y8q7z0k5w9x2c4d6ef
webhook-timestamp17923149022026-10-18 09:15:02 UTC; use it as “now”.
Body{"id":"01jab3t6y8q7z0k5w9x2c4d6ef","object":"event","type":"webhook.test","api_version":"v1","created_at":"2026-10-18T09:15:02.481Z","team_id":"01j8x7tm5k2m4n6p8q0r2s4t6v","channel":null,"test":true,"data":{"note":"UPPU test olayı","endpoint_id":"01jab3ep5k2m4n6p8q0r2s4t6v"}}277 bytes in UTF-8 (the ı in «olayı» takes 2 bytes). Copy it exactly, without a trailing newline.
Expected signaturev1,jSu7JaoTMNvhCH/kppizvNNG0V/GD1DA6WSpY4cuxPw=
Rotation headerv1,jSu7JaoTMNvhCH/kppizvNNG0V/GD1DA6WSpY4cuxPw= v1,mVpouHP8zRAeVBqhKcwVxioDhjcztU/vb3NHRPka6ms=Both the secret and the old secret verify.
  • Must pass: the single signature with the secret; the rotation header with the secret; the rotation header with the old secret.
  • Must fail: the body with one extra space at the end; the timestamp plus 301 seconds as “now”; an empty webhook-id.

Delivery, order and retries

  • At least once: the same event can arrive twice, for example after a timeout. De-duplicate by webhook-id.
  • In order per contact: events of one contact (template and channel events: of one template or channel) are delivered first in, first out, at most one at a time. Different contacts go in parallel, up to max_in_flight.
  • best_effort (default): an event waiting for a retry doesn't hold back later ones, so you may see them out of order. strict: the oldest unfinished event blocks its contact. Choose strict for bot state machines; one failing event can then hold that contact for hours.
  • Success is any 2xx. 410 suspends the endpoint at once. Everything else is retried, including 3xx, 4xx and timeouts. 429 and 503 may carry Retry-After (honoured up to 1 hour) but still use up an attempt.

Wait before each attempt (12 attempts, about 76 hours, each wait ±10%). After the last one the delivery is dead.

  1. 0first attempt
  2. 30 sattempt 2
  3. 2 minattempt 3
  4. 10 minattempt 4
  5. 30 minattempt 5
  6. 1 hattempt 6
  7. 2 hattempt 7
  8. 4 hattempt 8
  9. 8 hattempt 9
  10. 12 hattempt 10
  11. 24 hattempt 11
  12. 24 hlast attempt
  • Circuit breaker: 20 failures in a row make the endpoint failing; it then gets one probe a minute, and the first 2xx brings it back. After 72 hours of failing, or after a 410, the endpoint is suspended: deliveries are held for up to 7 days. The owner and admins get emails.
  • Resend from the panel: the same webhook-id and bytes with a new timestamp. Bodies are kept 7 days for delivered events and up to 30 days for dead or held ones.
  • A short burst of 429 or 503 slows UPPU down, but for lasting load answer 2xx at once, do the work in the background and lower max_in_flight.

Missed events: GET /v1/events

After an outage, or for events recorded while the endpoint wasn't active, pull the same events from the API (scope events:read, 2 requests per second).

  • The last 7 days, oldest first, with the same IDs and bodies as the webhooks. Run them through the same de-duplication.
  • Parameters: after (the last event you processed, or the previous page's next_cursor), types=a,b and limit (1 to 100).
  • Events from the last 10 seconds appear on a later call, so a cursor never skips one. Test events are never listed.
  • An after that is unknown or older than 7 days gets 422 (field after): start again without after.
  • A key bound to one channel sees only that channel's events; template.updated needs an all-channels key.
GET/v1/events

cURL

curl -sS "$UPPU_API_BASE/events?after=01jace0e1v2m4n6p8q0r2s4t6a&types=message.received,message.status&limit=100" \
  -H "Authorization: Bearer $UPPU_API_KEY"

Media and the reply window

  • Inbound media arrives with UPPU's media ID and "state": "pending" while the file downloads. Fetch it with GET /v1/media/{id}/content (scope media:read); a 409 media_not_ready carries Retry-After: 2. No separate event says the file is ready, so finish pending files in the background. ETag and single-range requests are supported.
  • Inbound media is kept 90 days. On Messenger and Instagram several files can arrive in one message, under attachments.
  • The reply window: before any automatic free-text reply, check data.service_window. The window is counted by Meta's clock, so a message Meta delivered late can arrive with "open": false. On WhatsApp send an approved template; on Messenger and Instagram wait for the person to write again.

Receiver checklist

  • A public HTTPS endpoint on port 443 with a valid certificate and no redirects.
  • Signature checked on the raw body before parsing; 401 for a bad one; both signatures accepted during a rotation.
  • Repeats dropped by webhook-id, kept for at least 35 days; a repeat still gets 2xx.
  • 2xx within a few seconds, with the work queued; no 410 unless you mean to suspend.
  • Statuses applied by rank, never backwards; unknown event types and fields ignored; "test": true events acknowledged and skipped.
  • People keyed on contact.id; BSUIDs updated on contact.identity_changed; your copy of personal data removed on contact.erased.
  • Gaps filled with GET /v1/events after an outage.

Next

Connect UPPU's events to your systems.

We turn the API and webhooks on for your workspace and walk you through it with your own scenario.

  • Standard Webhooks
  • Ordered per contact
  • Pull fallback for 7 days