Documentation
Open workspace

Webhooks

Signed events, receiver verification, retries, recovery and rollout requirements.

This guide defines the final B1 signed-webhook contract. Rollout is disabled by default (WEBHOOKS_ENABLED=false). Dispatch, send-time projection, wake infrastructure and dashboard work still have implementation and verification gates. This does not establish deployment or enablement. The billing catalogue's signedWebhooks availability remains unchanged until the user authorizes release.

Endpoint setup and capacity

Create with POST /v1/webhook-endpoints. A 201 returns {endpoint, secret} and Location: /v1/webhook-endpoints/{id}. Store the secret in a server secret store.

Create a sandbox endpoint
{"url":"https://hooks.example.com/vat-tools","eventTypes":["verification.completed"],"payloadSchemaVersion":"2026-09-07","mode":"sandbox","managedBy":"manual"}

The same URL, subscriptions and payload schema return 200 with the existing endpoint and current secret; conflicting settings return 409 WEBHOOK_ENDPOINT_EXISTS. POST mutations accept an 8–128 character Idempotency-Key. A matching replay within 24 hours returns the original response; a different request under that key returns 409 IDEMPOTENCY_CONFLICT.

Reads need webhooks:read; mutations need webhooks:write. Session mutations require an owner/admin and trusted origin. Integration principals remain restricted to their organization/environment. Foreign IDs return the corresponding endpoint/delivery 404. Put mode in the create body and in the query everywhere else. Sandbox events never reach live endpoints.

Plan/account stateLive endpointsSandbox endpoints
Free or trial01
Starter33
Growth55
Scale1010
Business1616
Inactive00

Caps apply separately per organization/environment and count all nondeleted endpoints, including disabled ones. A zero cap returns 403 WEBHOOKS_NOT_IN_PLAN; a full cap returns 409 WEBHOOK_ENDPOINT_LIMIT_REACHED. Free live needs a paid plan; Free sandbox permits one endpoint. Admission uses normalized capacity counts, not the signedWebhooks boolean. Downgrades preserve endpoints: only the oldest enabled endpoints within the new cap get fan-out, selected before subscription and event-age filtering. New endpoints receive only events that occurred after creation. Deliveries consume no usage units.

Use a canonical public HTTPS URL on port 443, without credentials, fragment or IP literal. Local/private/internal targets, restricted service hosts and any DNS answer with a forbidden address are rejected. Every attempt validates DNS and connects to a validated pinned address. Redirects are never followed. No static source IPs are provided; verify signatures instead of relying on an IP allowlist.

Wire format and signing

Requests use POST, Content-Type: application/json and User-Agent: VATToolsWebhooks/1.

HeaderMeaning
webhook-idStable evt_ plus 32 lowercase hexadecimal characters, also the envelope id. Identical across endpoints, retries and redeliveries.
webhook-timestampFresh attempt time, integer Unix seconds. Check freshness against this header.
webhook-signatureSpace-separated v1,<base64 signature> values, one per active secret.

The strict envelope has exactly {id, type, timestamp, data}. Envelope timestamp is the original event's occurredAt, an ISO datetime; it does not advance on retry. The header timestamp is fresh. Body bytes and current projection may change on a later attempt while the event ID stays stable.

Synthetic test envelope
{"id":"evt_00000000000000000000000000000001","type":"webhook.test","timestamp":"2026-10-21T12:00:00.000Z","data":{"schemaVersion":"webhook-events-v1","endpointId":"00000000-0000-4000-8000-000000000001","environment":"sandbox","message":"This is a test event from VAT Tools. It carries no customer data."}}

Sign HMAC-SHA256 over id + "." + timestamp + "." + raw body, using the exact integer header string and the base64-decoded payload of whsec_ plus canonical base64 of 32 random bytes. The bytes signed are the bytes sent. Verify the unmodified raw body before parsing JSON; never stringify a parsed object to verify it. Official verifiers allow five minutes (300 seconds) past/future tolerance. Synchronize receiver clocks; envelope time is not the freshness clock.

Delivery is at least once, with no ordering and no delivery SLA. Persist verified envelope id with the processing result and deduplicate transactionally. A delivery-row ID is not the deduplication key; lease loss can resend an accepted event. Return 2xx after durable acceptance and process slow work asynchronously. Never acknowledge unverified data.

Official receiver verifiers

Verified pins: standardwebhooks@1.1.1 (npm), standardwebhooks==1.1.0 (PyPI), Go module github.com/standard-webhooks/standard-webhooks/libraries v0.0.1. The Go import ends in /libraries/go; this directory is not a standalone module. These verification helpers throw or return an error for rejection. Deduplicate only verified events.

