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):

  1. 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.
  2. Copy the signing secret (whsec_…). It is shown once. You can rotate it later.
  3. Click Send test to deliver a ping, and see the response in the delivery log.

Events

TypeWhen
conversation.createdA visitor starts a conversation
conversation.message.createdA message is completed (visitor, agent or your team)
conversation.handoff.requestedThe visitor or the agent asks for a human
conversation.status.changedThe status changed: open, escalated, resolved, unresolved or failed (see Conversation statuses)
lead.capturedA visitor leaves contact details
usage.threshold.reachedYour workspace reaches 50%, 80% or 100% of its monthly credits (answers on own-key plans)
csat.submittedA 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.madeA 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 v1 signature, 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-insensitive

PHP

<?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-id and ignore repeats.
  • Order isn't guaranteed. Use created_at when 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.