Test a sandbox delivery
This example uses a company subscription andorder.accepted. For a
Partner subscription, follow Consume Partner webhooks;
the receiver and verification steps are the same.
- Open webhook.site and copy your unique HTTPS URL, or deploy your own public HTTPS test receiver. Use synthetic sandbox data with a disposable third-party receiver.
- In the sandbox portal, open Für Entwickler → Webhooks → Neuen Endpoint
hinzufügen, enter that URL, and select
order.accepted. You can also create it withPOST /v2/webhooks/using your sandbox key; save the one-timesecret_keyfrom the response for verification. - Send a test delivery with
POST /v2/webhooks/{id}/test/and JSON body{"event": "order.accepted"}, or finalize an order and simulate acceptance to exercise a real lifecycle event. - Inspect the incoming body and signature headers in your receiver and the delivery result in the portal. A synthetic test confirms transport; the simulated acceptance also lets your handler fetch the real order and mandate.
localhost or private-IP
URL cannot receive deliveries directly. To exercise your local handler, expose
it through a public HTTPS tunnel whose URL answers without redirects, then
register that URL. Use the event feed to reconcile missed events;
it does not verify the HTTP push path or your signature handling.
Synthetic test deliveries use the selected event’s payload shape with generated
resource identifiers. They contain no test marker. Correlate a test with the
event_id and delivery_id returned by the command; a test does not switch
the subscription’s environment.
Receive an event
Deliveries contain a stable event UUID and a thin reference to the affected resource. This example body comes from the current Case contract:Verify signatures
The canonical verification headers arewebhook-id, webhook-timestamp, and
webhook-signature. X-Paywise-Signature and X-Paywise-Timestamp carry the
same signature and timestamp as compatibility aliases.
To verify a delivery:
- Preserve the exact request-body bytes and read the three canonical headers.
- Build the UTF-8 message
<webhook-id>.<webhook-timestamp>.<raw-body>. Do not reserialize the JSON. - Compute HMAC-SHA256 with the endpoint’s signing secret encoded as UTF-8,
Base64-encode the raw 32-byte digest, and prefix it with
v1,. - Split
webhook-signatureon spaces and strictly decode and validate everyv1,<base64>entry. Accept any digest matching a held secret using constant-time comparison before parsing the body. Confirm thatwebhook-idequals the payloadid, and reject a timestamp outside your chosen replay window.
Verify with a known answer
Check your implementation against this vector before pointing it at real deliveries. Every input is shown verbatim; the body is exactly 49 bytes with no trailing newline.v1,cePL6RRZ5U5l9c1ik1KH+BCyH0Zd+CO0Rj2dZej25No= v1,<signature with the previous secret>;
this known-answer vector matches the first entry.
Signing secrets
For acontract_version=v2 subscription, every delivery during the 24-hour
rotation overlap carries the newest and previous signatures in one
space-separated header:
v1,<new-signature> v1,<previous-signature>. This includes retries of
deliveries created before rotation. Strictly Base64-decode and validate every
entry, then accept any signature matching a secret you hold using constant-time
comparison. Keep both secrets for the overlap and try the current secret first;
afterward, deliveries carry only the newest signature. Never log a secret or a
complete customer payload. A legacy contract_version=v1 subscription carries
one signature made with its current secret rather than the v2 overlap pair.
Process delivery safely
Delivery is at least once and unordered:- Deduplicate by event UUID and durably store each verified event before
returning
2xx. Acknowledge duplicates without applying them again. - Process stored events asynchronously and record processing separately from HTTP receipt. Refetch the authoritative resource named by the thin payload; do not infer its current state from event arrival order.
- Handle missing or deleted resources. If processing fails, retry it independently; after durable receipt, do not return a failing HTTP status to use paywise delivery retries as a job queue.
- Tolerate growth. Store and acknowledge an event whose
typeyou do not recognise, then skip it; never fail the delivery. Ignoredatamembers you do not know.
429, and 5xx are retried for
no more than 48 hours. After a transient failure, that
endpoint’s other due retries wait for the next retry run, at most about 60
seconds later; the delivery’s exponential backoff still applies. Redirects,
4xx responses other than 429, a TLS certificate or hostname mismatch, and
a destination that resolves to a non-public address are terminal. The
delivery’s error_message names the reason, for example
Request timed out after 10 seconds, Connection error: <type>,
TLS error: <type>, TLS certificate error: <type>,
blocked_destination:unresolvable (retried), or
blocked_destination:internal_address (terminal). Manual redelivery reuses
the original UUID and payload. It does not create a new domain event.
Event feed
Use the ordered Case feed or Partner feed to catch up after missed webhooks. Events remain available for 30 days and appear about 60 seconds after paywise records them. Each page returnscursor, the
position after its last event: store it and pass it as since on your next
poll. On an empty page cursor echoes the since you sent; it is null only
for an empty page requested without since. next is set only while more
events are waiting — follow it to drain a backlog. The feed is ordered by the
time paywise recorded each event, so an event’s created_at can be earlier
than that of the event before it. limit accepts ASCII digits from 1 to 100.
Deduplicate by the same event id used in webhooks.
Recover missed events
The event feed is the recovery path; manual redelivery is for a single event. paywise has no bulk redelivery.
The feed keeps events for 30 days, so store your
cursor durably and poll
it at least that often. In the paywise portal, Erneut senden on the
delivery log re-sends failed deliveries up to 7 days old. Older deliveries
show a disabled button. Use the API’s redeliver, which has no age limit,
or the feed.
Subscription and delivery recovery
Ifevents is omitted at creation, paywise stores the complete catalog that
exists then. Literal "*" also includes future events available to that
subscription. See your API’s subscription details for ownership and filtering. There is no generic
catch-all event. Deleting a subscription hard-deletes its delivery rows.
Each company (Case Management API) or Partner (Partner API) may register at most 20
endpoints, enabled or disabled; creating a 21st returns 409 with code
webhook_limit_reached — delete an endpoint you no longer need first. The
cap is enforced atomically, so concurrent creates never exceed it. Two
of your endpoints may not share a destination: scheme and host compare
case-insensitively and the default port is ignored, while path and query
must match exactly. A create or PATCH that would collide returns 400
with duplicate_url on the url field. Use distinct paths or query
strings when you deliberately need several subscriptions on one host.
Destinations must use HTTPS, resolve only to public unicast IPs (multicast,
loopback, private, link-local, and cloud-metadata addresses are refused), and
must not redirect. DNS and address safety are checked again for every
delivery attempt: paywise resolves the host anew and connects only to an
address from that resolution, so a destination behind a load balancer or DNS
pool may change addresses between attempts. The stored url is the canonical
form of what you sent — lower-case scheme and host, IDNA-encoded host, trailing
FQDN dot and default port removed, path and query byte-exact — so read it back
from the response instead of assuming your input. A fragment is 400 fragment_not_allowed, a port outside 1–65535 is 400 invalid_port, and a
host that cannot be IDNA-encoded is 400 invalid_host. A public IPv4 literal
such as https://93.184.216.34/hook is accepted. A missing, dotless, or
localhost host, an IPv6 literal, and a host or IPv4 literal that resolves to
a non-public address are 400 on url with code invalid (The webhook URL must be a publicly reachable HTTPS URL.).
enabled accepts only a JSON boolean and max_consecutive_failures only a
JSON integer from 1 to 1000 (default 50, on Case and Partner endpoints alike);
description is a single line. When an endpoint reaches
max_consecutive_failures, paywise switches it off and the read shape reports
auto_disabled: true next to enabled: false, consecutive_failures, and
last_failure_at. Fix the destination, then PATCH {"enabled": true}: the
re-enable resets the failure counter, so the next terminal failure does not
immediately disable it again. A PATCH that changes nothing leaves
updated_at and the ETag untouched. The list filter ?enabled= accepts
only true, false, 1, or 0. rotate-secret and redeliver take no
body; sending one is 400 unexpected_body.
Sandbox and production use the same destination and signature rules. Test
delivery and manual redelivery share a limit of 20 commands per hour for each
company or Partner, charged only when the command is accepted (202); see
Public limits.
Both commands return 202 Accepted with detail, delivery_id, and event_id.
Manual redelivery creates a new delivery attempt (delivery_id) while keeping
the original domain event UUID (event_id) and payload. It is allowed only
on a settled delivery (success or failed, including one whose 48-hour
retry window has expired); while a delivery is still pending, retrying,
or delivering the command is 409 delivery_in_progress — wait for it to
settle. A disabled endpoint answers 409 conflict.
Partner-owned subscriptions are re-checked at delivery time, not only when
the event is fanned out: a queued case event for a company whose Partner
access has ended is closed as failed with error_message
partner_access_unavailable, without contacting your endpoint and without
counting toward consecutive_failures. This covers queued lifecycle
company.* events as well; only your own company.access.revoked is always
delivered. See Partner webhook ownership.
The access check runs right before each send and does not hold up a
disconnect: an attempt that has already passed it can still reach your
endpoint just after the disconnect commits. Every later attempt, retry, and
manual redelivery is suppressed.
Mahnservice events are durable. Most invoice.* and dunning.* events are
recorded together with the invoice change that raises them. A real
debt-collection handover instead commits a durable handover record, and a
relay records dunning.handed_to_collection under its stable event ID shortly
afterward. Delivery itself stays asynchronous. See the
Mahnservice event details.
Troubleshooting
Start with the delivery log in the developer portal (Für Entwickler → your webhook). It shows each attempt, its response status, and up to 4 KiB of your response body for 30 days. Replay resends the original event UUID and payload. For Partner-owned subscriptions, use the Partner delivery history.Contact support
If these checks do not resolve the problem, contact [email protected] with the subscription ID, the event UUID if available, and theX-Paywise-Request-Id of the API call that should
have produced the event. Do not include signing secrets or complete customer
payloads.