TypeScript
import { Webhook } from "standardwebhooks";

export async function verifyWebhook(request: Request, secret: string): Promise<unknown> {
  const rawBody = await request.text();
  const names = ["webhook-id", "webhook-timestamp", "webhook-signature"];
  const headers = Object.fromEntries(names.map((name) => {
    const value = request.headers.get(name);
    if (!value) throw new Error("Missing webhook header");
    return [name, value];
  }));
  return new Webhook(secret).verify(rawBody, headers);
}
Python
from collections.abc import Mapping
from standardwebhooks.webhooks import Webhook

def verify_webhook(raw_body: bytes, headers: Mapping[str, str], secret: str):
    # Pass request bytes before any JSON parsing or body transformation.
    return Webhook(secret).verify(raw_body, dict(headers))
Go
package receiver

import (
    "fmt"
    "io"
    "net/http"
    standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)

func VerifyWebhook(r *http.Request, secret string) ([]byte, error) {
    rawBody, err := io.ReadAll(io.LimitReader(r.Body, 1048577))
    if err != nil { return nil, err }
    if len(rawBody) > 1048576 { return nil, fmt.Errorf("webhook body too large") }
    verifier, err := standardwebhooks.NewWebhook(secret)
    if err != nil { return nil, err }
    if err := verifier.Verify(rawBody, r.Header); err != nil { return nil, err }
    return rawBody, nil // Parse JSON only after verification succeeds.
}

Official references: Standard Webhooks libraries, Python verifier, Go verifier.

The synthetic committed one-secret vector tests signing bytes, not event-schema validation. Its historical timestamp must fail normal freshness verification. Freeze time only in tests. Go conformance uses VerifyIgnoringTimestamp only for historical fixtures and separately tests normal freshness.

Fixed signing vector
secret: whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
webhook-id: evt_00000000000000000000000000000001
webhook-timestamp: 1700000000
raw body: {"hello":"world"}
webhook-signature: v1,GoH9d6IZw43G7lBk+henfQ4MIGUiCphj7SvLIxUBiro=

apps/api/src/webhooks/test-vectors.json also covers two-secret rotation and non-ASCII UTF-8. node apps/api/scripts/webhook-conformance.mjs checks official Python/Go libraries. TypeScript conformance tests both signing directions and rejects changed body/ID/timestamp and stale/future attempts. These checks do not deploy anything.

Exact event catalogue

Subscribe to one to five unique domain event types. webhook.test goes directly to one endpoint, outside the outbox and subscription list. No other event names are part of this contract.

Typedata
verification.completedCurrent-policy saved verification/history item for the endpoint's pinned payloadSchemaVersion. Four history kinds only; bulk rows are excluded.
bulk_job.completed{schemaVersion: "webhook-events-v1", job, rowsPath}. Finished job: completed, completed_with_issues or cancelled. Fetch rows separately; a later re-finish creates no second event.
monitor.change_observedStrict monitor-events-v1 data: disclosure-policy version, environment, monitor ID and observed change.
monitor.cannot_seeStrict monitor data: subject, cannot-see time, last definitive observation, threshold and unavailable facts. No source fields; lack of observation is not authority-invalid.
monitor.visibility_restoredStrict monitor data: subject, restored time, previous cannot-see time and restored facts.
webhook.test{schemaVersion: "webhook-events-v1", endpointId, environment, message}. Fresh event ID, real endpoint signing secret.

payloadSchemaVersion is pinned at creation: 2026-09-01 (default) or 2026-09-07. It selects the saved verification representation and is not a PATCH field or disclosure exception. The envelope has no top-level schemaVersion; bulk/monitor data use their own versions.

Every non-test attempt, retry, redelivery and recovery rereads the current outbox event for validity and retention. Verification is reprojected with DeliveryContext.purpose: "history", matching current history detail apart from servedAt. Missing, expired, suppressed, invalid or refused data becomes withheld, with no outbound request. Delivery never falls back to stored payloads, native results or old exports. Outbox retention is 35 days. Policy changes can invalidate queued monitor events and withhold them.

Source identities and links may identify only currently reviewed government/public-authority services for the permitted fields/resource. Other remote evidence stays anonymous. Private-only agreement is partial_correlation, capped at 60/100: a heuristic, not a probability or official verification. Preserve notices, original observation times, ambiguity and unavailable states. Delivery/recovery reruns no source work and charges no new operation.

Attempts, health and recovery

