Receive webhooks and verify signatures
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:managescope — see Get your Ody API key. - Your endpoint must be an HTTPS URL that responds with a
2xxstatus quickly (deliveries time out after 10 seconds). - Each workspace can register up to 50 endpoints.
Step-by-step
- 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.
- 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"}'
- Store the
secretfrom the response immediately. Thewhsec_…signing secret is returned only by create and rotate — it is never shown again. - 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/deliverieslists recent deliveries newest first, with per-attempt status codes, errors, and durations — your first stop when events seem missing. - Manage endpoints:
GET /v1/api/webhookslists them (never the secret),PATCH /v1/api/webhooks/:idupdatesurl,events,label, orstatus(enabled/disabled), andDELETE /v1/api/webhooks/:idremoves one along with its delivery history. - Rotate the secret any time with
POST /v1/api/webhooks/:id/rotate— the newwhsec_…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
statusisenabled, itseventslist 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.