Skip to main content
For event types and payload keys, see Webhook events. The shared webhook guide covers delivery behavior and troubleshooting.

Outcome

Your HTTPS handler verifies exact raw bytes before parsing, durably deduplicates events, acknowledges after persistence, and refetches authoritative state.

Prerequisites

  • A public HTTPS endpoint without redirects or private/reserved resolution.
  • A Bearer key.
  • Secure secret storage, a replay window, and a durable event-ID receipt table.
  • One stable idempotency key per create, test, redelivery, or rotation command.

1. Create and capture the one-time secret

The destination https://hooks.example.test/paywise is a placeholder like the API key: replace it — here and in the runnable workflow below — with your own publicly reachable HTTPS endpoint before running the sample. The API resolves the hostname at creation time and rejects unreachable or private destinations with 400 validation_error, so the placeholder itself never creates a subscription.
CaseWebhookWithSecret.secret_key appears exactly once. Import it directly from the restricted response into a secret manager and securely remove the response; never print or log it. Ordinary read/update responses never expose the secret.

2. Exhaust the secret-free subscription read

Representative response

This complete PaginatedWebhookList response correctly contains no secret.

3. Request a test delivery

Capture the exact returned delivery_id and event_id. A fresh request ID is not a delivery correlation key.

4. Read the stable delivery detail

5. Redeliver only after exact ownership validation

The management workflow below fetches the stable delivery detail and fails closed unless both its exact id and exact webhook equal the expected values. The redelivery POST occurs only after that check. Redeliver only a settled delivery — status success or failed. While a delivery is pending, retrying, or delivering, the dispatcher still owns it and the command is 409 delivery_in_progress; wait for it to settle and read the outcome before deciding. The command takes no request body (400 unexpected_body otherwise), and its expensive:webhook-delivery budget unit is charged only when the 202 is returned.

6. Rotate and retain overlap secrets

Capture the new secret_key once. Try the current secret first and retain the prior secret for the documented 24-hour overlap. Every delivery for the contract_version=v2 subscription used in this workflow, including retries created before rotation, carries newest and previous signatures during that window. Remove the prior secret after the overlap.

Exact raw-body verification and durable receipt

Read webhook-id, webhook-timestamp, and webhook-signature before parsing. The signed bytes are exactly <event_id>.<unix_timestamp>.<exact raw JSON>. Split the space-separated v1,<base64> signatures, strictly Base64-decode and validate every entry, then accept any raw HMAC-SHA256 digest matching a held secret in constant time. Enforce the replay window, then parse JSON and require payload id to equal the header ID. Compatibility aliases are not additional signatures. Persist the event ID, raw/minimized receipt, and one pending outbox job in the same durable transaction before returning 2xx. A duplicate receives success only after the prior receipt and job are confirmed. A separate recoverable worker claims pending or interrupted jobs, performs an idempotent domain effect, and marks the job complete. It refetches authoritative state by stable identity; delivery retries are not a job queue. For acceptance, reconcile the stable claim UUID you stored when the draft was created. The submitted order ID is informational after review: paywise may accept the claim under a different order without changing the claim UUID. Match membership in claim_ids, then read the claim directly and adopt its current order_id and mandate_id:
Acceptance reconciliation
Clients do not reconstruct A→B merge chains; they do not follow merged_into for claim commands. Every claim-specific read or command continues at its top-level /v2/claims/{claim_id}/… URL.

Runnable Python workflow

Python workflow
Run subscription setup, test delivery, ownership-checked redelivery, and secret rotation only with WEBHOOK_PHASE=management. Deploy the HTTP receiver with WEBHOOK_PHASE=handler and the recoverable job runner with WEBHOOK_PHASE=worker, both pointing at the same durable database. Handler and worker invocations make no paywise management API calls and need no API URL or API token. Supply the captured values to the receiver as WEBHOOK_CURRENT_SECRET and WEBHOOK_PREVIOUS_SECRET; the worker needs neither. Each invocation is independent: a worker crash is recovered by a later fresh worker execution, not an in-memory retry. There is deliberately no combined or default phase; an omitted or unknown phase fails closed. The subscription search uses a five-page example budget. Keep a finite production budget plus cycle and exact HTTPS-origin validation before every opaque next request so the Bearer token is never sent elsewhere.

Failure and recovery

  • Invalid signature, mismatched ID, or stale timestamp: reject before domain parsing/processing and record only safe diagnostics.
  • Duplicate ID: acknowledge only after confirming the durable prior receipt.
  • Out-of-order event: persist, acknowledge, and refetch current state.
  • Management 429: test and redelivery share a company bucket; wait at least Retry-After, then retry the same command and idempotency key.
  • 409 delivery_in_progress: the delivery is still pending, retrying, or delivering. Do not retry blindly; poll the delivery until it settles.
  • 409 webhook_limit_reached: the company already holds 20 endpoints (enabled or disabled). The cap is enforced atomically, so parallel creates cannot slip past it; delete an unused endpoint first.
  • 400 unexpected_body on rotate-secret or redeliver: send the command without a body.
  • 403 use_partner_webhooks: a Partner key with X-On-Behalf-Of-Company cannot create, change, or redeliver company endpoints; this answer comes before any If-Match (412) or query-parameter (400) check. Use the Partner-owned endpoints instead.
  • Management timeout, 500, or 503: retry the same POST and key.

Verify

Require the row whose event_id equals the test command’s event_id to report status success and a 2xx response_status_code, and confirm your receiver stored exactly one receipt for that event UUID — a second receipt means deduplication did not hold.