voepy

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
}
FieldNotes
target_urlHTTPS required. Must respond within 10s.
event_typesArray of event names, or ["*"] to catch everything.
connection_idOptional. Scope to a single connection so only its DIDs' events fire. null = tenant-wide.
descriptionFree-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

CategoryEvents
Callcall.started, call.answered, call.bridged, call.ended, call.dtmf, call.gather_complete, call.amd_complete, call.transcript_chunk
Recordingrecording.completed, recording.failed
Streamingstream.started, stream.stopped, stream.failed
Conferenceconference.started, conference.ended, conference.member_joined, conference.member_left, conference.member_muted, conference.member_unmuted
Number ordernumber_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:

HeaderValue
Content-Typeapplication/json
X-Webhook-Signaturesha256=<hmac_hex>
X-Webhook-TimestampUnix seconds (integer)
X-Webhook-Event-IdThe event_id from the body — use this to dedupe
User-Agentwebhook-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

AttemptDelay after previous
230s
32m
410m
51h
66h
724h

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 2xx within 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=failed for anomalies.

Next

  • Calls — the events you'll mostly handle come from calls.
  • Error reference — every code you might see when managing subscriptions.