Developers
Webhooks and events
Webhooks will send what happens in your workspace to your own systems. They are not delivered yet: this page says what exists today and what is coming.
Paths, field names, enum values, headers and error codes in these samples are the real ones, checked against the API's own specification when this page is built. The ids, amounts and names in them are illustrative. `$WETALK_API_KEY` and `$AGENT` are shell variables you set yourself.
Where webhooks are today
Webhooks are not delivered yet. Every delivery will be signed so your system can check it came from WeTalk, and signing secrets are not issued yet — WeTalk never sends an unsigned delivery, so nothing is sent until they are. Signed deliveries, with the secret shown to you once, are coming.
A destination is added through the API for now, with a key that may change agents (`agent:write`). There is no page in the console to add one yet; Settings → Developers lists the destinations you have registered. A destination belongs to a workspace, names an absolute `https://` URL and subscribes to one or more event kinds. Plaintext is refused: a signature proves who sent the body, not that nobody read it.
curl https://api.wetalk.io/v1/webhook-endpoints \
-X POST \
-H "Authorization: Bearer $WETALK_API_KEY"
-H "Content-Type: application/json" \
-H "Idempotency-Key: 0199f1c2-6b48-7d7a-b4bb-90712ca3e598" \
-d '{
"workspace_id": "0199f1c2-6b3f-7910-8c2d-5b0a4e9f1d66",
"url": "https://orders.romapizzeria.example/wetalk",
"event_kind": ["agent.published"]
}' Orders and survey answers
Neither needs a webhook. Confirmed orders are kept on the agent's Orders tab, and a program can read them through the API with a key that may read orders (`order:read`). Survey answers are kept in the console too, with each survey's results.
Event kinds
A destination subscribes to one or more of these. So far only `agent.published` is ever produced; the others are not produced yet.
- agent.published — a new version of an agent went live
- call.started, call.ended — a phone conversation began or ended
- thread.started, thread.ended — a web chat or messaging conversation began or ended
- transcript.ready — a conversation's transcript is ready
- order.confirmed, booking.created — outcome events
- campaign.finished — an outbound campaign finished
What a delivery will carry
The body will always be the same four fields. `data` is the event's own payload; everything outside it is the envelope, and the envelope is what you should read. Two convenience headers will carry the id and the kind, so a router can dispatch without parsing the body first.
POST /wetalk HTTP/1.1
Host: orders.romapizzeria.example
Content-Type: application/json
X-WeTalk-Signature: t=1789046400,v1=6b1f…
X-WeTalk-Event-Id: 0199f1c2-6b49-7e8b-85cc-a1823db4f6a9
X-WeTalk-Event-Kind: agent.published {
"event_id": "0199f1c2-6b49-7e8b-85cc-a1823db4f6a9",
"event_kind": "agent.published",
"created_at": "2026-09-14T19:20:00Z",
"data": {
"voice_agent_id": "0199f1c2-6b40-7a11-9d3e-6c1b5f0a2e77",
"agent_version_id": "0199f1c2-6b44-7c2e-a0d1-3e7f9b2c4a18",
"agent_version_label": "v3",
"previous_agent_version_id": "0199f1c2-6b42-7b91-8f40-2d6e8a1b3c07",
"action": "published",
"published_at": "2026-09-14T19:19:58Z"
}
} Verifying a delivery
The header will be comma-separated: one `t=` and one or more `v1=`. The signed string is the timestamp, a full stop, and the raw body exactly as transmitted — not a re-serialised copy of it, which can differ in key order or whitespace and will fail.
The timestamp is inside the signed string, which is what makes the replay window enforceable: reject anything where your clock and ours differ by more than 300 seconds. If `t` were outside the signature an attacker would simply rewrite it.
While a secret is being replaced the header will carry one `v1=` per secret, so check every `v1=`, in constant time, and stop at the first that matches. Replacing a secret is not available yet; it comes with the secrets themselves.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECOND = 300;
export function verifyWeTalkSignature(header, secret, rawBody) {
const part = header.split(",").map((one) => one.trim());
const stamp = part.find((one) => one.startsWith("t="))?.slice(2);
if (stamp === undefined || !/^[0-9]+$/.test(stamp)) return false;
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(stamp));
if (skew > TOLERANCE_SECOND) return false;
const expected = createHmac("sha256", secret)
.update(`\${stamp}.\${rawBody}`, "utf8")
.digest("hex");
return part
.filter((one) => one.startsWith("v1="))
.some((one) => constantTimeEqual(one.slice(3), expected));
}
function constantTimeEqual(left, right) {
const a = Buffer.from(left, "utf8");
const b = Buffer.from(right, "utf8");
return a.length === b.length && timingSafeEqual(a, b);
} Deliver-once is not on offer
Delivery will be at-least-once. A `200` that never reaches us — the socket dies after you committed — is indistinguishable from a failure, so we retry and you see the event twice. The alternative is marking it delivered on uncertainty, which loses events, and losing an event is worse than handling one twice.
So the contract is: **be idempotent on `event_id`.** It is a UUID v7, it is stable across every retry of the same event, and it is the only key you need.
The delivery log
Every attempt we make is written down, and especially the ones that failed. A customer saying "your webhook never fired" is answered by this log and by nothing else, so it records the response status you returned, an excerpt of your response body, how long the attempt took and the transport error where there was one.
Until signing secrets are issued nothing is sent, and the log says so: each attempt is recorded with no response status and the error
Error: Cannot sign a webhook with no signing secret. Fail closed., and retried like any other failure.
It is one page of rows per endpoint, newest first, cursor-paginated like every other collection. The row is an audit of what left our side, not an interpretation of it.
curl -G https://api.wetalk.io/v1/webhook-endpoints/$ENDPOINT/attempts \
-H "Authorization: Bearer $WETALK_API_KEY" \
--data-urlencode "limit=50" | Field | What it means |
|---|---|
| attempt_index | 1 for the first delivery, then one per retry. It is the dispatcher's own attempt counter, so it is stable across restarts and never starts again at 1 for the same event. |
| requested_at | When we made the request, not when we decided to. |
| response_status | What you answered. Null when the request never produced a response at all — a refused connection, a TLS failure or our own timeout. |
| response_body_excerpt | The first 2048 characters of your response body. Enough to find the stack trace you returned; not enough to be a copy of your logs. |
| duration_ms | How long the attempt took, including a timeout that ran to the full ten seconds. |
| error | The transport failure, the timeout, or the sentence describing a non-2xx answer. Null on an accepted delivery. |
| outbox_message_id | The queued message this attempt was dispatched from. Every attempt at one event shares it, which is how the retries of a single event group together. |
| webhook_endpoint_id | Which of your endpoints it was aimed at. |
| Attempt | Requested at | Status | Duration | Outcome | Error |
|---|---|---|---|---|---|
| 1 | 2026-09-14T19:20:00Z | 500 | 212 ms | Retried | the receiver answered 500 |
| 2 | 2026-09-14T19:20:30Z | — | 10000 ms | Retried | AbortError: This operation was aborted |
| 3 | 2026-09-14T19:21:30Z | 200 | 148 ms | Accepted | — |
The retry schedule
A delivery counts as accepted on any 2xx. Anything else — a 4xx, a 5xx, a refused connection, or a request that has not answered within 10 seconds — is a failure, and the message goes back on the queue.
The wait doubles from thirty seconds and stops growing at 6 hours. The cap matters as much as the doubling: uncapped, an endpoint that came back on day three would not be tried again until day six. Capped, it is retried within six hours of recovering, which is the shape the common case wants — somebody's endpoint being down for an afternoon.
There is no jitter, deliberately. Jitter spreads retries that were all scheduled at the same instant, and these were not: they became due when the conversation that produced them happened. What jitter would cost is a reproducible log, and "why was attempt nine at 14:03" should never be answered with "randomly".
| Attempt | Waits | Falls at |
|---|---|---|
| 2 | 30 seconds | 30 seconds after the event |
| 3 | 1 minute | 1 minute 30 seconds after the event |
| 4 | 2 minutes | 3 minutes 30 seconds after the event |
| 5 | 4 minutes | 7 minutes 30 seconds after the event |
| 6 | 8 minutes | 15 minutes 30 seconds after the event |
| 7 | 16 minutes | 31 minutes 30 seconds after the event |
| 8 | 32 minutes | 1 hour 3 minutes 30 seconds after the event |
| 9 | 1 hour 4 minutes | 2 hours 7 minutes 30 seconds after the event |
| 10 | 2 hours 8 minutes | 4 hours 15 minutes 30 seconds after the event |
| 11 | 4 hours 16 minutes | 8 hours 31 minutes 30 seconds after the event |
Retrying stops 7 days after the event, measured from the event and not from the first attempt — a message that waited in the queue has not earned an extra week on top of that. Within that window an endpoint that never answers is tried about 37 times, and then the message is abandoned rather than retried forever. The attempts stay in the log.
Two more rules worth knowing. A response body is kept only to its first 2048 characters, which is enough to find the stack trace you returned and not enough to be a copy of your logs. And if you delete or disable an endpoint while events are still queued for it, the queued events stop — but an attempt row is still written for each, saying so, because a log that goes silent explains nothing.
The endpoint, its parameters and its response shape are in the API reference, which is generated from the same specification the API serves.