> ## Documentation Index
> Fetch the complete documentation index at: https://docs.paywise.de/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive a test event, verify signed deliveries, handle retries and duplicates, and troubleshoot your receiver.

Webhooks send signed HTTP notifications when something changes in paywise.
This page covers the behavior shared by all webhook subscriptions: receiving
an event, verifying it, processing it reliably, and recovering from failures.
If a delivery is missing or failing, start with [Troubleshooting](#troubleshooting).

Choose event types and subscription settings in the docs for your API:

| API | Event types and subscription details |
| - | - |
| Case Management | [Orders, cases, payments, and statements](/api-docs/case-management-api/concepts/webhook-events) |
| Mahnservice | [Invoices and dunning](/api-docs/mahnservice-api/concepts/invoice-lifecycle#webhook-events) |
| Partner | [Company and user events, ownership, and company filtering](/api-docs/partner-api/concepts/webhook-ownership) |

## Test a sandbox delivery

This example uses a company subscription and `order.accepted`. For a
Partner subscription, follow [Consume Partner webhooks](/api-docs/partner-api/workflows/consume-webhooks);
the receiver and verification steps are the same.

1. Open [webhook.site](https://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.
2. 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 with `POST /v2/webhooks/` using your sandbox key; save the
   one-time `secret_key` from the response for [verification](#signing-secrets).
3. Send a test delivery with `POST /v2/webhooks/{id}/test/` and JSON body
   `{"event": "order.accepted"}`, or finalize an
   order and [simulate acceptance](/api-docs/case-management-api/quickstart#4-accept-the-order-in-the-sandbox)
   to exercise a real lifecycle event.
4. 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.

The sandbox has no built-in webhook-bin receiver. A `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](#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:

```http theme={null}
POST /hooks/paywise HTTP/1.1
Content-Type: application/json
webhook-id: 00000000-0000-4000-8000-000000000001
webhook-timestamp: 1787832000
webhook-signature: v1,<base64-hmac-sha256>
X-Paywise-Signature: v1,<base64-hmac-sha256>
X-Paywise-Timestamp: 1787832000

{
  "id": "00000000-0000-4000-8000-000000000001",
  "type": "order.submitted",
  "created_at": "2026-08-27T12:00:00Z",
  "data": {
    "company_id": "10000000-0000-4000-8000-000000000001",
    "order_id": "20000000-0000-4000-8000-000000000001",
    "order_url": "/v2/orders/20000000-0000-4000-8000-000000000001/"
  }
}

HTTP/1.1 204 No Content
```

Every event has four top-level fields:

| Field | Meaning |
| - | - |
| `id` | Stable event UUID; use it to deduplicate deliveries. |
| `type` | Event key from the API's catalog. |
| `created_at` | When the event was created, not when this delivery arrived. |
| `data` | Event-specific resource identifiers and references; use the API's catalog for the exact keys. |

## Verify signatures

The canonical verification headers are `webhook-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:

1. Preserve the exact request-body bytes and read the three canonical headers.
2. Build the UTF-8 message
   `<webhook-id>.<webhook-timestamp>.<raw-body>`. Do not reserialize the JSON.
3. 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,`.
4. Split `webhook-signature` on spaces and strictly decode and validate every
   `v1,<base64>` entry. Accept any digest matching a held secret using
   constant-time comparison before parsing the body. Confirm that
   `webhook-id` equals the payload `id`, 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.

| Input | Value |
| - | - |
| Signing secret | `whsec_test_0123456789abcdef` |
| `webhook-id` | `msg_2b5c1e9d4f8a` |
| `webhook-timestamp` | `1756800000` |
| Raw body | `{"id":"msg_2b5c1e9d4f8a","type":"order.accepted"}` |
| Signed message | `msg_2b5c1e9d4f8a.1756800000.{"id":"msg_2b5c1e9d4f8a","type":"order.accepted"}` |
| Expected `webhook-signature` | `v1,cePL6RRZ5U5l9c1ik1KH+BCyH0Zd+CO0Rj2dZej25No=` |

```python theme={null}
import base64, hashlib, hmac
secret, webhook_id, timestamp = "whsec_test_0123456789abcdef", "msg_2b5c1e9d4f8a", "1756800000"
body = b'{"id":"msg_2b5c1e9d4f8a","type":"order.accepted"}'
message = f"{webhook_id}.{timestamp}.".encode() + body
digest = hmac.new(secret.encode(), message, hashlib.sha256).digest()
assert "v1," + base64.b64encode(digest).decode() == "v1,cePL6RRZ5U5l9c1ik1KH+BCyH0Zd+CO0Rj2dZej25No="
```

If your result differs, the usual causes are a reserialized body (added
whitespace or reordered keys), a missing dot between the three parts, a
hex-encoded instead of Base64-encoded digest, or a secret that was itself
Base64-decoded before use.

During a rotation overlap the same delivery carries
`v1,cePL6RRZ5U5l9c1ik1KH+BCyH0Zd+CO0Rj2dZej25No= v1,<signature with the previous secret>`;
this known-answer vector matches the first entry.

## Signing secrets

For a `contract_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.

<Warning>
  `POST /v2/webhooks/` and `POST /partner/v2/webhooks/` return a documented
  one-time-secret response containing `secret_key`. The corresponding bodyless
  `POST .../{id}/rotate-secret/` operations return the same one-time-secret
  shape. Capture and store that field securely. List, retrieve, and update
  responses use the secret-free webhook shape and never reveal it. Neither
  does an idempotent replay: repeating the create or rotate command with the
  same `Idempotency-Key` answers `Idempotency-Replayed: true` **without**
  `secret_key`. If the original response was lost, rotate the secret.
</Warning>

## Process delivery safely

Delivery is at least once and unordered:

1. Deduplicate by event UUID and durably store each verified event before
   returning `2xx`. Acknowledge duplicates without applying them again.
2. 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.
3. 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.
4. Tolerate growth. Store and acknowledge an event whose `type` you do not
   recognise, then skip it; never fail the delivery. Ignore `data` members you
   do not know.

The HTTP transport has 5 seconds to connect and 10 seconds in total.
Synchronous DNS resolution and, for Partner webhooks, Partner authorization
happen before and outside that transport budget. Timeouts, connection errors,
transient TLS errors, DNS resolution failures, `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](/api-docs/case-management-api/reference/events/list-events)
or [Partner feed](/api-docs/partner-api/reference/events/list-events) to catch
up after missed webhooks. Events remain available for 30 days and appear
about 60 seconds after paywise records them. Each page returns `cursor`, 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.

| Situation | What to do |
| - | - |
| Your endpoint was unreachable or answered `429`/`5xx`, and is back within 48 hours | Nothing. paywise keeps retrying until 48 hours after the event and the deliveries arrive. |
| Your endpoint was down longer, or answered a terminal status (a redirect or a `4xx` other than `429`) | Fix the receiver, then read the feed from your last stored `cursor`. To re-send one specific delivery, call `redeliver` on it. |
| Your endpoint shows `auto_disabled: true` | Fix the destination, `PATCH {"enabled": true}`, then read the feed from your last stored `cursor`. Events raised while the endpoint was disabled are not re-sent. |
| paywise had a delivery outage | Deliveries queued during a short outage go out once it ends, within their 48-hour window. After a longer one, read the feed from your last stored `cursor`. |

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

If `events` 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](/api-docs/essentials/limits#api-rate-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](/api-docs/partner-api/concepts/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](/api-docs/mahnservice-api/concepts/invoice-lifecycle#webhook-events).

## 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](/api-docs/partner-api/reference/webhook-deliveries/list-webhook-deliveries).

| Symptom | Check and fix |
| - | - |
| No event in the delivery log | Check that the subscription is enabled, belongs to the environment where you triggered the action, and includes the event type. Check your API's catalog for the action that emits it. For company-selection or access issues, see [Partner webhook troubleshooting](/api-docs/partner-api/concepts/webhook-ownership#diagnose-missing-company-events). |
| Failed attempts, but nothing reaches your receiver | Use a public HTTPS URL that answers directly without redirects. Check DNS, timeouts, and private-address resolution. [Test a sandbox delivery](#test-a-sandbox-delivery) with a unique webhook.site URL to confirm that paywise is sending. A local handler needs a public HTTPS tunnel. |
| Signature mismatch | Verify the exact raw request bytes and canonical headers using the [verification recipe](#verify-signatures) and [known-answer vector](#verify-with-a-known-answer). Use the `secret_key` captured from create or rotate; list, update, and idempotent replay responses do not reveal it. During the 24 hours after a rotation the header carries two space-separated signatures; split it and accept any match. |
| Duplicate deliveries | Retries and manual replay can deliver the same event again. Deduplicate by event UUID, acknowledge after durable storage, and do not repeat its effects. |
| Subscription is disabled | Check `auto_disabled`, `consecutive_failures`, and `last_failure_at`. Fix the destination, then `PATCH {"enabled": true}` to re-enable it and reset the failure counter. |
| A failed event is no longer retried | Redirects, `4xx` responses other than `429`, TLS certificate errors, and non-public destinations are terminal; timeouts, connection and DNS errors, transient TLS errors, `429`, and `5xx` are retried for no more than 48 hours. Fix the receiver and replay the failed delivery, or catch up through the [event feed](#event-feed). |
| Events arrive out of order | Refetch the resource named by the payload and use its current state. Persist events you cannot process yet and retry processing independently. Replay does not guarantee arrival order. |
| Sandbox and production events are mixed up | Create the subscription with the host and key for the environment you want, and verify its destination URL. The two environments have separate subscriptions and events. Use synthetic sandbox data with a disposable test receiver; keep production subscriptions pointed at your own receiver. |

### Contact support

If these checks do not resolve the problem, contact
[info@paywise.de](mailto:info@paywise.de) with the subscription ID, the event
UUID if available, and the `X-Paywise-Request-Id` of the API call that should
have produced the event. Do not include signing secrets or complete customer
payloads.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.