Voicegram

Custom webhooks

Receive a signed HTTP POST on your own server every time a voicegram completes. HMAC verification, retries, and debugging.

On this page

Receive a signed POST on your own server every time a voicegram completes. Voicegram signs every payload with HMAC-SHA256, retries failed deliveries on a fixed schedule, and exposes a delivery history view so you can correlate attempts with your server logs.

What is a webhook subscription?

A webhook subscription is an HTTPS URL on your server that Voicegram POSTs to every time a voicegram on your account finishes processing. One subscription equals one URL. You can have up to 25 active subscriptions per account and each subscription can optionally filter by channel (a single domain or campaign) so a subscription only fires for the slice of traffic you care about.

Webhooks are the lower-level alternative to the Zapier app. If you need to land completed voicegrams directly in your own data warehouse, CRM, or ticketing system, use webhooks. If you want a no-code drag-and-drop integration, use the Zapier app.

Setting up a subscription

You can create subscriptions either through the dashboard or the public API.

Via the dashboard

Sign in and visit Integrations, Webhooks. Click Add webhook, paste in your HTTPS URL, optionally select a channel filter, and click Create. Copy the plaintext HMAC secret into your server-side secret store before closing the dialog.

Via the public API

POST to /api/v1/webhook-subscriptions with a Bearer PAT. The 201 response contains the plaintext secret. See the OpenAPI spec for the request and response schemas.

The secret is shown once

The HMAC secret appears in the 201 response (or in the dashboard) exactly once. If you lose your secret, delete the subscription and create a new one. The new secret will be shown once at creation time. Copy it then. The plaintext is never displayed again after creation.

Note: if you delete a subscription and leave it disabled for more than 90 days, it can no longer be reactivated and you will need to create a new one. See the 90-day reactivation limit section below.

Receiving events

Voicegram delivers events as POST requests with these headers:

  • Content-Type: application/json
  • X-Voicegram-Event: the event type. Today the only value is voicegram.completed.
  • X-Voicegram-Delivery-Id: a UUID unique to this delivery attempt. Retries get a fresh delivery id. See idempotency below.
  • X-Voicegram-Signature: the HMAC signature. See signature verification.
http
POST /your/webhook/endpoint HTTP/1.1
Host: your-server.example.com
Content-Type: application/json
X-Voicegram-Event: voicegram.completed
X-Voicegram-Delivery-Id: 7d83a1e8-9b4c-4f0e-9b1a-3f8a0a2b1c44
X-Voicegram-Signature: t=1747269923,v1=9f2b4a8c1d6e5f7a0b3c2d1e4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6

The full request body schema for voicegram.completed is defined in the OpenAPI spec under the VoicegramCompletedEvent component, and documented in the Payload reference below. The shape covers sender info, channel info, transcript, AI summary, signed audio URL, and a dashboard URL.

Heads-up: audio.url is a signed listen URL with a plan-scaled validity window (30 days on Free, 90 days on Starter, 365 days on Pro / Business / Enterprise). If you need access beyond that window, store the audio bytes immediately or use dashboard_url instead. See the audio.url expiry callout below for details.

HMAC signature verification

Every delivery is signed so you can verify it really came from Voicegram (and not a forger probing your endpoint). The header is Stripe-style:

text
X-Voicegram-Signature: t=<unix-timestamp>,v1=<hmac-sha256-hex>

The signed content is <timestamp>.<raw-body>: the timestamp, a literal period, and the exact bytes of the request body. The signature is HMAC-SHA256 keyed by your subscription secret, encoded as lowercase hex.

Three things you must do when verifying:

  1. Use the raw body. Do not parse to JSON and re-serialize before computing the HMAC. Any whitespace change breaks the signature.
  2. Use constant-time comparison. Use crypto.timingSafeEqual (Node), hmac.compare_digest (Python), or hash_equals (PHP). String equality (==) is timing-attack-vulnerable.
  3. Reject stale signatures. Compare the t value to your server's clock and reject if the difference exceeds 5 minutes (300 seconds). This blocks replay attacks even if a delivery is captured in transit.

Node.js (Express)

javascript
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.VOICEGRAM_WEBHOOK_SECRET;
if (!SECRET) throw new Error('VOICEGRAM_WEBHOOK_SECRET env var is required');

const app = express();

