Webhooks
Get notified when something happens: a lead is captured, a visitor asks for a human, a conversation changes status. OneType sends a signed POST to your HTTPS endpoint.
Status: Available (Premium). The signing scheme below is final, and the verification code on this page is tested against the official Standard Webhooks test vector.
Set up an endpoint
In Developer › Webhooks (owner or admin):
- Add an HTTPS URL, and pick the events or all of them (
*). Optionally pick Agents: the endpoint then receives only those agents' events, and no workspace-wide events. With no agents picked it receives every agent's events and the workspace-wide ones. - Copy the signing secret (
whsec_…). It is shown once. You can rotate it later. - Click Send test to deliver a
ping, and see the response in the delivery log.
Events
| Type | When |
|---|---|
conversation.created | A visitor starts a conversation |
conversation.message.created | A message is completed (visitor, agent or your team) |
conversation.handoff.requested | The visitor or the agent asks for a human |
conversation.status.changed | The status changed: open, escalated, resolved, unresolved or failed (see Conversation statuses) |
lead.captured | A visitor leaves contact details |
usage.threshold.reached | Your workspace reaches 50%, 80% or 100% of its monthly credits (answers on own-key plans) |
csat.submitted | A visitor rated a conversation (once, final). data.object is a csat_rating with conversation, agent, rating (1 to 5), handled_by (ai or human), comment (the visitor's own words, or null) and created_at. It carries the comment, so treat it as personal data |
booking.made | A visitor booked a meeting from the chat's booking card (needs Cal.com or Calendly, coming soon). data.object is a booking with event_type, start_at, status, contact, conversation and matched_by (token: booked from this conversation's card; email: only a verified email matched, not proof) |
More event types will be added over time. Ignore types you don't handle. Fields can be added to the envelope and to objects over time as well: ignore the ones you don't use.
Payload
{
"id": "evt_01J9…",
"type": "lead.captured",
"created_at": "2026-09-22T10:00:00Z",
"api_version": "v1",
"workspace": "wsp_…",
"agent": "agt_…",
"data": { "object": { "id": "con_…", "object": "contact", "email": "jane@example.com", "name": "Jane" } }
}data.object is the same object the REST API returns for that resource. Message objects include is_ai_generated.
agent is the id of the agent the event belongs to, in the same agt_… form the REST API uses. It is present on events about one agent's conversations and visitors: conversation.created, conversation.message.created, conversation.handoff.requested, conversation.status.changed, csat.submitted, lead.captured and contact.identified. It is also on booking.made when the booking came from an agent's conversation or person. It is left out when no agent is involved: workspace-wide events such as usage.threshold.reached, a booking.made that matched no agent, and contact changes that did not come from a visitor (for example an edit by your team). Treat it as optional and read the resource from data.object. The field is an addition: existing endpoints start receiving it with no change, so a receiver that rejects unknown fields has to allow agent.
Verifying signatures
OneType signs every delivery with Standard Webhooks, an open scheme also used by several large API providers. Every request has three headers:
webhook-id: evt_01J9… (the same on every retry of this event)
webhook-timestamp: 1790000000 (Unix seconds)
webhook-signature: v1,K5oZfz…= (can hold several, space-separated, during secret rotation)The signature is base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}")), where key is your secret without the whsec_ prefix, base64-decoded.
Always:
- verify against the raw request body, before any JSON parsing or re-serializing
- reject timestamps more than 5 minutes from now, which prevents replays
- compare in constant time
- accept any listed
v1signature, so rotations never break you
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyOnetypeSignature(rawBody, headers, secret, toleranceSeconds = 300)
{
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures)
{
throw new Error("missing webhook headers");
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds)
{
throw new Error("timestamp outside tolerance");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
for (const entry of signatures.split(" "))
{
const [version, value] = entry.split(",");
const candidate = Buffer.from(value ?? "", "base64");
if (version === "v1" && candidate.length === expected.length && timingSafeEqual(candidate, expected))
{
return JSON.parse(rawBody);
}
}
throw new Error("no matching signature");
}
// Express: keep the raw body for this route
app.post("/hooks/onetype", express.raw({ type: "application/json" }), (req, res) => {
let event;
try { event = verifyOnetypeSignature(req.body.toString("utf8"), req.headers, process.env.ONETYPE_WEBHOOK_SECRET); }
catch { return res.sendStatus(400); }
if (event.type === "lead.captured") crm.upsert(event.data.object);
res.sendStatus(204);
});To route by agent, read event.agent (for example if (event.agent === "agt_…") …); it is absent on workspace-wide events. Or give each agent its own endpoint with Agents (agentIds in the API): a redelivery the endpoint no longer takes is refused, and a test is signed as one of its agents.
Python
import base64, hashlib, hmac, json, time
def verify_onetype_signature(raw_body: bytes, headers, secret: str, tolerance_seconds: int = 300):
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures:
raise ValueError("missing webhook headers")
if abs(time.time() - int(timestamp)) > tolerance_seconds:
raise ValueError("timestamp outside tolerance")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, value = entry.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
return json.loads(raw_body)
raise ValueError("no matching signature")
# Flask: request.get_data() is the raw body; request.headers is case-insensitivePHP
<?php
function verifyOnetypeSignature(string $rawBody, array $headers, string $secret, int $toleranceSeconds = 300): array
{
$id = $headers['webhook-id'] ?? null;
$timestamp = $headers['webhook-timestamp'] ?? null;
$signatures = $headers['webhook-signature'] ?? null;
if (!$id || !$timestamp || !$signatures) {
throw new RuntimeException('missing webhook headers');
}
if (abs(time() - (int) $timestamp) > $toleranceSeconds) {
throw new RuntimeException('timestamp outside tolerance');
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));
foreach (explode(' ', $signatures) as $entry) {
[$version, $value] = array_pad(explode(',', $entry, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $value)) {
return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
}
}
throw new RuntimeException('no matching signature');
}
// Usage: $raw = file_get_contents('php://input');
// $headers = array_change_key_case(getallheaders(), CASE_LOWER);No-code tools (Zapier, Make)
Their basic catch hooks accept any request and cannot check the signature. Treat the hook URL as a secret. Where the tool offers a code step, run the same check there: it needs the raw body, the three webhook-* headers and your secret. Deduplicate on webhook-id either way, because a retry can deliver an event twice. A step-by-step guide for owners is in the help centre: Connect Zapier or Make.
Check your implementation with the Standard Webhooks test vector:
- secret
whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw - id
msg_p5jXN8AQM9LWM0D4loKWxJek - timestamp
1614265330 - body
{"test": 2432232314} - expected result
v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
Responding and retries
- Answer any 2xx within 10 seconds. Do slow work after responding, e.g. queue it.
- On a non-2xx response or a timeout, we retry over about 3 days: after 5 s, 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h, 12 h, then every 24 h three times.
- Delivery is at least once. Store
webhook-idand ignore repeats. - Order isn't guaranteed. Use
created_atwhen order matters. - An endpoint that only fails for 3 days is disabled, and owners see a banner. Re-enable it in the dashboard; new events flow from then.
- The delivery log (30 days) shows every attempt with its status and timing. Redeliver sends an event again.
- We only deliver to public HTTPS addresses, and don't follow redirects.