Webhooks
UPPU sends every event to your HTTPS endpoint as a signed POST request, one event per request. This page explains how to set up an endpoint, verify the signature, answer and retry, catch up after an outage, and what every event looks like.
Set up an endpoint
The company owner manages endpoints in the UPPU panel: the company's settings, then Webhooks (after confirming the password), or with an API key through the API. A company has at most 5 endpoints.
- URL:
httpson port 443, a public host name and a valid certificate. Redirects are not followed. - Events: the event types to receive; new endpoints start with the defaults in the event table. A channel filter (one channel or all), the ordering (
best_effortorstrict) and the most deliveries in flight at once (1 to 32, 8 by default). - Signing secret:
whsec_..., shown once when the endpoint is created. Store it like a password.
Activation. A new endpoint receives events only after two checks, both within the last 60 minutes:
- Deploy your receiver with signature verification.
- Send test event: UPPU sends a sample event signed with your secret; your endpoint must answer
2xx. - Send wrong-signature probe: UPPU sends
webhook.testsigned with a wrong secret; your endpoint must refuse it with any status other than2xx. This proves that you check the signature. - Activate.
Events created while an endpoint is not active are not delivered to it; fetch them with GET /v1/events.
UPPU's outgoing address is shown on the Webhooks page of the panel and announced 30 days before it changes. Do not rely on an IP filter alone: the signature is what proves that an event comes from UPPU.
Manage endpoints through the API
An API key with the webhooks:manage scope manages the same endpoints as the panel at /v1/webhook-endpoints: list, create, change and delete them, send a test event or the wrong-signature probe, activate and disable them, and rotate the signing secret. No preset includes the scope and existing keys do not have it: the owner adds it to a key with Custom on the Developers page. The rules, the limit of 5 endpoints and the audit records are those of the panel; one endpoint more gets 409 plan_limit_reached.
- The secret is shown once: in the answer of
POST /v1/webhook-endpointsand ofPOST /v1/webhook-endpoints/{id}/secret, nowhere else. A repeat with the sameIdempotency-Keyanswers without it: rotate the secret if you lost the first answer. - Activation works as in the panel:
POST /v1/webhook-endpoints/{id}/test,POST /v1/webhook-endpoints/{id}/probe, thenPOST /v1/webhook-endpoints/{id}/activate. Tests and probes wait for your receiver (at most 10 seconds): at most 10 per minute for the company and one at a time. A check that would have to wait gets429 rate_limitedwith the reasonconcurrent_checksand is not sent: send it again afterRetry-After. - A new URL is checked at once:
POST /v1/webhook-endpointsand aPATCHwithurllook the host name up in DNS during the request, one at a time per company together with tests and probes. A write that would have to wait gets429 rate_limitedwith the reasonconcurrent_checksand changes nothing: send it again afterRetry-After. Such writes do not count in the 10 per minute. - A key bound to one channel sees and manages only the endpoints that are filtered to its channel.
Weekly test event
An endpoint can opt in to a weekly test event: the endpoint form of the panel, or weekly_test: true through the API (off by default). Once a week UPPU then sends webhook.test with test: true, signed with the endpoint's secret, to the endpoint while it is active or failing. A failed weekly test is only a warning: the panel shows it on the endpoint and the API in weekly_test.failed_at. The endpoint is never suspended for it and deliveries go on. A passing weekly test or test event, turning the weekly test off and a new URL clear the warning.
The request
| Header | Value |
|---|---|
Content-Type |
application/json; charset=utf-8 |
User-Agent |
UPPU-Webhooks/1.0 |
webhook-id |
The event id, the same across retries, resends and endpoints; equal to the body's id |
webhook-timestamp |
Unix seconds, new on every attempt |
webhook-signature |
v1,<base64>; during a secret rotation two values separated by a space |
Uppu-Event-Type |
The event type, for example message.status |
Uppu-Delivery-Id |
This delivery to this endpoint; a resend keeps it |
Uppu-Attempt |
1 for the first attempt |
The body is compact UTF-8 JSON, the same bytes on every attempt:
{
"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": {}
}
channelisnullfor events without a channel (template.updated).testistrueonly for the test events of the panel: answer2xxand do nothing else.- An event is at most 256 KB. A larger one is shortened: nested objects keep only their
id, long strings are cut anddata.truncatedistrue. Read the full message withGET /v1/messages/{id}.
Verify the signature
UPPU signs every request the Standard Webhooks way:
- Key: remove
whsec_from the secret and base64-decode the rest. The key is the decoded bytes, not the text of the secret. - Signed content:
webhook-id+.+webhook-timestamp+.+ the raw body, exactly as received. Never decode and encode the JSON again before checking. - Signature: HMAC-SHA256 of the signed content with the key, standard base64 with padding.
- Compare: split
webhook-signatureat spaces; a partv1,<value>whose value equals your signature, compared in constant time, makes the request valid. - Refuse a request without
webhook-id, with a timestamp that is not digits only, or with a timestamp more than 300 seconds away from your clock (keep the clock in sync with NTP).
During a secret rotation UPPU signs with both the new and the old secret for 24 hours, so either secret verifies the request. Switch your receiver to the new secret within those 24 hours.
Ready code: PHP, Node.js, Python and C#.
Test vector. The published test vector of the Standard Webhooks specification checks an implementation offline:
| Value | |
|---|---|
| Secret | whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw |
webhook-id |
msg_p5jXN8AQM9LWM0D4loKWxJek |
webhook-timestamp |
1614265330 |
| Body | {"test": 2432232314} |
webhook-signature |
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= |
Check it with your clock set to the vector's timestamp; the secret is a public example, never a real one.
Answer quickly
- Answer
2xxwithin 10 seconds (connecting must succeed within 5). Store the event, answer204, and process it afterwards. - Drop repeats: delivery is at least once, so remember every
webhook-idfor at least 35 days and answer2xxto one you have seen. - Do not put personal data into an error answer: UPPU keeps the first 191 characters of a failed answer for the panel.
Retries, ordering and suspension
- Success is any
2xx. Every other answer,3xxand4xxincluded, a timeout or a failed connection is retried. - Retry schedule: 0, 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours, 4 hours, 8 hours, 12 hours, 24 hours and 24 hours (±10%): 12 attempts over about 76 hours, then the delivery is dead.
429or503withRetry-After(seconds or an HTTP date, at most one hour) waits at least that long, but still counts as a failed attempt.410suspends the endpoint at once. Do not answer410unless you want that.- Failing and suspended: after 20 failures in a row the endpoint is failing and receives one attempt per minute until an answer is
2xx. After 72 hours of failing it is suspended: nothing is sent, new events wait (held) for at most 7 days. The owner and the admins get e-mails. Re-enable it in the panel: a test event must pass, then the held events follow in order. - Resend: the panel resends a single delivery, or every dead delivery since a given time, for 30 days; the
webhook-idand the bytes stay the same. - Ordering: events are queued per contact (per template or channel for those events) and at most one delivery per queue is in flight. With
best_effort(the default) a delivery that waits for a retry does not hold back later events, so you can see events out of order. Withstrictthe oldest undelivered event blocks its queue until it is delivered or dead; choose it for a state machine such as a chat bot. - Statuses only move forward:
message.statuscarriesprevious_status. Rank the statusesqueued<accepted=unknown=expired<sent<failed<delivered<readand ignore an event whose rank is not above the stored one: afailedcan still be followed bydelivered, and anexpiredby any later status of the network (sent,failed,delivered,read).
Missed events
GET /v1/events (scope events:read, 2 requests per second per key) returns the events of the last 7 days with the same ids and bodies as the webhooks, oldest first.
after=<event id>: the last event you processed;types=message.received,message.statusfilters;limit1 to 100.- Follow
next_cursor(pass it asafter) until it isnull. - Events of the last 10 seconds appear on the next call.
- A key bound to one channel sees only that channel's events, and never
template.updated. - An
afterthat is unknown, older than 7 days or invisible to the key gets422 validation_failed(fieldafter): start again withoutafterand let your de-duplication drop the repeats.
Events
| Event | Subscribed by default | Ordered per |
|---|---|---|
message.received | yes | contact |
message.status | yes | contact |
message.created | no | contact |
contact.identity_changed | yes | contact |
contact.consent_changed | no | contact |
contact.erased | yes | contact |
template.updated | no | template |
channel.status_changed | yes | channel |
channel.health_changed | no | channel |
conversation.updated | no | contact |
contact.reachability_changed | yes | contact |
contact.updated (not produced yet) | no | contact |
account.service_stop_scheduled | yes | company account |
account.service_resumed | yes | company account |
webhook.test | always sent | not ordered |
Unknown event types and fields can appear within v1: ignore them. The examples below show whole events; ids and values are examples.
message.received
A person sent a message on any channel. data.message is the message, data.contact the person (identities holds the current identity per type on this channel, for example phone, bsuid, psid, igsid, telegram or web_visitor), data.conversation the conversation and data.service_window the reply window (null on web chat and Telegram).
- Key the person by
data.contact.id; usedata.message.idfor a read receipt or a reply. - Check
service_window.openandcloses_atbefore an automatic free-form answer. - Media arrives with
statepending; download it withGET /v1/media/{id}/content(409 media_not_readywithRetry-Afterwhile UPPU is still downloading). No further event follows when the file is ready. - Not sent for blocked contacts, system notices, duplicates and reactions to unknown messages.
- A completed WhatsApp form (WhatsApp Flows) arrives as
content.typeflow_reply:flow_token(the token of your flow message),flow_id(when the person answered a flow message sent through UPPU, elsenull) andresponse, the form's data as your flow defines it. A response too large to keep hasresponsenullandresponse_omittedtoo_largeortoo_deep. A file field keeps onlyfile_name,mime_typeandsha256. While the WhatsApp channel does not listflowincapabilities.interactive_types, a completed form arrives asunsupported(provider_typenfm_reply).
{
"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": "Deniz",
"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": "PROVIDER-MESSAGE-ID",
"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": "Deniz",
"identities": {
"phone": "+447700900100",
"bsuid": "TR.ExampleUser1"
}
},
"conversation": {
"id": "01jab39zcv2m4n6p8q0r2s4t6v",
"status": "open",
"handling": "api"
},
"service_window": {
"open": true,
"closes_at": "2026-10-21T09:13:00.000Z"
}
}
}
message.status
A status of an outbound message moved forward: accepted, unknown, sent, delivered, read, failed or expired (never queued). It is sent for every author: your API key, the team, UPPU Bot, a campaign of the panel. Match it to your send by data.message.id or data.message.client_reference; on failed, data.error.code says why. expired (code ttl_expired) follows a send with a ttl that UPPU could not hand to the provider in time: nothing was sent, and previous_status is queued. If an earlier attempt got an error answer from the provider, the ttl ends the send as failed with that error instead. In the rare case that the provider took the message after all (UPPU could not reach it and counted the attempt as not delivered), a later status of that attempt follows the expired. A campaign message (author type campaign) that had not reached the provider when the campaign was paused or cancelled also ends as expired (code ttl_expired), or as failed with the error of an earlier attempt.
{
"id": "01jace0e1v2m4n6p8q0r2s4t6b",
"object": "event",
"type": "message.status",
"api_version": "v1",
"created_at": "2026-10-20T09:20:03.010Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": {
"id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"type": "whatsapp"
},
"test": false,
"data": {
"message": {
"id": "01jab3a1k9f2m4n6p8q0r2s4t6",
"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"conversation_id": "01jab39zcv2m4n6p8q0r2s4t6v",
"contact_id": "01j8x7ct5k2m4n6p8q0r2s4t6v",
"provider_message_id": "PROVIDER-MESSAGE-ID",
"client_reference": "order-1001",
"author": {
"type": "api",
"api_key_prefix": "Ab12Cd34Ef56"
}
},
"status": "delivered",
"previous_status": "sent",
"occurred_at": "2026-10-20T09:20:03.004Z",
"error": null
}
}
message.created
An outbound message was queued: by an agent in the inbox, by UPPU, by UPPU Bot, by a campaign of the panel (author type campaign, bulk template messages) or by an API key. data has the shape of message.received, with direction out and status queued. Use it to mirror the team's replies in your own system.
{
"id": "01jace0e1v2m4n6p8q0r2s4t6c",
"object": "event",
"type": "message.created",
"api_version": "v1",
"created_at": "2026-10-20T09:20:00.130Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": {
"id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"type": "whatsapp"
},
"test": false,
"data": {
"message": {
"object": "message",
"id": "01jab3a1k9f2m4n6p8q0r2s4t6",
"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"channel_type": "whatsapp",
"conversation_id": "01jab39zcv2m4n6p8q0r2s4t6v",
"contact_id": "01j8x7ct5k2m4n6p8q0r2s4t6v",
"direction": "out",
"author": {
"type": "api",
"label": "Order bot",
"api_key_prefix": "Ab12Cd34Ef56"
},
"content": {
"type": "text",
"text": {
"body": "Your order has shipped.",
"preview_url": false
}
},
"context": null,
"template": null,
"preview": "Your order has shipped.",
"status": "queued",
"status_changed_at": "2026-10-20T09:20:00.120Z",
"error": null,
"client_reference": "order-1001",
"provider_message_id": null,
"sent_at": null,
"received_at": null,
"created_at": "2026-10-20T09:20:00.120Z"
},
"contact": {
"id": "01j8x7ct5k2m4n6p8q0r2s4t6v",
"display_name": "Deniz",
"identities": {
"phone": "+447700900100",
"bsuid": "TR.ExampleUser1"
}
},
"conversation": {
"id": "01jab39zcv2m4n6p8q0r2s4t6v",
"status": "open",
"handling": "api"
},
"service_window": {
"open": true,
"closes_at": "2026-10-21T09:13:00.000Z"
}
}
}
contact.identity_changed
A person's WhatsApp user id changed. data.contact_id stays the same; update a stored identity from previous to current. reason names what reported the change: usually user_changed_user_id (WhatsApp's notice in the conversation), user_changed_number when the person changed their phone number (WhatsApp then gives them a new user id too), and user_id_update from the staging simulator. Accept reasons you do not know.
{
"id": "01jace0e1v2m4n6p8q0r2s4t6d",
"object": "event",
"type": "contact.identity_changed",
"api_version": "v1",
"created_at": "2026-10-20T10:00:00.050Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": {
"id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"type": "whatsapp"
},
"test": false,
"data": {
"contact_id": "01j8x7ct5k2m4n6p8q0r2s4t6v",
"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"previous": {
"type": "bsuid",
"value": "TR.ExampleUser1"
},
"current": {
"type": "bsuid",
"value": "TR.ExampleUser2"
},
"reason": "user_id_update",
"occurred_at": "2026-10-20T10:00:00.000Z"
}
}
contact.consent_changed
A consent record was added: through the API, by a person who wrote STOP, DUR or İPTAL, by WhatsApp's marketing opt-out, by a person who stopped the business's marketing messages in WhatsApp (source is meta_user_preferences; marketing only, and turning them back on in WhatsApp is not a consent: no record, no event), by a team member in the UPPU panel (source is agent_recorded) or from a CSV import (source is import; one event per new record). The event reports the record that was written, not the person's current state: a consent collected before a newer withdrawal is still recorded and sends a granted event, and the withdrawal stays in force. Read the current state from the consents summary of GET /v1/contacts/{id}. UPPU applies the consent rules itself; use the event to keep your own lists in step.
{
"id": "01jace0e1v2m4n6p8q0r2s4t6e",
"object": "event",
"type": "contact.consent_changed",
"api_version": "v1",
"created_at": "2026-10-20T09:16:00.030Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": {
"id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"type": "whatsapp"
},
"test": false,
"data": {
"contact_id": "01j8x7ct5k2m4n6p8q0r2s4t6v",
"consent": {
"id": "01jab3cn5t2m4n6p8q0r2s4t6v",
"channel_type": "whatsapp",
"purpose": "marketing",
"state": "withdrawn",
"source": "inbound_keyword",
"recorded_at": "2026-10-20T09:16:00.000Z"
}
}
}
contact.erased
UPPU erased a person (for example on a data protection request). Delete or anonymize your copy of the person.
{
"id": "01jace0e1v2m4n6p8q0r2s4t6f",
"object": "event",
"type": "contact.erased",
"api_version": "v1",
"created_at": "2026-10-20T11:00:00.000Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": {
"id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"type": "whatsapp"
},
"test": false,
"data": {
"contact_id": "01j8x7ct5k2m4n6p8q0r2s4t6v"
}
}
template.updated
The status, category or quality of a WhatsApp template changed. Stop sending a template that is PAUSED or REJECTED. The envelope's channel is null.
{
"id": "01jace0e1v2m4n6p8q0r2s4t6g",
"object": "event",
"type": "template.updated",
"api_version": "v1",
"created_at": "2026-10-20T08:00:00.000Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": null,
"test": false,
"data": {
"template": {
"object": "template",
"id": "01jab3tp1a2m4n6p8q0r2s4t6v",
"name": "order_update",
"language": "tr",
"category": "UTILITY",
"status": "PAUSED",
"quality": "YELLOW",
"channel_ids": [
"01j9z0ch5k2m4n6p8q0r2s4t6v"
]
},
"change": {
"field": "status",
"previous": "APPROVED",
"current": "PAUSED",
"reason": null
}
}
}
channel.status_changed
A channel's status or mode changed, for example it was connected, paused or disconnected. reason explains why (for example token_invalid, disconnected, connected); accept reasons you do not know.
{
"id": "01jace0e1v2m4n6p8q0r2s4t6h",
"object": "event",
"type": "channel.status_changed",
"api_version": "v1",
"created_at": "2026-10-20T09:00:00.000Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": {
"id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"type": "whatsapp"
},
"test": false,
"data": {
"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"status": "paused",
"previous_status": "active",
"mode": "live",
"previous_mode": "live",
"reason": "token_invalid"
}
}
channel.health_changed
The health summary of a channel changed (green, amber, red, unknown); reasons lists the checks that are not green.
{
"id": "01jace0e1v2m4n6p8q0r2s4t6j",
"object": "event",
"type": "channel.health_changed",
"api_version": "v1",
"created_at": "2026-10-20T09:00:00.500Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": {
"id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"type": "whatsapp"
},
"test": false,
"data": {
"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"health": {
"status": "red",
"previous_status": "green",
"reasons": [
"token_invalid"
],
"checked_at": "2026-10-20T09:00:00.000Z"
},
"messaging_limit": "TIER_1K",
"quality": "GREEN"
}
}
conversation.updated
A conversation changed: who holds it, its status or its assignment. data.conversation is the conversation as GET /v1/conversations/{id} returns it at that moment, data.change what changed, data.reason why and data.actor who did it (type member, api with your key's api_key_prefix, bot or system). New endpoints leave this event off: subscribe to it explicitly. UPPU writes it only while one of your endpoints subscribes to it, so GET /v1/events has it only then too.
handed_over: the team took the conversation from UPPU Bot or your system: UPPU Bot handed over (reasonis its handover reason, for examplekeyword), an agent replied, claimed or assigned it (agent_takeover), or your system calledPOST /v1/conversations/{id}/handover(api).returned_to_botandreleased: the conversation went back to UPPU Bot (from the inbox or withPOST /v1/conversations/{id}/release) or to your system (releaseon an api channel).resolved,reopened,assignedandunassigned:reasonsays what caused it, for examplemanual(an agent in the inbox),api(your key),auto(automatic assignment) oridle(resolved after 24 hours without activity). A conversation that opens already assigned sendsassignedbefore themessage.receivedof its first message.- One action can send more than one event, in the order the changes happened: a take-over sends
handed_overand thenassigned. - When a team member leaves or is removed, their open conversations send
unassigned(reasonmember_removed,member_left,role_changed,account_deletedortenant_closed); their resolved conversations send nothing. - A change of the channel's handling in the panel sends no event, although the channel's open conversations change hands with it: an api channel moved to UPPU Bot or to the team inbox gives the conversations your system held to the team (
held_byteam). Only removing UPPU Bot from a channel sendshanded_over(bot_detached) for each conversation the bot held. Whenconversation.handlinginmessage.received(or the channel'shandling) changes, read the channel's open conversations again withGET /v1/conversations?channel_id=…&status=open. - The assignee's identity, the handover note and your own
reasonandnotenever appear. Accept changes, reasons and actor types you do not know.
{
"id": "01jace0e1v2m4n6p8q0r2s4t6k",
"object": "event",
"type": "conversation.updated",
"api_version": "v1",
"created_at": "2026-10-20T12:00:00.012Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": {
"id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"type": "whatsapp"
},
"test": false,
"data": {
"conversation": {
"object": "conversation",
"id": "01jab39zcv2m4n6p8q0r2s4t6v",
"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6v",
"contact_id": "01j8x7ct5k2m4n6p8q0r2s4t6v",
"status": "open",
"handling": "api",
"assigned": false,
"handed_over_at": "2026-10-20T12:00:00.000Z",
"held_by": "team",
"handover_reason": "api",
"opened_at": "2026-10-20T11:55:00.000Z",
"resolved_at": null,
"last_inbound_at": "2026-10-20T11:55:00.000Z",
"last_message_at": "2026-10-20T11:56:00.000Z",
"service_window": {
"open": true,
"closes_at": "2026-10-21T11:53:00.000Z"
}
},
"change": "handed_over",
"reason": "api",
"actor": {
"type": "api",
"api_key_prefix": "k7Hq2mP0aZ1x"
},
"occurred_at": "2026-10-20T12:00:00.000Z"
}
}
contact.reachability_changed
Telegram: a person blocked or unblocked the bot, or Telegram refused a send. reachable is false with blocked_by_contact or send_forbidden, and true with unblocked_by_contact or contact_wrote.
{
"id": "01jace0e1v2m4n6p8q0r2s4t6m",
"object": "event",
"type": "contact.reachability_changed",
"api_version": "v1",
"created_at": "2026-10-21T10:00:00.040Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": {
"id": "01j9z0tg5k2m4n6p8q0r2s4t6v",
"type": "telegram"
},
"test": false,
"data": {
"contact_id": "01j8x7ct5k2m4n6p8q0r2s4t6v",
"channel_id": "01j9z0tg5k2m4n6p8q0r2s4t6v",
"identity": {
"type": "telegram",
"value": "123456789"
},
"reachable": false,
"reason": "blocked_by_contact",
"occurred_at": "2026-10-21T10:00:00.000Z"
}
}
contact.updated
A person's contact changed: the name, the identities (an identity added or replaced, for example a WhatsApp user id or the user id your website signed), the company's contact fields, or a merge and its undo. data.contact is the contact as GET /v1/contacts/{id} returns it at that moment to a key for all channels, data.changes the fields that changed (display_name, identities, merged_into, notes, attributes; never the earlier values) and data.reason what changed it (member_edited, provider_name, pre_chat, identity_added, identity_rotated, signed_handoff, merged or merge_reverted). New endpoints leave this event off: subscribe to it explicitly. UPPU writes it only while one of your endpoints subscribes to it, so GET /v1/events has it only then too.
- One change sends one event; an unchanged write sends none. A merge sends one event for each of the two contacts: the merged contact gets
merged_intoand no identities, the contact it was merged into gets the identities. Undoing the merge sends one for each again. - A change of the team's notes alone sends no event; every event carries the current notes.
- The opening of a contact is not a change:
message.receivedcarries the new contact, also when your website signed the user before the first message. - Replacing or turning off your website's sign-in secret signs out the browsers of signed-in users: each of their contacts gets one event (
signed_handoff) in which thoseweb_visitoridentities arecurrent: false. - The envelope's
channelisnull, because a contact can have identities on several channels. The contact carries its identities on all channels, so an endpoint with a channel filter gets the event only when all of the contact's identities and conversations are on that channel; a contact on several channels goes only to endpoints without a channel filter.GET /v1/eventsreturns it only to a key for all channels. - Not sent for blocked contacts. Accept fields, changes and reasons you do not know.
- UPPU switches this event on for each environment; until then the event table above marks it as not produced yet: you can subscribe to it, nothing is sent.
{
"id": "01jace0e1v2m4n6p8q0r2s4t6m",
"object": "event",
"type": "contact.updated",
"api_version": "v1",
"created_at": "2026-10-26T12:00:00.012Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": null,
"test": false,
"data": {
"contact": {
"object": "contact",
"id": "01j8x7ct5k2m4n6p8q0r2s4t6v",
"display_name": "Ayşe Yılmaz",
"blocked": false,
"merged_into": null,
"identities": [
{
"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6w",
"type": "web_visitor",
"value": "wv_01jab3vs5k2m4n6p8q0r2s4t6v",
"current": true
},
{
"channel_id": "01j9z0ch5k2m4n6p8q0r2s4t6w",
"type": "external",
"value": "user-1001",
"current": true
}
],
"service_windows": [],
"consents": [],
"created_at": "2026-10-25T09:30:00.000Z",
"notes": null,
"attributes": {
"customer_no": "A-1001"
}
},
"changes": [
"display_name",
"identities"
],
"reason": "signed_handoff",
"occurred_at": "2026-10-26T12:00:00.000Z"
}
}
account.service_stop_scheduled
The company's service stops for billing at data.stop_at unless UPPU records the payment first: its payment is long overdue (reason billing_overdue) or its subscription has ended (subscription_ended). It is sent with UPPU's stop warning, at least 72 hours before the stop, which always falls at 10:00 Turkey time on a working day. From then on every API response to a caller that passes the key's IP allow-list carries the header UPPU-Service-Stop-At with the stop instant in force. The instant only ever moves later: if UPPU's warning reached nobody and goes again, a second account.service_stop_scheduled names the later instant, and the header always carries the current one.
- New endpoints subscribe to it by default, and an endpoint can leave it out of its event list. An endpoint whose list does not have it (for example one opened before it could be chosen) gets it once you add it. It goes to every subscribed endpoint, whatever its channel filter. The envelope's
channelisnull. - While the service is stopped, every API request gets
403 service_stopped, no other event is sent and due deliveries end dead withservice_stopped. Incoming messages are still recorded.
{
"id": "01jace0s7p2m4n6p8q0r2s4t6m",
"object": "event",
"type": "account.service_stop_scheduled",
"api_version": "v1",
"created_at": "2026-10-26T07:00:00.012Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": null,
"test": false,
"data": {
"stop_at": "2026-10-29T07:00:00.000Z",
"reason": "billing_overdue",
"occurred_at": "2026-10-26T07:00:00.000Z"
}
}
account.service_resumed
A service stopped for billing runs again: stopped_at is when it stopped, resumed_at when it opened again. Resend the deliveries that died with service_stopped from the panel; nothing is sent again by itself. Like account.service_stop_scheduled, it is on by default for new endpoints and goes to every subscribed endpoint, whatever its channel filter.
{
"id": "01jace0r3s2m4n6p8q0r2s4t6m",
"object": "event",
"type": "account.service_resumed",
"api_version": "v1",
"created_at": "2026-10-30T11:42:10.512Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": null,
"test": false,
"data": {
"stopped_at": "2026-10-29T07:00:00.000Z",
"resumed_at": "2026-10-30T11:42:10.500Z",
"occurred_at": "2026-10-30T11:42:10.500Z"
}
}
webhook.test
Sent by the test button and the wrong-signature probe of the panel and of the API, and by the weekly test event, always with test: true. It is not stored and never appears in GET /v1/events.
{
"id": "01jace0e1v2m4n6p8q0r2s4t6n",
"object": "event",
"type": "webhook.test",
"api_version": "v1",
"created_at": "2026-10-20T09:15:02.481Z",
"team_id": "01j8x7tm5k2m4n6p8q0r2s4t6v",
"channel": null,
"test": true,
"data": {
"note": "UPPU test olayı",
"endpoint_id": "01jab3ep5k2m4n6p8q0r2s4t6v"
}
}