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:

EventFires when
person.createdA new person is created in the project
person.updatedA person's profile or traits change
person.deletedA person is deleted
subscription.createdA subscription starts
subscription.renewedA subscription renews for another period
subscription.cancelledA subscription is cancelled
subscription.expiredA subscription expires
purchase.completedA one-time purchase completes
purchase.refundedA 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"
}
FieldTypeDescription
idstringEndpoint ID (wh_ep_*)
projectIdstringOwning project
namestringDisplay name
urlstringDestination URL (must be http: or https:)
secretstringSigning secret (whsec_ + 64 hex chars) used for the delivery signature
eventsstring[]Subscribed event types
statusstringactive, disabled, or failed — only active endpoints receive deliveries
descriptionstring | nullOptional free-form description
consecutiveFailuresnumberFailed attempts since the last successful delivery; reset to 0 on success
lastSuccessAtstring | nullTimestamp of the last successful delivery
createdAtstring | nullCreation timestamp

Setting status back to active via update also resets consecutiveFailures to 0.

Manage endpoints

Create an endpoint

POST /api/v1/webhooks/endpoints
curl 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"
  }'
FieldTypeRequiredDescription
namestringYesDisplay name
urlstringYesDestination URL, http: or https: only
eventsstring[]YesNon-empty list of event types from the catalog above
descriptionstringNoFree-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/:endpointId

Listing returns every endpoint in the project. Fetching an unknown ID returns 404 Api/WebhookEndpointNotFoundError.

Update an endpoint

PATCH /api/v1/webhooks/endpoints/:endpointId

All fields are optional; only the ones you send change:

FieldTypeDescription
namestringNew display name
urlstringNew destination URL (validated)
eventsstring[]Replacement event list (validated, non-empty)
descriptionstring | nullSet 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/:endpointId

Returns an empty success response.

Rotate the secret

POST /api/v1/webhooks/endpoints/:endpointId/rotate-secret

Generates 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/test

Enqueues 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:

HeaderDescription
Content-Typeapplication/json
X-Webhook-EventThe event type, e.g. subscription.created
X-Webhook-TimestampUnix timestamp (seconds) when the delivery was signed
X-Webhook-Signaturev1=<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:

StatusMeaning
pendingCreated, not yet attempted
in_progressAn attempt is currently running
succeededAn attempt got a 2xx response — terminal
failedThe last attempt failed; a retry is scheduled at nextAttemptAt
exhaustedAll 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"
}
FieldTypeDescription
idstringDelivery ID (wh_del_*)
webhookEndpointIdstringTarget endpoint
eventTypestringEvent type of this delivery
eventOccurredAtstringWhen the event happened
payloadunknownThe exact JSON body sent to your endpoint
statusstringLifecycle status — see above
attemptCountnumberAttempts made so far
maxAttemptsnumberMaximum automatic attempts (5)
nextAttemptAtstring | nullScheduled time of the next automatic retry
completedAtstring | nullWhen the delivery reached a terminal state
createdAtstring | nullCreation timestamp

Delivery history

List deliveries

GET /api/v1/webhooks/deliveries

Returns the project's deliveries across all endpoints.

Get a delivery with attempts

GET /api/v1/webhooks/deliveries/:deliveryId

Returns the delivery plus an attempts array — one record per HTTP attempt, which is your debugging window into what your server actually returned:

FieldTypeDescription
idstringAttempt ID (wh_att_*)
attemptNumbernumber1-based attempt index
succeededbooleanWhether the attempt got a 2xx response
statusCodenumber | nullHTTP status returned by your server (null on connection error or timeout)
durationMsnumber | nullRound-trip duration
responseBodystring | nullYour server's response body, truncated to 2048 characters
errorMessagestring | nullTransport-level error when no response was received
createdAtstring | nullWhen the attempt ran

Retry a delivery

POST /api/v1/webhooks/deliveries/:deliveryId/retry

Re-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_tagMeaning
400Api/WebhookValidationErrorInvalid URL, unknown or empty event list, or retry of a non-retryable delivery
401Api/NotAuthenticatedErrorMissing or invalid credentials
403Api/ActionForbiddenErrorCredential lacks permission on the project
404Api/WebhookEndpointNotFoundErrorNo endpoint with that ID in the project
404Api/WebhookDeliveryNotFoundErrorNo delivery with that ID in the project
500Api/WebhookServiceErrorInternal webhook infrastructure failure

See API Overview for the general error shape and status mapping.