Success is any 2xx within 15 seconds from connection start; DNS has its own 3-second bound. Redirects, timeouts, connection/TLS failures and every non-2xx fail. Never follow 3xx. Payloads are at most 1 MiB.

Nine attempts: immediate, then delays of 30 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 24 hours and 24 hours. Each retry adds 0–20% jitter, roughly 65.6 hours before jitter. On 429/503, Retry-After accepts seconds or an HTTP date; wait the greater of the step and header, capped at 24 hours. Failure nine marks the delivery failed.

Five consecutive failures open a circuit for min(60 seconds × 2^opens, 15 minutes), then allow one half-open probe. A 2xx clears failure health/circuit. HTTP 410 disables immediately (gone). Continuous failure disables only after all three: five days failing, at least two failed attempts, and first-to-last failure at least 12 hours apart. A single other 4xx never disables. Disable fails pending deliveries. Verified owners/admins get warnings at 24/96 hours and one disable notice, containing endpoint host/dates and never payloads, snippets or secrets.

ActionAPI
List / find exact URLGET /v1/webhook-endpoints?mode=sandbox&url=...
Read / update / deleteGET, PATCH, DELETE /v1/webhook-endpoints/{endpointId}?mode=sandbox
Delivery logGET /v1/webhook-endpoints/{endpointId}/deliveries?mode=sandbox
Delivery detailGET /v1/webhook-deliveries/{deliveryId}?mode=sandbox
TestPOST /v1/webhook-endpoints/{endpointId}/test?mode=sandbox
RedeliverPOST /v1/webhook-deliveries/{deliveryId}/redeliver?mode=sandbox
RecoverPOST /v1/webhook-endpoints/{endpointId}/recover?mode=sandbox
Rotate secretPOST /v1/webhook-endpoints/{endpointId}/rotate-secret?mode=sandbox

Logs are newest first with stable cursors; filter by status or eventType. Statuses: pending, succeeded, failed, withheld. Delivery/attempt logs expire after 30 days. Public responseSnippet is currently null: arbitrary receiver text has no source admission. Internal text is bounded to 1,024 UTF-8 bytes with unsafe controls removed.

Tests get one attempt, no retries, and a five-per-minute endpoint limit (429 WEBHOOK_TEST_RATE_LIMITED). Redelivery creates a new row with redeliveryOf, the same event ID and fresh 30-day log lifetime. It does not extend source/outbox retention. Pending original/latest redelivery returns 409 WEBHOOK_DELIVERY_IN_PROGRESS. Recovery accepts {"since":"2026-10-21T00:00:00.000Z"}, schedules failed deliveries at most 30 days back and 1,000 per call, and returns 202 {schemaVersion:"webhook-deliveries-v1", scheduled}. Withheld deliveries are not failed-delivery recovery candidates.

Repair the receiver, PATCH {"status":"enabled"} to reset failure counters/circuit, then recover. Disabled endpoints reject redelivery/recovery with 409 WEBHOOK_ENDPOINT_DISABLED. DELETE returns 204 on repeat in the original scope; a never-existing ID returns 404.

Rotate with {"expirePreviousInSeconds":86400}: overlap is 0–86,400 seconds, default 86,400; zero revokes immediately. Accept a valid signature from your active secrets during transition, then discard the old key. Owners/admins can reveal through session-only GET /v1/webhook-endpoints/{endpointId}/secret?mode=sandbox, outside OpenAPI. Never log secrets or send them to support. Use the errors guide, API reference and X-Request-Id for support correlation.

For integration authors

These are platform handoff requirements; REST Hook triggers still require implementation, fail-closed envelope validation and platform review. Existing polling remains available.

  • Zapier performSubscribe: POST endpoints with {url: bundle.targetUrl, eventTypes, managedBy: "zapier", mode}; keep response endpoint.id as subscribe-data id. performUnsubscribe: DELETE /v1/webhook-endpoints/{bundle.subscribeData.id}?mode=..., accepting repeated 204. Keep the poll as performList. Free live subscribe returns 403 WEBHOOKS_NOT_IN_PLAN; Free sandbox permits one endpoint. Deduplicate by envelope id. Continuous-failure disable sends one best-effort DELETE for Zapier-managed endpoints through the hardened transport.
  • Add paths to Zapier's lib/client.js allowlist. Extend the integrations/shared-public-delivery generator for the strict envelope and validate hooks through validatePublicDelivery, failing closed. This task changes no integrations implementation.
  • Make: create with managedBy: "make", retain the ID, detach with DELETE. Verify signatures/current delivery before running a scenario.
  • n8n: checkExists lists by exact URL; create with managedBy: "n8n" only if missing. Retain/delete the ID on unsubscribe. Port 443 applies to self-hosted receivers.
  • Power Automate: create with managedBy: "power_automate", retain the 201 Location, DELETE it in the same mode. Signature verification and current envelope admission still apply.