// IMPORTANT: capture the RAW body. Express's json() parser stringifies and
// re-serializes; that mutates whitespace and breaks signature verification.
app.post(
  '/voicegram-webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.header('X-Voicegram-Signature') ?? '';
    const rawBody = req.body.toString('utf8');

    const parts = Object.fromEntries(
      header.split(',').map((kv) => kv.split('='))
    );
    const t = parseInt(parts.t, 10);
    const v1 = parts.v1;
    if (!t || !v1) return res.status(400).end();

    // Replay protection: reject signatures older than 5 minutes.
    if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) {
      return res.status(400).end();
    }

    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(`${t}.${rawBody}`)
      .digest('hex');

    const ok =
      v1.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
    if (!ok) return res.status(400).end();

    const event = JSON.parse(rawBody);
    // ...process event...
    res.status(200).end();
  }
);

Python (Flask)

python
import hmac, hashlib, os, time, json
from flask import Flask, request, abort

SECRET = os.environ.get("VOICEGRAM_WEBHOOK_SECRET")
if not SECRET:
    raise RuntimeError("VOICEGRAM_WEBHOOK_SECRET env var is required")

app = Flask(__name__)

@app.post("/voicegram-webhook")
def voicegram_webhook():
    raw = request.get_data()  # raw bytes, do not pre-parse
    header = request.headers.get("X-Voicegram-Signature", "")
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    try:
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        abort(400)

    # Replay protection: reject signatures older than 5 minutes.
    if abs(int(time.time()) - t) > 300:
        abort(400)

    expected = hmac.new(
        SECRET.encode(),
        f"{t}.{raw.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(v1, expected):
        abort(400)

    event = json.loads(raw)
    # ...process event...
    return "", 200

PHP

php
<?php
$secret = getenv('VOICEGRAM_WEBHOOK_SECRET');
if (!$secret) {
    throw new RuntimeException('VOICEGRAM_WEBHOOK_SECRET env var is required');
}

// Reads the raw POST body. Do NOT use $_POST or anything that parses it.
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_VOICEGRAM_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $kv) {
    [$k, $v] = array_pad(explode('=', $kv, 2), 2, null);
    $parts[$k] = $v;
}
$t = isset($parts['t']) ? (int) $parts['t'] : 0;
$v1 = $parts['v1'] ?? '';

if ($t === 0 || $v1 === '') {
    http_response_code(400); exit;
}
// Replay protection: reject signatures older than 5 minutes.
if (abs(time() - $t) > 300) {
    http_response_code(400); exit;
}

$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);

if (!hash_equals($expected, $v1)) {
    http_response_code(400); exit;
}

$event = json_decode($raw, true);
// ...process event...
http_response_code(200);

Inspecting headers with curl

bash
# Useful when debugging. Print the raw delivery headers your endpoint receives.
curl -i -X POST https://your-server.example.com/voicegram-webhook \
  -H "Content-Type: application/json" \
  -H "X-Voicegram-Event: voicegram.completed" \
  -H "X-Voicegram-Delivery-Id: 00000000-0000-0000-0000-000000000000" \
  -H "X-Voicegram-Signature: t=$(date +%s),v1=dead..." \
  --data '{"event":"voicegram.completed","data":{}}'

Retry schedule

If your endpoint does not return a 2xx status within the delivery timeout, Voicegram retries on a fixed back-off schedule. There is one initial attempt plus up to five retries, for six attempts total.

AttemptDelay from previousCumulative time
Attempt 1 (initial)Immediate0
Attempt 21 minute1 minute
Attempt 35 minutes6 minutes
Attempt 430 minutes36 minutes
Attempt 52 hours2 hours 36 minutes
Attempt 612 hours14 hours 36 minutes

Total budget is roughly 14.5 hours from initial attempt to final retry. If all six attempts fail, the delivery is marked permanently failed.

Status code semantics

  • 2xx: delivered successfully. Any 2xx is treated as acceptance.
  • 410 Gone: we mark the subscription as permanently gone and stop retrying. Return 410 if the endpoint has been removed deliberately.
  • Any other 4xx or 5xx: retried on the schedule above.
  • Network errors (DNS failure, TCP timeout, TLS error): retried on the schedule above.
  • 3xx: treated as failure. See the terminal-receiver requirement above.

Auto-disable after persistent failures

If 5 delivery attempts fail in a row, where a single event can produce up to 6 attempts via retries, we automatically disable the subscription and email all admins on the account. Because the failure counter increments on every failed attempt (including retries of the same event), a single broken endpoint can hit the threshold during one event's retry cycle. You can re-enable the subscription with one click from the dashboard once you have fixed the underlying endpoint issue.

Idempotency

