Webhooks
This page covers two directions: events we send to you, and a signal your system uses to wake an agent.
They use different signing schemes
This is the easiest thing to mix up. Outgoing webhooks sign timestamp + "." + body. The incoming trigger signal signs only the body.
Outgoing: operator events
We send a POST to your address on three events. The webhook address is set in the agent's configuration.
| Event | When | What's inside |
|---|---|---|
escalated | the conversation was handed over to a person | transcript — the recent context |
visitor_message | the visitor wrote in a handed-over conversation | text — their message |
resolved | the request was closed | — |
{
"event": "escalated",
"bot_id": "bot_7f3a1c",
"session_id": "3f6c1b9e-7f42-4d2a-9a6f-0d5b1f0b21c7",
"transcript": "Customer: Order #4417 still hasn't arrived\nBot: According to the carrier, the parcel is on its way…\nCustomer: I was told that a week ago. Get me a person.",
"at": 1753600000123
}session_id is the key to everything that follows: it's what the operator part of the API works with.
The resolved event also arrives when you closed the request yourself through the API. That's deliberate: this way several operator systems stay in sync.
Signature
Every delivery is signed with two headers:
X-YouSelfBot-Timestamp: 1753600000
X-YouSelfBot-Signature: sha256=<hex>The signature is HMAC-SHA256(secret, "<timestamp>.<raw body>") in hex. The agent's secret is in its configuration (handoff_webhook_secret) and starts with whsec_.
The timestamp is part of the signature
The signed string is timestamp + "." + body. This lets you reject old deliveries: an intercepted request can't be replayed later, even if the signature is still mathematically valid.
import hashlib, hmac, time
def verify(raw_body: bytes, headers, secret: str, tolerance: int = 300) -> bool:
ts = headers.get("X-YouSelfBot-Timestamp", "")
got = headers.get("X-YouSelfBot-Signature", "").removeprefix("sha256=")
if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
return False # stale delivery
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256)
return hmac.compare_digest(mac.hexdigest(), got)import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, headers, secret, tolerance = 300) {
const ts = headers['x-youselfbot-timestamp'] ?? '';
const got = (headers['x-youselfbot-signature'] ?? '').replace(/^sha256=/, '');
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > tolerance) return false;
const want = createHmac('sha256', secret)
.update(ts + '.')
.update(rawBody)
.digest('hex');
const a = Buffer.from(want);
const b = Buffer.from(got);
return a.length === b.length && timingSafeEqual(a, b);
}Three rules that are easy to break:
- Compute the HMAC over the raw body, before JSON parsing. Reformatted JSON gives a different signature.
- Compare in constant time (
compare_digest,timingSafeEqual), not with==. - Reject old deliveries — a few minutes of tolerance is enough.
Retries
We retry a failed delivery three times — after 1, 5 and 25 seconds. Only 5xx and network errors are retried: a 4xx means your system is saying the request is wrong, and there's no point repeating it.
Two consequences follow:
- The handler must be idempotent — the same event can arrive twice.
- Reply
200right away, and do the work afterwards. A delivery gets 5 seconds.
The webhook address must be public: we deliberately don't make requests into private networks.
Incoming trigger signal
The reverse direction: your system wakes the agent when something happened on your side.
You create a trigger in the conversation with the builder: "let a signal from my system wake the agent". It gives you the address with the secret once — save it right away. See So it works without you.
POST https://api.youselfbot.com/v1/hooks/{tid}Signature
Authentication is an HMAC signature of the body, not a key in a header:
X-Hook-Signature: sha256=<hex HMAC-SHA256(secret, body)>Only the body is signed, without a timestamp — unlike the outgoing webhooks above.
BODY='{"order_id":4417,"status":"delayed"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$HOOK_SECRET" -hex | awk '{print $2}')
curl -sS -X POST "https://api.youselfbot.com/v1/hooks/trg_01j9x2h7q0" \
-H "X-Hook-Signature: sha256=$SIG" \
-H "Content-Type: application/json" \
-d "$BODY"import hashlib, hmac, json, requests
body = json.dumps({"order_id": 4417, "status": "delayed"}).encode()
sig = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
requests.post(
"https://api.youselfbot.com/v1/hooks/trg_01j9x2h7q0",
data=body,
headers={"Content-Type": "application/json", "X-Hook-Signature": "sha256=" + sig},
)The most common mistake is computing the HMAC over reformatted JSON. Sign the same bytes you send.
The trigger identifier in the address is unpredictable, but it isn't treated as a secret: the address ends up in other people's configurations and logs. So without a valid signature it's always 401.
Two response behaviours
Set in the trigger's properties (the /v1/dashboard/* group).
"Acknowledge immediately" — 202 Accepted straight away, the work runs in the background:
{ "status": "accepted" }"Wait for the agent's answer" — the connection is held until the work is finished:
{
"run_id": "run_01j9x2m4p8",
"status": "done",
"answer": "The customer has been notified, delivery moved to tomorrow."
}The first option is right almost every time: other systems often have a short timeout, and the agent's work can take minutes. Choose the second only when the caller needs the answer immediately — and set your own timeout on your side.
In the second option, the answer is read by whoever sent the signal. That's why in such a run the agent doesn't see your email, its notes about you or your calendar events (only busy times) — otherwise the signal body could ask it to retell them. Set personal tasks to "Acknowledge immediately": there the agent sees everything, and can tell you the result itself — with a notification or an email, if the task says so.
If a synchronous run failed — 502 with a run_id, so you can find it in the run log (GET /v1/dashboard/bots/{id}/runs).
What the agent sees
The run's input is the trigger's Run task plus the raw signal body:
Process the event from the CRM and, if needed, write to me.
Подія (тіло вебхука):
{"order_id":4417,"status":"delayed"}The line before the body is added by the platform and is currently always in Ukrainian ("Event (webhook body)😊; the task text above it is yours, in whatever language you wrote it.
In other words, in the trigger you write what to do, and the signal brings what to do it with.
Limits
| Body size | up to 256 KiB |
| Disabled trigger | 409 |
| Unknown or deleted trigger | 404 |
| Invalid signature | 401 |
There's no deduplication
A repeated delivery of the same event makes a second run. If your source can deliver an event twice, keep a "processed" marker in the agent's state (GET/PUT /v1/dashboard/bots/{id}/state).
Five failed runs in a row pause the trigger
This protects against a broken task that would spend credits all night. You'll need to turn the trigger back on by hand; a notification about the pause is sent — see When something breaks.
Next
For developers — calls for conversation, operator work and knowledge.