Stop asking. We will tell you.
Delivered, read, failed, replied to, unsubscribed. 7 events, signed, and retried for about a day if your server is having an afternoon.
Set an address in Settings → API and press Send test event. Polling
GET /messages/{id} works and is the wrong way round — a delivery receipt arrives when it arrives.
What you get told
Every event, and when it fires.
The one worth wiring first is
contact.opted_out. It arrives the moment somebody replies STOP, which is the moment your own system
should stop trying.
- message.sent
message.delivered
message.read
message.failed - A message sent through the API changes state
- message.received
- Somebody writes to your number
- contact.opted_out
- Somebody replies STOP, or is marked opted out in talkv
- contact.opted_in
- Somebody who had opted out writes START
- broadcast.finished
- A broadcast has sent everything and its figures have settled
- template.approved
template.rejected - Meta decides on a template
- webhook.test
- You press Send test event in Settings → API
| Event | When |
|---|---|
| message.sent message.delivered message.read message.failed | A message sent through the API changes state |
| message.received | Somebody writes to your number |
| contact.opted_out | Somebody replies STOP, or is marked opted out in talkv |
| contact.opted_in | Somebody who had opted out writes START |
| broadcast.finished | A broadcast has sent everything and its figures have settled |
| template.approved template.rejected | Meta decides on a template |
| webhook.test | You press Send test event in Settings → API |
The body
One shape, every time.
Switch on
type, read data, and
ignore a type you do not recognise — so a new event can never break the
handler you already have.
Fields
-
idstring required -
typestring required - message.sent · message.delivered · message.read · message.failed · message.received · contact.opted_out · contact.opted_in · broadcast.finished · template.approved · template.rejected · webhook.test
-
created_atdate-time required -
dataobject required - message.* →
{message: Message}; message.received →{message: {id, from, type, text, received_at}, contact: Contact}; contact.* →{contact: Contact}; broadcast.finished →{broadcast: Broadcast}; template.* →{template: Template, reason}.
| Field | Type | Needed | What it is |
|---|---|---|---|
| id | string | required | |
| type | string | required | message.sent · message.delivered · message.read · message.failed · message.received · contact.opted_out · contact.opted_in · broadcast.finished · template.approved · template.rejected · webhook.test |
| created_at | date-time | required | |
| data | object | required | message.* → {message: Message}; message.received → {message: {id, from, type, text, received_at}, contact: Contact}; contact.* → {contact: Contact}; broadcast.finished → {broadcast: Broadcast}; template.* → {template: Template, reason}. |
{
"id": "evt_01JB8Z3T6Q0K7V9X2M4N5P6R7S",
"type": "message.delivered",
"created_at": "2026-10-04T10:15:09+05:30",
"data": { "message": { "id": 812, "to": "+919825012345", "status": "delivered" } }
} Getting it right
Three mistakes, in the order people make them.
-
Answer in ten seconds
Return any 2xx and do the work afterwards, on your own queue. Anything slower is a failure as far as the retry schedule is concerned.
-
Expect it twice
Ignore an event whose id you have already seen. Retries and redeliveries are normal; an order marked shipped twice is your bug, not ours.
-
Verify the raw bytes
Compute the HMAC before parsing the JSON. Re-encoding the body changes the bytes, and then the signature can never match.
Checking the signature
Signed with Talkv-Signature: t=<unix time>,v1=<hex HMAC-SHA256 of "t.raw body"> using the workspace's webhook secret. Answer 2xx within 10 seconds; anything else is retried for about a day. Events can repeat — use id.
The secret is in Settings → API, beside the address. If it may have been seen, New secret replaces it — and the old one stops working at once.
[$t, $v1] = sscanf($_SERVER['HTTP_TALKV_SIGNATURE'], 't=%d,v1=%s');
$expected = hash_hmac('sha256', $t.'.'.file_get_contents('php://input'), $secret);
$valid = hash_equals($expected, $v1) && abs(time() - $t) < 300; Questions
Asked while wiring it up.
How do I verify a talkv webhook signature?
Every request carries a Talkv-Signature header of the form t=<unix time>,v1=<hex>. Compute the HMAC-SHA256 of the timestamp, a full stop, and the raw request body, keyed with the webhook secret from Settings → API. Compare it in constant time and reject any timestamp more than five minutes old.
What happens if my endpoint is down?
talkv retries after about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, then stops. Answer with any 2xx within 10 seconds and do the work afterwards — a slow endpoint is treated as a failed one.
Can the same webhook event arrive twice?
Yes, by design. Use the event id to ignore repeats. Every event carries one, it never changes between retries, and events usually arrive within a minute of the thing they describe.
Do I get a webhook for every message in a broadcast?
No. Message events cover messages you sent through the API one at a time. A broadcast sends one broadcast.finished event when its figures have settled, and GET /broadcasts/{id}/recipients has the per-person detail.
Every endpoint, every field, one page.
API reference