Webhooks
VerityPay pushes outbound webhooks to HTTPS endpoints you register — signed JSON POSTs that let your systems mirror contact and payment state without polling. This page is the delivery contract; the endpoint-management API itself is documented in Endpoints → Webhook endpoints.
Register an endpoint
Register programmatically (POST /api/v1/webhook-endpoints) or in the tenant dashboard. Either way you provide:
- an HTTPS delivery URL (plain
http://is rejected), - a name for humans,
- a list of subscribed events — exact types, domain wildcards (
payable.*), or the catch-all*.
The 201 response includes the endpoint's signing_secret once. Store it in your secrets manager immediately — no later read returns it. If it leaks, rotate it (POST …/rotate-secret); the new plaintext is again returned exactly once and delivery history is preserved.
Event catalog
| Domain | Event types |
|---|---|
| Transaction | transaction.status_changed, transaction.notification_resent |
| Contact | contact.created, contact.updated, contact.deleted, contact.batch.completed |
| Payable | payable.link_created, payable.disbursed, payable.failed, payable.cancelled, payable.link_revoked, payable.expired, payable.retry_requested, payable.identity_overridden, payable.unlocked, payable.batch.completed |
| Receivable | receivable.link_created, receivable.paid, receivable.failed, receivable.cancelled, receivable.link_revoked, receivable.expired, receivable.retry_requested, receivable.payer_overridden, receivable.unlocked, receivable.batch.completed |
Subscriptions support exact match, single-segment wildcards per domain (transaction.*, contact.*, payable.*, receivable.*), and * for everything. Unknown event strings are rejected at registration time with 422 — you can't silently subscribe to an event that will never fire.
transaction.status_changed fires on every transaction status transition (ready, in_progress, awaiting_provider_confirmation, succeeded, failed, expired, cancelled); its data carries the serialized transaction plus from_status and to_status. Terminal direction events (payable.disbursed, receivable.paid, …) still fire alongside it — the two surfaces serve lifecycle mirroring vs. settlement reconciliation. Subscribe to the granularity you need and dedupe by event id.
Batch completion events
contact.batch.completed, receivable.batch.completed, and payable.batch.completed fire once per finished batch ingest. By default a batch suppresses its per-record *.created/*.updated deliveries (a 10,000-row import will not send you 10,000 webhooks) and emits this single summary instead. Its data carries the batch header — id, type, status (completed, partial_failure, or failed), the item counters (total_items, processed_items, succeeded_items, failed_items), timestamps, and links.self / links.errors for fetching per-row failures — not the records themselves. If a batch is submitted with suppress_webhooks: false, the per-record events fire as usual and the summary event is not emitted.
Delivery format
Every delivery is a JSON POST with a stable envelope:
{
"id": "01HZKA3V6E9Q4W2X8Y7Z5T3R1M",
"type": "receivable.paid",
"occurred_at": "2026-07-01T14:32:11+00:00",
"data": { /* event-specific payload */ }
}
id— ULID, unique per event. Dedupe on this: retries and multi-endpoint fan-out can deliver the same event more than once.type— the event type; route on it.occurred_at— ISO-8601; order on it, not on arrival time.data— the event payload (e.g., the serialized transaction).
Request headers
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
VerityPay-Webhooks/1.0 |
X-VerityPay-Signature |
t=<unix_ts>,v1=<hmac_hex> (see below) |
X-VerityPay-Event |
the event type |
X-VerityPay-Event-Id |
the envelope id |
X-VerityPay-Attempt |
attempt number, starting at 1 |
Verify the signature
Every POST is signed with your endpoint's signing_secret using HMAC-SHA256, following the Stripe convention:
X-VerityPay-Signature: t=1751380331,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
The signed value is {t}.{raw_body} — the timestamp, a literal dot, then the exact raw request bytes (do not re-serialize the JSON before verifying). To verify:
- Parse
tandv1from the header. - Reject if
|now - t|exceeds your tolerance (we recommend 300 seconds) — this blocks replay of captured deliveries. - Compute
HMAC-SHA256(secret, t + "." + raw_body)and compare tov1in constant time.
function verifyVerityPaySignature(string $rawBody, string $secret, string $header, int $tolerance = 300): bool
{
$parts = [];
foreach (explode(',', $header) as $segment) {
[$k, $v] = array_pad(explode('=', trim($segment), 2), 2, null);
if ($k !== null && $v !== null) {
$parts[$k] = $v;
}
}
if (! isset($parts['t'], $parts['v1']) || abs(time() - (int) $parts['t']) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'].'.'.$rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
Reject anything that fails verification with a non-2xx status. Signature verification is your only proof the payload came from VerityPay.
Retries and auto-disable
- A delivery counts as successful only on an HTTP 2xx response. Redirects are not followed — a
3xxis a failure (following one could leak the signed payload to a host you never registered). - Failures retry with exponential backoff —
min(2^(attempt−1) × 30s, 24h)— up to 8 attempts, then the delivery is markedfailed. - Each failure increments the endpoint's
consecutive_failuresand stampslast_failed_at; any success resets the counter and stampslast_succeeded_at. - After 50 consecutive failures the endpoint is auto-disabled (
is_active: false,disabled_atset) so a dead receiver stops consuming delivery capacity. Fix your receiver, then re-enable viaPATCH /webhook-endpoints/{webhook_endpoint}with{ "is_active": true }or from the dashboard.
Use GET /webhook-endpoints/{webhook_endpoint}/deliveries to inspect attempt outcomes (status, attempt_count, response_status, last_error, next_attempt_at) when debugging missed events.
Receiver best practices
- Verify the signature before parsing. Read the raw body, verify, then decode.
- Respond 2xx fast, process async. You have 30 seconds before the attempt times out; queue the payload and return
200immediately rather than doing work inline. - Dedupe by envelope
id. At-least-once delivery is the contract; exactly-once is your job. - Order by
occurred_at, not arrival. Retries mean events can arrive out of order. - Don't redirect. Serve the receiver directly on the registered URL — any
3xxcounts as a failed delivery. - Alert on
disabled_at. Poll your endpoint's health fields (or watch for silence) so a 50-failure auto-disable doesn't go unnoticed for days. - Rotate secrets on staff departure or suspected leak. Rotation is cheap and keeps delivery history.