Webhooks
Subscribe to person, subscription, and purchase events with signed, automatically retried webhook deliveries.
Webhooks push events from Voidhash to your server as they happen — a person is created, a subscription renews, a purchase is refunded. You register one or more HTTPS endpoints per project, pick the events each endpoint cares about, and Voidhash POSTs a signed JSON payload to your URL with automatic retries on failure.
All routes live under /api/v1/webhooks and accept any project-scoped credential — a project secret key (x-secret-key) or a user API key (x-api-key). See Authentication.
Event types
An endpoint only receives the events it subscribes to. The catalog:
| Event | Fires when |
|---|---|
person.created | A new person is created in the project |
person.updated | A person's profile or traits change |
person.deleted | A person is deleted |
subscription.created | A subscription starts |
subscription.renewed | A subscription renews for another period |
subscription.cancelled | A subscription is cancelled |
subscription.expired | A subscription expires |
purchase.completed | A one-time purchase completes |
purchase.refunded | A purchase is refunded |
Subscriptions are validated on create and update — an unknown event type is rejected with 400 Api/WebhookValidationError, and the list must contain at least one event. Test deliveries additionally use the reserved test.ping event type, which you cannot subscribe to.
The webhook endpoint object
{
"id": "wh_ep_...",
"projectId": "...",
"name": "Production events",
"url": "https://example.com/voidhash/webhooks",
"secret": "whsec_...",
"events": ["subscription.created", "purchase.completed"],
"status": "active",
"description": null,
"consecutiveFailures": 0,
"lastSuccessAt": "2026-07-17T09:30:00.000Z",
"createdAt": "2026-07-01T12:00:00.000Z"
}| Field | Type | Description |
|---|---|---|
id | string | Endpoint ID (wh_ep_*) |
projectId | string | Owning project |
name | string | Display name |
url | string | Destination URL (must be http: or https:) |
secret | string | Signing secret (whsec_ + 64 hex chars) used for the delivery signature |
events | string[] | Subscribed event types |
status | string | active, disabled, or failed — only active endpoints receive deliveries |
description | string | null | Optional free-form description |
consecutiveFailures | number | Failed attempts since the last successful delivery; reset to 0 on success |
lastSuccessAt | string | null | Timestamp of the last successful delivery |
createdAt | string | null | Creation timestamp |
Setting status back to active via update also resets consecutiveFailures to 0.
Manage endpoints
Create an endpoint
POST /api/v1/webhooks/endpointscurl https://api.voidhash.com/api/v1/webhooks/endpoints \
-X POST \
-H "x-secret-key: vh_sk_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Production events",
"url": "https://example.com/voidhash/webhooks",
"events": ["subscription.created", "subscription.expired", "purchase.completed"],
"description": "Syncs entitlements into our CRM"
}'| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name |
url | string | Yes | Destination URL, http: or https: only |
events | string[] | Yes | Non-empty list of event types from the catalog above |
description | string | No | Free-form description |
Returns the full endpoint object with a freshly generated secret and status: "active".
Store the secret when you create the endpoint
The secret is what your server uses to verify delivery signatures. It is returned on every
read of the endpoint, but treat it like a credential — keep it in your server's secret store,
and rotate it if it ever leaks.
List and fetch endpoints
GET /api/v1/webhooks/endpoints
GET /api/v1/webhooks/endpoints/:endpointIdListing returns every endpoint in the project. Fetching an unknown ID returns 404 Api/WebhookEndpointNotFoundError.
Update an endpoint
PATCH /api/v1/webhooks/endpoints/:endpointIdAll fields are optional; only the ones you send change:
| Field | Type | Description |
|---|---|---|
name | string | New display name |
url | string | New destination URL (validated) |
events | string[] | Replacement event list (validated, non-empty) |
description | string | null | Set or clear the description |
status | "active" | "disabled" | Pause or resume deliveries |
curl https://api.voidhash.com/api/v1/webhooks/endpoints/wh_ep_... \
-X PATCH \
-H "x-secret-key: vh_sk_..." \
-H "Content-Type: application/json" \
-d '{ "status": "disabled" }'Disabled endpoints keep their configuration and delivery history but receive no new deliveries. Re-activating resets consecutiveFailures.
Delete an endpoint
DELETE /api/v1/webhooks/endpoints/:endpointIdReturns an empty success response.
Rotate the secret
POST /api/v1/webhooks/endpoints/:endpointId/rotate-secretGenerates a new whsec_ secret and returns the updated endpoint. Deliveries signed after rotation use the new secret immediately, so update your server's copy before rotating in production.
Send a test delivery
POST /api/v1/webhooks/endpoints/:endpointId/testEnqueues a real delivery with event type test.ping and returns the delivery object (initially status: "pending"). The body your server receives is:
{
"message": "This is a test webhook delivery",
"timestamp": "2026-07-17T09:30:00.000Z"
}Test deliveries go through the exact same signing, retry, and attempt-recording pipeline as real events, so they are the fastest way to verify your signature handling end to end.
Receiving deliveries
Voidhash POSTs the event payload as JSON to your endpoint URL. The event type and signature travel in headers — the body is the payload itself:
| Header | Description |
|---|---|
Content-Type | application/json |
X-Webhook-Event | The event type, e.g. subscription.created |
X-Webhook-Timestamp | Unix timestamp (seconds) when the delivery was signed |
X-Webhook-Signature | v1=<hex> — HMAC signature of the body, see below |
Respond with any 2xx status within 30 seconds to acknowledge the delivery. Anything else — a non-2xx status, a timeout, or a connection error — counts as a failed attempt and schedules a retry. Do your heavy processing asynchronously and acknowledge fast.
Verifying the signature
The signature is an HMAC-SHA256 over `${timestamp}.${body}` using the endpoint's secret, hex-encoded and prefixed with the scheme version v1=:
import { createHmac, timingSafeEqual } from "node:crypto";
const verifyWebhook = (rawBody: string, headers: Headers, secret: string) => {
const timestamp = headers.get("x-webhook-timestamp");
const signature = headers.get("x-webhook-signature");
if (!timestamp || !signature) return false;
const expected = `v1=${createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex")}`;
return (
expected.length === signature.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
);
};Sign the raw body
Compute the HMAC over the raw request body exactly as received, before any JSON parsing or re-serialization — a re-stringified body will not match the signature. Also check that the timestamp is recent (e.g. within 5 minutes) to guard against replay.
Delivery lifecycle
Every event fan-out creates one delivery per subscribed active endpoint. A delivery moves through these statuses:
| Status | Meaning |
|---|---|
pending | Created, not yet attempted |
in_progress | An attempt is currently running |
succeeded | An attempt got a 2xx response — terminal |
failed | The last attempt failed; a retry is scheduled at nextAttemptAt |
exhausted | All attempts failed and the retry schedule is depleted — terminal unless retried manually |
Failed attempts retry on a fixed backoff schedule: 1 minute, 5 minutes, 30 minutes, 2 hours, 24 hours. attemptCount tracks how many attempts have run, maxAttempts is 5, and nextAttemptAt tells you when the next automatic retry fires (null once the delivery is terminal).
The delivery object
{
"id": "wh_del_...",
"projectId": "...",
"webhookEndpointId": "wh_ep_...",
"eventType": "subscription.created",
"eventOccurredAt": "2026-07-17T09:30:00.000Z",
"payload": { ... },
"status": "failed",
"attemptCount": 2,
"maxAttempts": 5,
"nextAttemptAt": "2026-07-17T10:00:00.000Z",
"completedAt": null,
"createdAt": "2026-07-17T09:30:00.000Z"
}| Field | Type | Description |
|---|---|---|
id | string | Delivery ID (wh_del_*) |
webhookEndpointId | string | Target endpoint |
eventType | string | Event type of this delivery |
eventOccurredAt | string | When the event happened |
payload | unknown | The exact JSON body sent to your endpoint |
status | string | Lifecycle status — see above |
attemptCount | number | Attempts made so far |
maxAttempts | number | Maximum automatic attempts (5) |
nextAttemptAt | string | null | Scheduled time of the next automatic retry |
completedAt | string | null | When the delivery reached a terminal state |
createdAt | string | null | Creation timestamp |
Delivery history
List deliveries
GET /api/v1/webhooks/deliveriesReturns the project's deliveries across all endpoints.
Get a delivery with attempts
GET /api/v1/webhooks/deliveries/:deliveryIdReturns the delivery plus an attempts array — one record per HTTP attempt, which is your debugging window into what your server actually returned:
| Field | Type | Description |
|---|---|---|
id | string | Attempt ID (wh_att_*) |
attemptNumber | number | 1-based attempt index |
succeeded | boolean | Whether the attempt got a 2xx response |
statusCode | number | null | HTTP status returned by your server (null on connection error or timeout) |
durationMs | number | null | Round-trip duration |
responseBody | string | null | Your server's response body, truncated to 2048 characters |
errorMessage | string | null | Transport-level error when no response was received |
createdAt | string | null | When the attempt ran |
Retry a delivery
POST /api/v1/webhooks/deliveries/:deliveryId/retryRe-dispatches the delivery immediately with its original payload and returns it with status: "in_progress". Only deliveries in failed or exhausted state can be retried — anything else returns 400 Api/WebhookValidationError with the message "Can only retry failed or exhausted deliveries".
curl https://api.voidhash.com/api/v1/webhooks/deliveries/wh_del_.../retry \
-X POST \
-H "x-secret-key: vh_sk_..."Errors
| Status | _tag | Meaning |
|---|---|---|
400 | Api/WebhookValidationError | Invalid URL, unknown or empty event list, or retry of a non-retryable delivery |
401 | Api/NotAuthenticatedError | Missing or invalid credentials |
403 | Api/ActionForbiddenError | Credential lacks permission on the project |
404 | Api/WebhookEndpointNotFoundError | No endpoint with that ID in the project |
404 | Api/WebhookDeliveryNotFoundError | No delivery with that ID in the project |
500 | Api/WebhookServiceError | Internal webhook infrastructure failure |
See API Overview for the general error shape and status mapping.