Custom webhooks
Receive a signed HTTP POST on your own server every time a voicegram completes. HMAC verification, retries, and debugging.
On this page
- What is a webhook subscription?
- Setting up a subscription
- Via the dashboard
- Via the public API
- Receiving events
- HMAC signature verification
- Node.js (Express)
- Python (Flask)
- PHP
- Inspecting headers with curl
- Retry schedule
- Status code semantics
- Auto-disable after persistent failures
- Idempotency
- Testing a subscription
- 90-day reactivation limit for disabled subscriptions
- Payload reference
- Top-level fields
- data fields
- Debugging delivery failures
- Debugging checklist
- Common errors
- Common failure modes to design around
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/jsonX-Voicegram-Event: the event type. Today the only value isvoicegram.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.
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.
Webhook URLs must be terminal receivers
Voicegram does not follow 3xx redirects when delivering a webhook. If your endpoint returns any 3xx status, we treat it as a delivery failure: the status is recorded in last_error as redirect_blocked: <status> and it counts toward the consecutive failure budget that auto-disables the subscription.
Make sure your webhook handler responds directly with 2xx, 4xx, or 5xx rather than redirecting through a load balancer rule, an HTTP to HTTPS upgrade, or a CDN canonical-host policy.
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:
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:
- Use the raw body. Do not parse to JSON and re-serialize before computing the HMAC. Any whitespace change breaks the signature.
- Use constant-time comparison. Use
crypto.timingSafeEqual(Node),hmac.compare_digest(Python), orhash_equals(PHP). String equality (==) is timing-attack-vulnerable. - Reject stale signatures. Compare the
tvalue 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)
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)
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
$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
# 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.
| Attempt | Delay from previous | Cumulative time |
|---|---|---|
| Attempt 1 (initial) | Immediate | 0 |
| Attempt 2 | 1 minute | 1 minute |
| Attempt 3 | 5 minutes | 6 minutes |
| Attempt 4 | 30 minutes | 36 minutes |
| Attempt 5 | 2 hours | 2 hours 36 minutes |
| Attempt 6 | 12 hours | 14 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
4xxor5xx: 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 sameevent_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:
{
"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
| Field | Type | Description |
|---|---|---|
event | string | Always "voicegram.completed" today. Reserved for future event types. |
event_id | string | Stable per logical event. Retries of the same event reuse the same event_id. Use as a dedup key. |
delivered_at | ISO 8601 string | UTC timestamp when Voicegram dispatched this event. |
data | object | The voicegram payload. See below. |
data fields
| Field | Type | Description |
|---|---|---|
data.id | string | Stable identifier for the voicegram. Matches the ID in dashboard_url. |
data.created_at | ISO 8601 string | When the recording was made (UTC). |
data.completed_at | ISO 8601 string | When transcription and summarization finished (UTC). |
data.duration_seconds | number | Length of the recording in seconds. May be fractional (e.g., 11.36). |
data.sender.verification_method | string | How the sender verified before recording: email or sms. |
data.sender.email | string or null | Sender's email address. Present when verification_method is email. Null otherwise. |
data.sender.phone | string or null | Sender's phone number in E.164 format. Present when verification_method is sms. Null otherwise. |
data.channel.type | string | domain for widget-recorded voicegrams, campaign for campaign landing-page voicegrams. |
data.channel.id | string | Stable ID of the domain or campaign the voicegram came from. |
data.channel.name | string | Domain name (e.g., example.com) or campaign name. |
data.channel.page_url | string or null | Page URL the widget was on when the voicegram was recorded. Only set for domain channels. Null for campaign. |
data.transcript.text | string | Full transcript text. |
data.transcript.confidence | number | Transcription confidence score from 0.0 to 1.0. |
data.summary.text | string | AI-generated prose summary (typically 2 to 3 sentences, max ~200 words). |
data.summary.urgency | string or null | AI-classified urgency: low, medium, high, or null. |
data.summary.sentiment | string or null | AI-classified sentiment: positive, neutral, negative, frustrated, or null. |
data.summary.intent | string or null | AI-classified intent (free-form, e.g., inquiry, support, complaint). May be null. |
data.summary.callback_required | boolean | True when the caller explicitly asked for a callback. |
data.summary.action_items | array | Array 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.names | array of strings | People or company names the caller mentioned. Each entry is non-empty and capped at 256 characters. |
data.summary.mentioned.phones | array of strings | Phone numbers in E.164 format. Only valid phone numbers are included. |
data.summary.mentioned.emails | array of strings | Email addresses the caller mentioned. Only valid email addresses are included. |
data.audio.url | string | Signed 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_at | ISO 8601 string | UTC timestamp when this specific audio.url stops working. |
data.audio.content_type | string | MIME type of the audio file. Currently always audio/webm. |
data.dashboard_url | string | Permanent link to view the voicegram in your Voicegram dashboard. |
data.tags | array of strings | User-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-Idthat 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:
- 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.
- 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.
- Check the delivery log. The dashboard's delivery history view shows the status code, response body excerpt, and
X-Voicegram-Delivery-Idfor every attempt. Cross-reference the delivery ID against your server logs to find the matching inbound request. - 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
localhostURL. Usecurl -i https://your-endpoint.example.com/voicegram-webhook -X POSTfrom 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
5xxwith a response body excerpt: your server returned an error. The excerpt is usually enough to identify the cause. Cross-reference theX-Voicegram-Delivery-Idin 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 Requestfrom 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
200immediately. - 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).