Treat every webhook handler as "at least once." Network blips and retries can cause the same event to land on your server more than once. Two correlation IDs help you deduplicate:

  • X-Voicegram-Delivery-Id: unique per delivery attempt. A retry gets a fresh delivery id. Useful for cross-correlating with the dashboard delivery history view when you need to look up a specific failed attempt.
  • event.event_id: unique per logical event. Retries of the same event reuse the same event_id. Use this as your dedup key if you only want to process a given voicegram once, no matter how many delivery attempts hit your server.

A common pattern is: upsert a row in your processed_events table keyed by event_id. If the row already exists, return 200 OK immediately without re-running the business logic.

audio.url expires on a plan-scaled window

The data.audio.url field in the payload is a signed listen URL whose validity is scaled to the account plan: 30 days on Free, 90 days on Starter, 365 days on Pro / Business / Enterprise. The exact expiry timestamp is included as data.audio.expires_at. After expiry the URL returns 403.

If you need access beyond that window, fetch the bytes immediately when you receive the webhook and store them on your own infrastructure. For permanent access to the rest of the voicegram (transcript, summary, dashboard view), use the data.dashboard_url field, which is permanent.

Testing a subscription

Use the Test button in Integrations, Webhooks to fire a synthetic payload at your endpoint. The test event is clearly marked with an X-Voicegram-Test: true header so your handler can branch on it during development. It still uses real HMAC signing with your real secret, so it is a complete end-to-end test of your verification logic.

90-day reactivation limit for disabled subscriptions

If a subscription stays disabled for more than 90 days, it can no longer be reactivated. After that, you will need to create a new one. The dashboard shows a Scrubbed state for these subscriptions, and the Reactivate button is disabled with a tooltip explaining the situation.

A subscription becomes "disabled" either when you click Delete in the dashboard (which is a soft-delete) or when the auto-disable logic flips it off after persistent delivery failures. The 90-day clock starts at that point. Reactivating within the 90-day window preserves the original secret. After the window, the row, its URL, and its delivery history all remain for audit, but the subscription can no longer be reactivated.

The delivery history view stays available on scrubbed rows so you can still cross-reference past delivery IDs against your server logs.

Payload reference

The payload posted to your endpoint is a JSON object with this shape:

json
{
  "event": "voicegram.completed",
  "event_id": "evt_01HBQK9...",
  "delivered_at": "2026-05-27T19:42:18.000Z",
  "data": {
    "id": "vg_...",
    "created_at": "2026-05-27T19:41:00.000Z",
    "completed_at": "2026-05-27T19:42:00.000Z",
    "duration_seconds": 47,
    "sender": { "verification_method": "email", "email": "...", "phone": null },
    "channel": { "type": "domain", "id": "...", "name": "example.com", "page_url": "https://..." },
    "transcript": { "text": "...", "confidence": 0.94 },
    "summary": {
      "text": "...",
      "urgency": "medium",
      "sentiment": "neutral",
      "intent": "inquiry",
      "callback_required": true,
      "action_items": [{ "task": "...", "priority": "medium", "deadline": null }],
      "mentioned": { "names": ["..."], "phones": ["+1..."], "emails": ["..."] }
    },
    "audio": { "url": "...", "expires_at": "...", "content_type": "audio/webm" },
    "dashboard_url": "https://voicegram.io/dashboard/inbox/vg_...",
    "tags": ["..."]
  }
}

Top-level fields

FieldTypeDescription
eventstringAlways "voicegram.completed" today. Reserved for future event types.
event_idstringStable per logical event. Retries of the same event reuse the same event_id. Use as a dedup key.
delivered_atISO 8601 stringUTC timestamp when Voicegram dispatched this event.
dataobjectThe voicegram payload. See below.

data fields

