Create a webhook subscription
Creates a webhook subscription that fires the `voicegram.completed` event to the supplied URL on every matching completed voicegram for the account.
On this page
Creates a webhook subscription that fires the voicegram.completed event to the supplied URL on every matching completed voicegram for the account.
Terminal receiver requirement
The URL MUST be a terminal receiver. The delivery worker uses redirect: 'manual' when POSTing events, so any 3xx response is treated as a delivery failure recorded as redirect_blocked: <status> in last_error (and counted toward consecutive_failures). This applies to real-event delivery, retry delivery, and the dashboard's test-event endpoint. Customers should configure their webhook endpoint to respond directly with 2xx/4xx/5xx.
Secret handling
The response includes the plaintext HMAC secret ONCE. After this response the plaintext is unrecoverable from the API; secret_prefix is the only identifier surfaced afterwards. Save the plaintext in a secure secret store on your end. If you lose it, delete the subscription and create a new one to rotate.
URL validation
- MUST use HTTPS (
http://returns400 invalid_url). - MUST be a valid URI under 2048 characters.
- MUST NOT contain user/password credentials (
https://user:pass@host). - MUST NOT target a private or internal host. This is validated at delivery time, not just at subscribe time.
Subscription cap
Active subscriptions per account are capped at 25. The 26th subscription returns 409 limit_reached. Deleted subscriptions do not count toward the cap.
channel_id resolution
If channel_id is provided, it must match a domain or campaign on the PAT's account. Returns 400 invalid_channel_id if no match exists, whether the id is unknown or belongs to a different account.
Endpoint
POST /api/v1/webhook-subscriptions
Authentication
Bearer Personal Access Token. See Authentication.
Request body
Content type: application/json (required).
| Field | Type | Required | Description |
|---|---|---|---|
url | string (uri) | yes | HTTPS URL to POST events to. Must be a terminal receiver: 3xx responses are treated as delivery failures (redirect_blocked). |
event_type | enum ("voicegram.completed") | yes | The only event in v1. Required so callers do not have to change request shape when v2 adds additional event types. |
source | enum ("zapier", "dashboard") | yes | Source class. Drives zapier_subscription_id semantics. |
channel_id | string | null | no | Optional. When null the subscription fires for all channels on the account. When set, the server derives channel_type from the id; mismatch returns 400 invalid_channel_id. |
zapier_subscription_id | string | null | no | REQUIRED when source=zapier (used by the Zapier app to look up the subscription on unsubscribe). FORBIDDEN when source=dashboard. |
Example: Zapier-source subscription
{
"url": "https://hooks.zapier.com/hooks/standard/12345/abc",
"event_type": "voicegram.completed",
"channel_id": null,
"source": "zapier",
"zapier_subscription_id": "zap-12345-abc"
}
Example: Dashboard-source subscription scoped to a domain
{
"url": "https://example.com/webhooks/voicegram",
"event_type": "voicegram.completed",
"channel_id": "11111111-1111-1111-1111-111111111111",
"source": "dashboard"
}
Example request
curl -X POST https://www.voicegram.io/api/v1/webhook-subscriptions \
-H "Authorization: Bearer vg_pat_<your-token>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.zapier.com/hooks/standard/12345/abc",
"event_type": "voicegram.completed",
"channel_id": null,
"source": "zapier",
"zapier_subscription_id": "zap-12345-abc"
}'
Responses
Subscription created. The response includes the plaintext HMAC secret exactly once.
| Field | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | yes | |
url | string (uri) | yes | |
secret | string | yes | Plaintext HMAC secret. Returned ONCE in this response. After this response the plaintext is unrecoverable from any API. Save it in a secure secret store on your end. To rotate, delete the subscription and create a new one. |
secret_prefix | string | yes | |
is_active | boolean | yes | |
created_at | string (date-time) | yes |
{
"id": "44444444-4444-4444-4444-444444444444",
"url": "https://hooks.zapier.com/hooks/standard/12345/abc",
"secret": "vg_whsec_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"secret_prefix": "vg_whsec_012",
"is_active": true,
"created_at": "2026-05-14T19:23:45.000Z"
}
Body failed validation. The error field disambiguates:
validation_error: generic schema failure (seeissues)invalid_url: URL is not HTTPS, contains credentials, or targets a private/internal host (thereasonfield carries a stable diagnostic enum string)invalid_channel_id:channel_iddoes not match any domain or campaign on the PAT's account
| Field | Type | Required | Description |
|---|---|---|---|
error | enum ("invalid_token", "plan_required", "validation_error", "invalid_url", "invalid_channel_id", "not_found", "rate_limited", "limit_reached", "conflict", "internal_error") | yes | Stable error code. |
message | string | no | Human-readable error message. |
reason | string | no | Stable diagnostic enum string. Only set on invalid_url responses (e.g. url_must_be_https, url_resolves_to_private_host, url_must_not_contain_credentials). |
issues | array of object | no | Schema-validation issues. Only set on validation_error responses. Each entry has a path array and a message. |
Example: Generic validation failure
{
"error": "validation_error",
"issues": [
{
"path": [
"url"
],
"message": "Invalid url"
}
]
}
Example: URL targets a private host
{
"error": "invalid_url",
"message": "URL must be HTTPS and not target a private/internal host.",
"reason": "url_resolves_to_private_host"
}
Example: channel_id not on this account
{
"error": "invalid_channel_id",
"message": "channel_id does not match any domain or campaign on your account."
}
The PAT is missing, malformed, expired, or revoked.
See Errors for the shared error response shape.
PAT is valid but the account is on the free plan.
See Errors for the shared error response shape.
Conflict. Either the active-subscription cap of 25 has been reached (limit_reached), or a Zapier subscription with this zapier_subscription_id already exists (conflict).
| Field | Type | Required | Description |
|---|---|---|---|
error | enum ("invalid_token", "plan_required", "validation_error", "invalid_url", "invalid_channel_id", "not_found", "rate_limited", "limit_reached", "conflict", "internal_error") | yes | Stable error code. |
message | string | no | Human-readable error message. |
reason | string | no | Stable diagnostic enum string. Only set on invalid_url responses (e.g. url_must_be_https, url_resolves_to_private_host, url_must_not_contain_credentials). |
issues | array of object | no | Schema-validation issues. Only set on validation_error responses. Each entry has a path array and a message. |
Example: limit_reached
{
"error": "limit_reached",
"message": "Active subscription limit of 25 reached. Delete or disable an existing subscription before creating a new one."
}
Example: conflict
{
"error": "conflict",
"message": "A subscription with this zapier_subscription_id already exists."
}