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:

  1. Parse t and v1 from the header.
  2. Reject if |now - t| exceeds your tolerance (we recommend 300 seconds) — this blocks replay of captured deliveries.
  3. Compute HMAC-SHA256(secret, t + "." + raw_body) and compare to v1 in 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 3xx is 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 marked failed.
  • Each failure increments the endpoint's consecutive_failures and stamps last_failed_at; any success resets the counter and stamps last_succeeded_at.
  • After 50 consecutive failures the endpoint is auto-disabled (is_active: false, disabled_at set) so a dead receiver stops consuming delivery capacity. Fix your receiver, then re-enable via PATCH /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

  1. Verify the signature before parsing. Read the raw body, verify, then decode.
  2. Respond 2xx fast, process async. You have 30 seconds before the attempt times out; queue the payload and return 200 immediately rather than doing work inline.
  3. Dedupe by envelope id. At-least-once delivery is the contract; exactly-once is your job.
  4. Order by occurred_at, not arrival. Retries mean events can arrive out of order.
  5. Don't redirect. Serve the receiver directly on the registered URL — any 3xx counts as a failed delivery.
  6. 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.
  7. Rotate secrets on staff departure or suspected leak. Rotation is cheap and keeps delivery history.