FieldTypeDescription
data.idstringStable identifier for the voicegram. Matches the ID in dashboard_url.
data.created_atISO 8601 stringWhen the recording was made (UTC).
data.completed_atISO 8601 stringWhen transcription and summarization finished (UTC).
data.duration_secondsnumberLength of the recording in seconds. May be fractional (e.g., 11.36).
data.sender.verification_methodstringHow the sender verified before recording: email or sms.
data.sender.emailstring or nullSender's email address. Present when verification_method is email. Null otherwise.
data.sender.phonestring or nullSender's phone number in E.164 format. Present when verification_method is sms. Null otherwise.
data.channel.typestringdomain for widget-recorded voicegrams, campaign for campaign landing-page voicegrams.
data.channel.idstringStable ID of the domain or campaign the voicegram came from.
data.channel.namestringDomain name (e.g., example.com) or campaign name.
data.channel.page_urlstring or nullPage URL the widget was on when the voicegram was recorded. Only set for domain channels. Null for campaign.
data.transcript.textstringFull transcript text.
data.transcript.confidencenumberTranscription confidence score from 0.0 to 1.0.
data.summary.textstringAI-generated prose summary (typically 2 to 3 sentences, max ~200 words).
data.summary.urgencystring or nullAI-classified urgency: low, medium, high, or null.
data.summary.sentimentstring or nullAI-classified sentiment: positive, neutral, negative, frustrated, or null.
data.summary.intentstring or nullAI-classified intent (free-form, e.g., inquiry, support, complaint). May be null.
data.summary.callback_requiredbooleanTrue when the caller explicitly asked for a callback.
data.summary.action_itemsarrayArray of { task, priority, deadline } objects. priority is low, medium, or high. deadline is a free-form string (e.g. ASAP, by Friday) or null.
data.summary.mentioned.namesarray of stringsPeople or company names the caller mentioned. Each entry is non-empty and capped at 256 characters.
data.summary.mentioned.phonesarray of stringsPhone numbers in E.164 format. Only valid phone numbers are included.
data.summary.mentioned.emailsarray of stringsEmail addresses the caller mentioned. Only valid email addresses are included.
data.audio.urlstringSigned listen URL of the form https://www.voicegram.io/api/listen/<id>?t=<token>. Validity is plan-scaled: 30 days (Free), 90 days (Starter), 365 days (Pro / Business / Enterprise). After expiry the URL returns 403.
data.audio.expires_atISO 8601 stringUTC timestamp when this specific audio.url stops working.
data.audio.content_typestringMIME type of the audio file. Currently always audio/webm.
data.dashboard_urlstringPermanent link to view the voicegram in your Voicegram dashboard.
data.tagsarray of stringsUser-supplied tags from the dashboard Tags input. Distinct from summary.urgency / .sentiment / .intent, which are AI-derived. Empty array if the user has not tagged the voicegram.

Debugging delivery failures

Open a subscription from Integrations, Webhooks and click History. You will see the most recent 50 attempts, each with:

  • Timestamp of the attempt and which retry number it was.
  • HTTP response status returned by your endpoint.
  • A short excerpt of the response body (useful for surfacing error messages your endpoint returned).
  • The X-Voicegram-Delivery-Id that was sent in the request headers, so you can grep your server logs for it.

Debugging checklist

When a webhook is not behaving as expected, work through this checklist before opening a support ticket:

  1. Use a live inspector. Temporarily point the subscription at webhook.site, RequestBin, or your own logging endpoint. You will see the exact bytes, headers, and signature Voicegram sends, which removes any ambiguity from your application code.
  2. Verify the signature with our example code. Copy one of the Node.js / Python / PHP samples into a scratch file, paste the request body and signature header from a failed delivery, and run it locally. If it passes, your real handler has a bug in how it captures the raw body. If it fails, the secret may be wrong. Delete the subscription and create a new one to get a fresh secret.
  3. Check the delivery log. The dashboard's delivery history view shows the status code, response body excerpt, and X-Voicegram-Delivery-Id for every attempt. Cross-reference the delivery ID against your server logs to find the matching inbound request.
  4. Confirm your endpoint is reachable from the public internet. A common failure mode is a webhook URL behind a VPN, a corporate firewall, or a localhost URL. Use curl -i https://your-endpoint.example.com/voicegram-webhook -X POST from a non-corporate network to verify reachability.

Common errors

  • redirect_blocked: 301 (or 302, 303, 307, 308): your endpoint returned a redirect. See the terminal-receiver requirement.
  • url_now_blocked: <reason>: DNS resolved the hostname to a private or reserved IP at delivery time. Use a public address.
  • Generic 5xx with a response body excerpt: your server returned an error. The excerpt is usually enough to identify the cause. Cross-reference the X-Voicegram-Delivery-Id in your server logs for the full request trace.
  • Connection timeouts: your server did not respond within the delivery timeout window. Retried on schedule.
  • 400 Bad Request from your own code: a signature mismatch is the most common cause. Re-check that you are computing the HMAC over the raw request body and not over a re-serialized JSON object.

Common failure modes to design around

  • HTTPS required. Voicegram refuses to deliver to plain http:// URLs. Use HTTPS with a valid certificate.
  • Response time limit. Your endpoint must respond within the delivery timeout window. Long-running work should be queued (push the payload onto a background job) and the handler should return 200 immediately.
  • No 3xx redirects. Your endpoint must be a terminal receiver. See the terminal-receiver requirement.
  • Public IP only. Voicegram refuses to deliver to URLs whose hostname resolves to a private, loopback, link-local, or reserved IP. Use a public address (or a tunnel like ngrok during development).
Need help? Email support@voicegram.io.