OdyOdy Help

Receive webhooks and verify signatures

Updated Sun Aug 30 2026 00:00:00 GMT+0000 (Coordinated Universal Time)

Ody pushes workspace events — new messages, call outcomes, contact changes — to HTTPS endpoints you register through the API, with every delivery signed per the Standard Webhooks spec so you can prove it came from Ody.

Before you start

  • You need an API key with the webhooks:manage scope — see Get your Ody API key.
  • Your endpoint must be an HTTPS URL that responds with a 2xx status quickly (deliveries time out after 10 seconds).
  • Each workspace can register up to 50 endpoints.

Step-by-step

  1. List the event types you can subscribe to:
curl https://api.ody.co/v1/api/webhook-events \
  -H "Authorization: Bearer ody_live_…"

The catalog today: message.received, message.delivered, message.failed, call.ringing, call.answered, call.completed, call.forwarded, call.missed, call.recording.completed, call.summary.completed, call.transcript.completed, call.voicemail.completed, call.flow.started, call.flow.completed, call.flow.aborted, contact.updated, contact.deleted. Subscribing to "*" covers every current and future event type.

The call.flow.* events fire when a call is answered by a call flow: started when the caller enters the flow, completed when they reach a destination (with the destination type — ring, AI assistant, voicemail, forward, or hangup), and aborted if the flow couldn't finish.

  1. Register your endpoint:
curl https://api.ody.co/v1/api/webhooks \
  -H "Authorization: Bearer ody_live_…" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/ody-webhook","events":["message.received"],"label":"prod"}'
  1. Store the secret from the response immediately. The whsec_… signing secret is returned only by create and rotate — it is never shown again.
  2. Send yourself a signed test event and confirm your endpoint verifies and accepts it:
curl -X POST https://api.ody.co/v1/api/webhooks/WEBHOOK_ID/test \
  -H "Authorization: Bearer ody_live_…"

The test posts a {"type":"ping"} event signed exactly like a real delivery.

What a delivery looks like

Every delivery is a POST with a JSON body and three signature headers:

{
  "id": "evt_…",
  "apiVersion": "2026-06-01",
  "createdAt": "2026-08-16T12:00:00.000Z",
  "type": "message.received",
  "data": { "conversationId": "…", "activityId": "…", "numberE164": "+1…" }
}
Header Meaning
webhook-id The event id — deduplicate on it, since retries reuse it
webhook-timestamp Unix seconds at send time
webhook-signature v1,<base64 HMAC-SHA256> over <id>.<timestamp>.<raw body>

Verifying the signature

The HMAC key is the base64-decoded part of your secret after the whsec_ prefix, and the signed message is <webhook-id>.<webhook-timestamp>.<raw body>. Always verify against the raw request bytes — re-serializing parsed JSON breaks the signature. Reject timestamps more than a few minutes old to block replays.

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
app.post("/ody-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const secret = process.env.ODY_WEBHOOK_SECRET; // whsec_…
  const id = req.headers["webhook-id"];
  const timestamp = req.headers["webhook-timestamp"];
  const signature = req.headers["webhook-signature"];

  // Replay guard: reject deliveries older than 5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.status(401).end();

  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = "v1," + createHmac("sha256", key)
    .update(`${id}.${timestamp}.${req.body}`)
    .digest("base64");

  const a = Buffer.from(expected);
  const b = Buffer.from(String(signature));
  if (a.length !== b.length || !timingSafeEqual(a, b)) return res.status(401).end();

  const event = JSON.parse(req.body.toString("utf8"));
  console.log("verified event:", event.type);
  res.status(200).end();
});

The official SDKs ship this as a one-liner — verifyWebhookSignature (TypeScript) and verify_webhook_signature (Python), both with constant-time comparison and the 5-minute replay guard built in. See SDK quickstart: TypeScript and Python.

Retries, history, and management

  • Retries: a failed delivery (non-2xx, timeout, or connection error) is retried about 5 seconds and then 30 seconds after the first attempt — three attempts total, 10-second timeout each.
  • History: GET /v1/api/webhooks/:id/deliveries lists recent deliveries newest first, with per-attempt status codes, errors, and durations — your first stop when events seem missing.
  • Manage endpoints: GET /v1/api/webhooks lists them (never the secret), PATCH /v1/api/webhooks/:id updates url, events, label, or status (enabled/disabled), and DELETE /v1/api/webhooks/:id removes one along with its delivery history.
  • Rotate the secret any time with POST /v1/api/webhooks/:id/rotate — the new whsec_… is returned once, and old signatures stop validating immediately.

Troubleshooting

  • Signature verification always fails — you're probably verifying a re-serialized body. Use the raw request bytes, and confirm you base64-decoded the secret after stripping whsec_.
  • No deliveries arriving — check the endpoint's status is enabled, its events list includes the type you expect (or "*"), and the delivery history for per-attempt errors.
  • Duplicate events — retries reuse the same webhook-id. Deduplicate on it.
  • webhook limit reached — you're at 50 endpoints for the workspace; delete an unused one first.

Related articles

Frequently asked questions

How are webhook deliveries signed?

Per the Standard Webhooks spec: a webhook-signature header of the form v1,<base64 HMAC-SHA256> computed over '<webhook-id>.<webhook-timestamp>.<raw body>' with your whsec_ secret.

What happens if my endpoint is down?

Ody retries twice — about 5 seconds and then 30 seconds after the first failure. Check GET /v1/api/webhooks/:id/deliveries for per-attempt status codes and errors.

I lost my signing secret — what now?

Rotate it with POST /v1/api/webhooks/:id/rotate. The new whsec_ secret is returned once; old signatures stop validating immediately.

More in Developer API