Webhooks
We push events to your server in real time. Subscribe with a URL,
verify the signature on every delivery, and respond 2xx fast.
Paths under
/v1/.Authorization: Bearer <api-key>required for management endpoints. Webhook deliveries themselves arrive at your URL and are authenticated by the signature header.
At a glance
Your call happens → voepy receives carrier event
→ voepy translates to public event
→ POST https://yourapp.com/webhooks/voepy
← you respond 2xx within 10s
If you don't respond 2xx, we retry with exponential backoff for up
to 24 hours. After that the delivery is marked failed and you can
inspect it at GET /v1/webhook-deliveries.
Subscribe
POST /v1/webhook-subscriptions
Content-Type: application/json
{
"target_url": "https://yourapp.com/webhooks/voepy",
"event_types": ["*"],
"description": "Primary event handler",
"connection_id": null
}
| Field | Notes |
|---|---|
target_url | HTTPS required. Must respond within 10s. |
event_types | Array of event names, or ["*"] to catch everything. |
connection_id | Optional. Scope to a single connection so only its DIDs' events fire. null = tenant-wide. |
description | Free-form, ≤200 chars. |
Response includes signing_secret exactly once. Save it now —
you'll need it for verification. (Lose it and you'll have to delete
and recreate the subscription.)
{
"id": "9f8c…",
"target_url": "https://yourapp.com/webhooks/voepy",
"event_types": ["*"],
"signing_secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"is_active": true,
"created_at": "2026-05-12T14:32:18.000Z"
}
Available event types
| Category | Events |
|---|---|
| Call | call.started, call.answered, call.bridged, call.ended, call.dtmf, call.gather_complete, call.amd_complete, call.transcript_chunk |
| Recording | recording.completed, recording.failed |
| Streaming | stream.started, stream.stopped, stream.failed |
| Conference | conference.started, conference.ended, conference.member_joined, conference.member_left, conference.member_muted, conference.member_unmuted |
| Number order | number_order.completed, number_order.failed |
Subscribe with ["*"] if you want everything; otherwise list specific
types you handle. You can edit event_types later with PATCH /v1/webhook-subscriptions/{id}.
The delivery envelope
Every webhook has the same envelope:
{
"event_id": "0c4f5d8a-1ad1-4e79-9cf6-2e7d3f9b0a11",
"event_type": "call.started",
"occurred_at": "2026-05-12T14:32:18.000Z",
"data": {
"call_id": "voepy_call_01HZ…",
"from": "+15125550100",
"to": "+15558675309",
"direction": "inbound",
"route_id": "voepy_route_01HZ…",
"customer_reference": "cart-abc-123"
}
}
Headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | sha256=<hmac_hex> |
X-Webhook-Timestamp | Unix seconds (integer) |
X-Webhook-Event-Id | The event_id from the body — use this to dedupe |
User-Agent | webhook-platform/<version> |
Sample data shapes
// call.started
{ "call_id": "...", "leg_id": "...", "from": "...", "to": "...",
"direction": "outbound" | "inbound", "route_id": "...",
"customer_reference": "..." }
// call.answered
{ "call_id": "...", "leg_id": "...", "answered_at": "..." }
// call.ended
{ "call_id": "...", "leg_id": "...", "ended_at": "...",
"duration_seconds": 38, "end_reason": "completed",
"end_initiator": "callee" }
// recording.completed
{ "call_id": "...", "recording_id": "...", "channels": "dual",
"format": "mp3", "duration_seconds": 42, "download_url": "..." }
Full shapes per event are in the OpenAPI spec at https://api.voepy.com/api/docs/.
Verify the signature
Every webhook is signed. Always verify before processing. Anyone can POST to your URL; the signature is what proves it came from us.
The algorithm
hmac_sha256(signing_secret, f"{timestamp}.{raw_body}")
Sign over the exact raw bytes of the request body — not the
re-serialized JSON. Capture the body before any parsing. Compare with
a constant-time function (hmac.compare_digest).
Node.js (Express)
import express from "express";
import crypto from "crypto";
const SECRET = process.env.VOEPY_WEBHOOK_SECRET;
const app = express();
// IMPORTANT: get the raw body, not a parsed JSON object
app.post(
"/webhooks/voepy",
express.raw({ type: "application/json" }),
(req, res) => {
const ts = req.header("x-webhook-timestamp");
const sig = req.header("x-webhook-signature") ?? "";
// Replay window: 5 minutes
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(ts)) > 300) {
return res.status(400).send("timestamp out of tolerance");
}
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET)
.update(`${ts}.`)
.update(req.body)
.digest("hex");
if (
sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body.toString());
// Process the event …
// Respond 2xx ASAP; do heavy work async.
res.status(204).end();
},
);
Python (Flask)
import hmac, hashlib, os, time, json
from flask import Flask, request, abort
SECRET = os.environ["VOEPY_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/webhooks/voepy")
def voepy_webhook():
ts = request.headers.get("X-Webhook-Timestamp", "")
sig = request.headers.get("X-Webhook-Signature", "")
body = request.get_data() # raw bytes
try:
ts_int = int(ts)
except ValueError:
abort(400)
if abs(time.time() - ts_int) > 300:
abort(400, "timestamp out of tolerance")
expected = "sha256=" + hmac.new(
SECRET, f"{ts_int}.".encode() + body, hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
event = json.loads(body)
# Process the event …
return "", 204
Idempotency
We deliver at-least-once. Network blips, your 2xx lost in
transit, etc. all trigger retries. Dedupe by event_id — every event
has a unique UUID and the same event_id will not be retried after
you successfully ack it.
Simple pattern: insert into a table with event_id as the primary
key. If the insert errors with a unique-violation, you've seen this
event already; respond 2xx and skip processing.
Retry behavior
| Attempt | Delay after previous |
|---|---|
| 2 | 30s |
| 3 | 2m |
| 4 | 10m |
| 5 | 1h |
| 6 | 6h |
| 7 | 24h |
We give up after attempt 7. The delivery row stays queryable; you can inspect it and replay manually.
2xx ends retries. 4xx ends retries (we assume your endpoint is
permanently broken). 5xx or transport errors trigger retry.
Manage subscriptions
GET /v1/webhook-subscriptions # list
GET /v1/webhook-subscriptions/{id} # read (no secret)
PATCH /v1/webhook-subscriptions/{id} # update target_url, event_types, etc.
DELETE /v1/webhook-subscriptions/{id} # remove
Inspect deliveries
GET /v1/webhook-deliveries
?subscription_id=…
&status=failed
&event_type=call.ended
&created_after=2026-05-01T00:00:00Z
&cursor=…
&limit=50
Each delivery row:
{
"id": "ad12…",
"subscription_id": "9f8c…",
"event_id": "0c4f5d8a-1ad1-4e79-9cf6-2e7d3f9b0a11",
"event_type": "call.ended",
"attempt_no": 3,
"status": "succeeded", // queued | sending | succeeded | failed
"response_status_code": 204,
"response_body_snippet": "",
"error_message": "",
"sent_at": "2026-05-12T14:33:01.880Z",
"next_retry_at": null,
"created_at": "2026-05-12T14:33:01.500Z"
}
Useful for debugging "why didn't I get that event?" — the row will show the HTTP code your server returned and the response body snippet.
Failover
If your primary target_url is repeatedly failing, you can register a
tenant-wide failover URL during signup (webhook_failover_url). When
all primary subscriptions fail or aren't configured, events go there
instead, signed with a separate secret returned at signup
(webhook_failover_secret_plaintext).
Checklist for production
- Verify the signature on every request.
- Reject timestamps older than 5 minutes (replay defense).
- Dedupe by
event_id. - Respond
2xxwithin 10 seconds; queue heavy work async. - Use HTTPS (we refuse
http://URLs). - Whitelist our IPs if you must — see the static IP list in your dashboard's Webhook settings page.
- Monitor
GET /v1/webhook-deliveries?status=failedfor anomalies.
Next
- Calls — the events you'll mostly handle come from calls.
- Error reference — every code you might see when managing subscriptions.