Operator handoff and release gates

The build-versus-buy decision is confirmed: shared outbox, Mongo delivery state, hardened transport and Standard Webhooks signing with Cloud Tasks wakes. Customer payload delivery is not delegated to an external webhook platform. W2/W4 approval is recorded. The following user-only steps have not been performed by this documentation task.

ConfigurationPurpose
WEBHOOKS_ENABLEDDefault false; gates routes, consumer, dispatcher and sweep.
WEBHOOK_SECRET_KEY_BASE64, WEBHOOK_SECRET_KEY_ID32-byte AES-256-GCM encryption key and identifier.
WEBHOOK_SECRET_PREVIOUS_KEY_BASE64, WEBHOOK_SECRET_PREVIOUS_KEY_IDOptional previous paired key; decryption only.
WEBHOOK_WAKE_MODEcloud_tasks or disabled; sweep backs up lost wakes.
WEBHOOK_TASKS_QUEUEFull queue name.
WEBHOOK_WAKE_URL, WEBHOOK_WAKE_AUDIENCEInternal service URL and custom OIDC audience.
WEBHOOK_TASKS_INVOKERInvoker service account email.
WEBHOOK_ENDPOINT_MAX_IN_FLIGHTPer-endpoint concurrency, default 4.

Secrets use AES-256-GCM with AAD organizationId\0environment\0endpointId\0secretId. Enabled startup requires the encryption key. Secrets never enter logs, metrics, delivery rows, errors, mail or test output. Organization deletion removes endpoints, deliveries, attempts and mutation receipts even with the flag off.

The internal vat-tools-<env>-webhooks service uses the API image and apps/api/dist/webhook-dispatcher.mjs. Its only secrets are Mongo and the webhook encryption key; it needs no Redis, auth, source-provider or mail credentials.POST /internal/webhooks/wake fans out and dispatches within 60 seconds, with at most eight parallel attempts, then returns 204. Health endpoints are /health/ready and /health/live. OIDC requires the Google issuer, RS256, token age at most one hour, exact custom audience, verified invoker email and configured queue ID; failures return empty 401. Immediate wakes are coalesced per process; the worker sweep shares leases/fences, dispatches overdue work and owns warning mail. These are final runtime requirements, not deployment evidence.

  1. Generate a 32-byte key and seed Secret Manager out of band, without printing/committing it. Configure paired IDs and optional previous key; never copy production values to docs/tests.
  2. The infrastructure implementation is complete. Production owns runtime vattools-prod-webhooks, invoker vattools-prod-webhooks-invoker and key container vat-tools-prod-webhook-secret-key. Staging owns vattools-stg-webhooks, vattools-stg-webhooks-invoker and vat-tools-staging-webhook-secret-key. Current and optional previous keys use pinned numeric versions of the same environment-owned container, seeded out of band. The implementation configures internal-only Cloud Run, a custom audience, datastore CA mount, PRIVATE_RANGES_ONLY VPC egress and invoker-only run.invoker; runtime telemetry, task enqueueing and narrow Mongo/key access; and API/jobs/runtime roles/iam.serviceAccountUser on that environment's invoker. Wake/key configuration is wired to all three runtimes. Deployment, key seeding and enablement remain pending operator actions.
  3. The user runs pnpm infra:plan:staging, reviews, then pnpm infra:apply:staging. After staging acceptance repeat plan/review/apply for production. Cloud Tasks uses the default internal run.app URL in the same project/perimeter, IAM and application OIDC verification of exact audience/email and queue ID. Queue headers alone are not identity. Escalate ingress changes; never silently make a public fallback.
  4. Verify the completed B1 implementation with disclosure/security reviews and failure/platform drills before rollout. Measure staging completion p95: post-commit wake can add up to 750 ms. Review fire-and-forget plus sweep if unacceptable. Measure real payloads against platform guidance; 1 MiB remains the ceiling.
  5. The user enables WEBHOOKS_ENABLED=true per accepted environment. The user/B4 owner approves changing PRIVATE_BETA_FEATURE_AVAILABILITY.signedWebhooks to available. Capacity admission does not publish commercial availability. No deployment, flag or billing changes are performed here.
  6. Deferred: static egress IP, ports other than 443, and actual Zapier/Make/n8n/Power Automate trigger implementation/publishing. Each needs approved follow-up work; polling remains the fallback during disabled rollout.