Skip to content
For developers

Webhooks

Otto sends a POST to your URL when something happens: a new conversation, a handoff, a contact, a complaint, an imported catalogue. Signed per Standard Webhooks.

Setting it up

  1. In the panel open Settings, the Connections section, the Webhooks row.
  2. Enter a public https:// address of your server and pick the events. If you leave all of them selected, we also send events added later.
  3. Save. The panel shows the signing secret whsec_…, keep it to verify signatures.
  4. Send test event checks the connection right away. You receive a test.ping event.
Webhook settings in the Otto panel with the event list
Webhooks in Settings: the URL and the events.

Events

EventWhen it comesdata
conversation.startedA conversation started
When a customer sends their first message.
{ conversationId, question }
conversation.handoffThe customer wants a human
Otto cannot help, or the customer asked for a human.
{ conversationId, question, confidence }
conversation.operator_joinedA team member took over
Someone from your team took over the conversation in the Inbox.
{ conversationId, operator }
conversation.resolvedA conversation was resolved
You marked it resolved in the Inbox.
{ conversationId }
conversation.ratedThe customer rated the conversation
Thumbs up or down at the end of the conversation.
{ conversationId, rating }
message.created opt-inA new message in a conversation
Every message from the customer, the assistant and your colleagues. There are many, so only turn it on if you really process them.
{ id, conversationId, role, kind, text, author }
lead.createdA contact was left
A name, email or phone given right in the chat.
{ id, conversationId, name, email, phone }
lead.updatedA contact was handled or reopened
Someone in the panel or through the API changed whether a contact is handled.
{ id, conversationId, handled }
return.createdA complaint or return was filed
The customer submitted the form with any attachments.
{ id, kind, orderId }
return.updatedA complaint or return changed status
You approved, rejected or handled it.
{ id, kind, orderId, status }
unanswered.createdUnsure answer
A question with no confident answer in your knowledge.
{ conversationId, question }
catalog.syncedCatalogue imported
The product feed was imported, with the number of products.
{ source, products }
catalog.failedCatalogue import failed
The product feed couldn’t be imported. Sent on the first failure, not on every one after.
{ source, error }
usage.thresholdAI reply limit running low
Sent at 80% and at 100% of your plan’s AI reply limit.
{ used, limit, level }

message.created only comes when you turn it on yourself. There are many of them, and Select all leaves it out.

Request body

POST to your URL
{
  "id": "1b6f6a3e-0f7c-4a51-9a3d-6a2f0e8c4b11",
  "event": "lead.created",
  "at": "2026-10-10T08:14:03.512Z",
  "apiVersion": "1",
  "tenantId": "3f6c2a10-8d4e-4c1b-a6f2-9e0d7b5c3a21",
  "tenant": "Váš obchod",
  "data": {
    "id": "8353028d-e582-4e08-8ed9-f882d200cd22",
    "conversationId": "37e7d86d-57bf-455f-9fc6-8c856a607d2f",
    "name": "Ján Novák",
    "email": "jan@example.sk",
    "phone": ""
  }
}

The id stays the same across retries. When the same id arrives again, drop the duplicate.

Verifying the signature

We sign per Standard Webhooks, so a ready-made library for Node.js, PHP, Python and other languages can verify it. Every request carries three headers:

webhook-idthe event id, the same across retries
webhook-timestampthe attempt time in seconds since 1 January 1970
webhook-signaturev1,<signature>, where the signature is Base64 of HMAC-SHA256 over id.timestamp.body

The HMAC key is the signing secret without the whsec_ prefix, decoded from Base64. Compute the signature from the raw body, before you parse the JSON. Reject the attempt when the signature does not match or the time is more than 5 minutes off.

Node.js
import crypto from "node:crypto";

export function overPodpis(secret, hlavicky, suroveTelo) {
  const id = hlavicky["webhook-id"];
  const cas = hlavicky["webhook-timestamp"];
  const kluc = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const ocakavany = crypto.createHmac("sha256", kluc).update(id + "." + cas + "." + suroveTelo).digest("base64");
  const sedi = String(hlavicky["webhook-signature"] || "").split(" ").some((cast) => {
    const [verzia, podpis] = cast.split(",");
    return verzia === "v1" && podpis && podpis.length === ocakavany.length
      && crypto.timingSafeEqual(Buffer.from(podpis), Buffer.from(ocakavany));
  });
  return sedi && Math.abs(Date.now() / 1000 - Number(cas)) < 300;
}
PHP
function overPodpis(string $secret, array $h, string $suroveTelo): bool {
  $kluc = base64_decode(preg_replace('/^whsec_/', '', $secret));
  $sprava = $h['webhook-id'] . '.' . $h['webhook-timestamp'] . '.' . $suroveTelo;
  $ocakavany = base64_encode(hash_hmac('sha256', $sprava, $kluc, true));
  foreach (explode(' ', $h['webhook-signature'] ?? '') as $cast) {
    [$verzia, $podpis] = array_pad(explode(',', $cast, 2), 2, '');
    if ($verzia === 'v1' && hash_equals($ocakavany, $podpis)) {
      return abs(time() - (int) $h['webhook-timestamp']) < 300;
    }
  }
  return false;
}
Python
import base64, hashlib, hmac, time

def over_podpis(secret: str, h: dict, surove_telo: bytes) -> bool:
    kluc = base64.b64decode(secret.removeprefix("whsec_"))
    sprava = f"{h['webhook-id']}.{h['webhook-timestamp']}.".encode() + surove_telo
    ocakavany = base64.b64encode(hmac.new(kluc, sprava, hashlib.sha256).digest()).decode()
    sedi = any(
        hmac.compare_digest(cast.split(",", 1)[1], ocakavany)
        for cast in h.get("webhook-signature", "").split()
        if cast.startswith("v1,")
    )
    return sedi and abs(time.time() - int(h["webhook-timestamp"])) < 300

We still send the original X-Otto-Signature: sha256=<HMAC_SHA256(secret, body) in hex> header, so older integrations keep working.

Retries and disabling

Answer with a 2xx within 5 seconds and do the work afterwards. When no answer comes or it is not 2xx, we try again after: right away, 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h, 14 h, 24 h, about 3 days in total. Events wait in our database, so a restart on our side does not lose them. We do not retry a 4xx other than 408 and 429.

If we deliver nothing for 3 days, we disable the webhook and email you. Save it again in the panel to turn it back on.

Deliveries through the API