Quickstart
Keys, the request helper and your first sends in curl, PHP, Node.js and Python.
Developers
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.
91.216.36.26The 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.
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).
The signing secret, whsec_ followed by base64 of 32 bytes, is shown once. Put it in your secret store.
Verify every request before you parse it (code below). A request with a bad signature gets 401.
«Test olayı gönder» (Send test event) posts a signed sample with "test": true. Your endpoint must answer 2xx.
«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.
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.
| Header | Value |
|---|---|
Content-Type | application/json; charset=utf-8 |
User-Agent | UPPU-Webhooks/1.0 |
webhook-id | The event ID (ULID). The same on every retry, resend and endpoint; it equals the body's id |
webhook-timestamp | Unix seconds, new on every attempt |
webhook-signature | v1,<base64>; during a rotation v1,<new> v1,<old>, separated by one space |
Uppu-Event-Type | The event type, for example message.status |
Uppu-Delivery-Id | The delivery to this endpoint; a resend keeps it |
Uppu-Attempt | Starts at 1; a resend starts again at 1 |
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}.91.216.36.26; a change is announced 30 days ahead. Treat the signature, not the IP address, as the real check.3xx counts as a failure.Every event has the same envelope; data reuses the API's resources. This is a message.received event with made-up IDs.
{"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"}}}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.| Type | When | On by default |
|---|---|---|
message.received | Every inbound message, live chat included | Yes |
message.status | Every forward status change of an outbound message (accepted, sent, delivered, read, failed, unknown), with previous_status and your client_reference | Yes |
message.created | Every outbound message by an agent, the system or the API | No |
contact.identity_changed | A WhatsApp user ID (BSUID) rotated | Yes |
contact.consent_changed | A consent was recorded or withdrawn | No |
contact.erased | A contact was erased with the privacy tools | Yes |
template.updated | A template's status, category or quality changed | No |
channel.status_changed | The channel's status or Meta mode changed | Yes |
channel.health_changed | The channel checklist's health changed | No |
webhook.test | The panel's test event and the wrong-signature probe | Always |
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.
UPPU signs with the Standard Webhooks scheme. Check the signature on the raw body before anything else.
whsec_ from the secret and base64-decode the rest. The key is those raw bytes, not the text.webhook-id + "." + webhook-timestamp + "." + raw body. Never decode and re-encode the JSON first.v1, value matches, using a constant-time comparison.webhook-id, a timestamp that isn't all digits, or one more than 300 seconds away from your clock (keep it synced with NTP).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);Node.js
// Node.js 18+, no dependencies. Read the raw body before any JSON parser.
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyUppuWebhook(secrets, id, timestamp, rawBody, signatureHeader,
toleranceSec = 300, nowSec = Date.now() / 1000) {
if (!id || !/^[0-9]+$/.test(timestamp ?? '')) return false;
if (Math.abs(nowSec - Number(timestamp)) > toleranceSec) return false;
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const given = (signatureHeader ?? '').split(' ').flatMap((part) => {
const comma = part.indexOf(',');
return comma > 0 && part.slice(0, comma) === 'v1' ? [Buffer.from(part.slice(comma + 1), 'utf8')] : [];
});
for (const secret of secrets) {
if (typeof secret !== 'string' || !secret.startsWith('whsec_')) continue;
const key = Buffer.from(secret.slice(6), 'base64'); // raw bytes, not the text
if (key.length === 0) continue;
const expected = Buffer.from(createHmac('sha256', key).update(signed).digest('base64'), 'utf8');
if (given.some((sig) => sig.length === expected.length && timingSafeEqual(sig, expected))) return true;
}
return false;
}
const secrets = [process.env.UPPU_WEBHOOK_SECRET, process.env.UPPU_WEBHOOK_SECRET_OLD].filter(Boolean);
createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const raw = Buffer.concat(chunks); // raw bytes; do not re-serialise
const ok = verifyUppuWebhook(secrets, req.headers['webhook-id'], req.headers['webhook-timestamp'],
raw, req.headers['webhook-signature']);
if (!ok) { res.writeHead(401).end(); return; } // the activation probe expects a non-2xx
const event = JSON.parse(raw.toString('utf8'));
// drop repeats by event.id, queue the work, then answer quickly
res.writeHead(204).end();
});
}).listen(8080); // behind your HTTPS reverse proxy (port 443)Python
# Python 3.9+, standard library only.
import base64
import hashlib
import hmac
import json
import os
import re
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
def verify_uppu_webhook(secrets, msg_id, timestamp, raw_body, signature_header, tolerance=300, now=None):
if not msg_id or not timestamp or not re.fullmatch(r"[0-9]+", timestamp):
return False
if abs((time.time() if now is None else now) - int(timestamp)) > tolerance:
return False
signed = f"{msg_id}.{timestamp}.".encode("utf-8") + raw_body
given = []
for part in (signature_header or "").split(" "):
version, _, signature = part.partition(",")
if version == "v1" and signature:
given.append(signature.encode("utf-8"))
for secret in secrets:
if not secret or not secret.startswith("whsec_"):
continue
key = base64.b64decode(secret[len("whsec_"):]) # raw bytes, not the text
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())
if any(hmac.compare_digest(expected, signature) for signature in given):
return True
return False
class Receiver(BaseHTTPRequestHandler):
secrets = [s for s in (os.environ.get("UPPU_WEBHOOK_SECRET"), os.environ.get("UPPU_WEBHOOK_SECRET_OLD")) if s]
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) # raw bytes; do not re-serialise
ok = verify_uppu_webhook(self.secrets, self.headers.get("webhook-id"),
self.headers.get("webhook-timestamp"), raw, self.headers.get("webhook-signature"))
if not ok:
self.send_response(401) # the activation probe expects a non-2xx
self.end_headers()
return
event = json.loads(raw)
# drop repeats by event["id"], queue the work, then answer quickly
self.send_response(204)
self.end_headers()
if __name__ == "__main__":
HTTPServer(("127.0.0.1", 8080), Receiver).serve_forever() # behind your HTTPS reverse proxy (port 443)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.
| Item | Value |
|---|---|
| Secret | whsec_dXBwdS1leGFtcGxlLXNpZ25pbmcta2V5LTMyYnl0ZXM=Decodes to the 32 bytes uppu-example-signing-key-32bytes. |
| Old secret | whsec_dXBwdS1leGFtcGxlLU9MRC1zaWduaW5nLWtleS0zMmI= |
| webhook-id | 01jab3t6y8q7z0k5w9x2c4d6ef |
| webhook-timestamp | 17923149022026-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 signature | v1,jSu7JaoTMNvhCH/kppizvNNG0V/GD1DA6WSpY4cuxPw= |
| Rotation header | v1,jSu7JaoTMNvhCH/kppizvNNG0V/GD1DA6WSpY4cuxPw= v1,mVpouHP8zRAeVBqhKcwVxioDhjcztU/vb3NHRPka6ms=Both the secret and the old secret verify. |
webhook-id.webhook-id.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.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.
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.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.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.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).
after (the last event you processed, or the previous page's next_cursor), types=a,b and limit (1 to 100).after that is unknown or older than 7 days gets 422 (field after): start again without after.template.updated needs an all-channels key.cURL
curl -sS "$UPPU_API_BASE/events?after=01jace0e1v2m4n6p8q0r2s4t6a&types=message.received,message.status&limit=100" \
-H "Authorization: Bearer $UPPU_API_KEY"PHP
// Uses uppu_request() from the quickstart; load/save/handle are your own functions.
$after = load_last_event_id(); // '' the first time
while (true) {
$query = http_build_query(array_filter(['after' => $after, 'types' => 'message.received,message.status', 'limit' => 100]));
$r = uppu_request('GET', 'events?' . $query);
if ($r['status'] === 422 && $after !== '') { // unknown or older than 7 days: start again
$after = '';
continue;
}
if ($r['status'] !== 200) {
break; // try later (429: after Retry-After)
}
foreach ($r['body']['data'] as $event) {
handle_event_once($event); // the same de-duplication as your receiver
$after = $event['id'];
}
save_last_event_id($after);
if ($r['body']['next_cursor'] === null) {
break;
}
usleep(600000); // stay under 2 requests a second
}Node.js
// Uses uppuRequest() from the quickstart; load/save/handle are your own functions.
let after = await loadLastEventId(); // '' the first time
for (;;) {
const query = new URLSearchParams({ types: 'message.received,message.status', limit: '100' });
if (after) query.set('after', after);
const { status, data } = await uppuRequest('GET', `events?${query}`);
if (status === 422 && after) { after = ''; continue; } // unknown or older than 7 days: start again
if (status !== 200) break; // try later (429: after Retry-After)
for (const event of data.data) {
await handleEventOnce(event); // the same de-duplication as your receiver
after = event.id;
}
await saveLastEventId(after);
if (data.next_cursor === null) break;
await new Promise((resolve) => setTimeout(resolve, 600)); // stay under 2 requests a second
}Python
# Uses uppu_request() from the quickstart; load/save/handle are your own functions.
import time
from urllib.parse import urlencode
after = load_last_event_id() # "" the first time
while True:
params = {"types": "message.received,message.status", "limit": 100}
if after:
params["after"] = after
status, page = uppu_request("GET", "events?" + urlencode(params))
if status == 422 and after: # unknown or older than 7 days: start again
after = ""
continue
if status != 200:
break # try later (429: after Retry-After)
for event in page["data"]:
handle_event_once(event) # the same de-duplication as your receiver
after = event["id"]
save_last_event_id(after)
if page["next_cursor"] is None:
break
time.sleep(0.6) # stay under 2 requests a second"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.attachments.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.401 for a bad one; both signatures accepted during a rotation.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."test": true events acknowledged and skipped.contact.id; BSUIDs updated on contact.identity_changed; your copy of personal data removed on contact.erased.GET /v1/events after an outage.Keys, the request helper and your first sends in curl, PHP, Node.js and Python.
The Event schema, every endpoint and the webhook request, generated from the OpenAPI document.
Synchronous and asynchronous codes, including the ones a message.status event carries.
We turn the API and webhooks on for your workspace and walk you through it with your own scenario.