Webhooks
Register an HTTPS endpoint; we POST signed events on call completion.
Webhooks are OpenPhn's push-based alternative to polling /v1/calls/{id}.
You register an HTTPS endpoint; we POST a signed JSON payload when a
subscribed event fires. Retries, signing, and per-delivery history are all
first-class.
Events that fire today
| Event | When |
|---|---|
call.completed | Any outbound or inbound call reaches a terminal state (completed or failed). |
call.completed is the only event today. Future events (call.started,
call.transferred, sms.delivered) will be opt-in via the same endpoint.
Register an endpoint
curl -X POST https://api.openphn.com/v1/webhooks \
-H "Authorization: Bearer $OPENPHN_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/openphn",
"description": "Prod call-completion handler"
}'Response includes a secret (the HMAC signing key). Shown once — save it.
{
"id": "wh_01HV...",
"url": "https://example.com/webhooks/openphn",
"secret": "whsec_AbC123...",
"description": "Prod call-completion handler",
"created_at": "2026-04-21T14:00:00Z",
"active": true
}URLs must be HTTPS and resolve to a publicly routable IP. OpenPhn's SSRF guard rejects requests targeting RFC1918 space, loopback, link-local, or cloud metadata endpoints. Localhost testing needs ngrok / cloudflared.
Payload shape
{
"id": "evt_01HV...",
"type": "call.completed",
"created_at": "2026-04-21T14:05:12Z",
"data": {
"call_id": "cll_01HV...",
"status": "completed",
"to": "+14155551234",
"from": "+14155550000",
"objective": "Confirm order #A-14421 ships today",
"outcome": {
"will_ship_today": true,
"tracking": "1Z...",
"eta": "2026-04-22T18:00:00-07:00"
},
"transcript": "…",
"duration_seconds": 83,
"cost_total": "0.09"
}
}Signature verification
Every POST carries two headers:
X-OpenPhn-Signature: sha256=<hex>
X-OpenPhn-Timestamp: 1776791112The signature is HMAC-SHA256 of timestamp + "." + raw_body using the
webhook's secret. Verify before you process the body, and reject if
the timestamp is more than ~5 minutes old (replay defense).
Node.js
import crypto from "node:crypto";
export function verify(rawBody, timestamp, signature, secret) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
throw new Error("bad signature");
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
throw new Error("timestamp too old");
}
}Python
import hmac, hashlib, time
def verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> None:
expected = "sha256=" + hmac.new(
secret.encode(),
f"{timestamp}.{raw_body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise ValueError("bad signature")
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("timestamp too old")Retry policy
If your endpoint returns anything other than 2xx OR the request fails
at the transport layer, OpenPhn retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 2 | +5 seconds |
| 3 | +30 seconds |
| 4 | +2 minutes |
After the 4th failure the delivery is marked permanently failed. You can
replay it manually — see below.
Not retried: 4xx responses (except 429). A 4xx tells us your
endpoint saw the request and rejected it; retrying just wastes attempts.
Idempotency on your side
Each delivery has a stable id (evt_01HV...) that's stable across
retries. Deduplicate on that. Don't dedupe on call_id alone — one call
can generate multiple deliveries if your endpoint 500s and we retry.
Delivery history API
Every attempt is persisted.
curl https://api.openphn.com/v1/webhooks/wh_01HV.../deliveries \
-H "Authorization: Bearer $OPENPHN_KEY"Each row includes the attempt number, response status, response body (truncated), and latency. Useful when chasing down "did my handler see this?" questions.
Manual retry
curl -X POST https://api.openphn.com/v1/webhooks/wh_01HV.../deliveries/dlv_01HV.../retry \
-H "Authorization: Bearer $OPENPHN_KEY"Forces OpenPhn to re-POST the original payload. Useful after you've fixed a bug on your endpoint and want to process missed deliveries without replaying the whole call.
Delete / deactivate
DELETE /v1/webhooks/{id} soft-deletes the endpoint. No more events will
be dispatched. Existing delivery history remains queryable.