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. Your endpoint must respond directly with 2xx/4xx/5xx; a 3xx response is treated as a delivery failure (recorded as redirect_blocked). This applies to real-event delivery, retry delivery, and the dashboard's test-event endpoint.
Secret handling
The response includes the plaintext signing 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). - The URL must not point to a private or internal host.
Subscription cap
Active subscriptions per account are capped at 25. The 26th subscription returns 409 limit_reached. Deleted subscriptions don't count toward the cap.
channel_id resolution
If channel_id is provided, it must be a domain or campaign on your account. Returns 400 invalid_channel_id if no match exists.
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. |
source | enum ("zapier", "dashboard") | yes | Whether the subscription was created via Zapier or the dashboard. Send zapier or dashboard; this governs whether zapier_subscription_id is required. |
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. 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 signing secret exactly once.
| Field | Type | Required | Description |
|---|---|---|---|
id | string (uuid) | yes | |
url | string (uri) | yes | |
secret | string | yes | Plaintext signing 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 | First 12 characters of the signing secret (e.g. vg_whsec_012). Safe to display in lists, logs, and dashboards. |
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 string. Only set on invalid_url responses. Treat this as an open set and match on the prefix before the colon when one is present. Bare (colon-less) values: url_parse_failed, scheme_must_be_https, url_must_not_contain_credentials, hostname_is_localhost, dns_lookup_returned_no_addresses. Address/message-suffixed values of the form <reason>: <detail>: host_resolves_to_private_ip: 10.0.0.5, ipv4_is_private_or_loopback: 127.0.0.1, ipv6_is_private_or_loopback: ::1, and dns_lookup_failed: <message>. |
issues | array of object | no | Schema-validation issues. Only set on validation_error responses. Each entry has a path array and a message. |
Example: 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": "host_resolves_to_private_ip: 10.0.0.5"
}
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 string. Only set on invalid_url responses. Treat this as an open set and match on the prefix before the colon when one is present. Bare (colon-less) values: url_parse_failed, scheme_must_be_https, url_must_not_contain_credentials, hostname_is_localhost, dns_lookup_returned_no_addresses. Address/message-suffixed values of the form <reason>: <detail>: host_resolves_to_private_ip: 10.0.0.5, ipv4_is_private_or_loopback: 127.0.0.1, ipv6_is_private_or_loopback: ::1, and dns_lookup_failed: <message>. |
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."
}