Webhooks
Webhooks notify your server about asynchronous changes. A channel can create an endpoint for its merchants and receives its signing secret only in the create response. Store that secret immediately. Rotation is a hard cutover: the previous secret stops signing new deliveries as soon as rotation succeeds.
Webhook integration contract
Section titled “Webhook integration contract”Treat delivery as an authenticated, asynchronous report. Verify first, persist and deduplicate second, acknowledge promptly, and reconcile the referenced resource whenever current state matters.
The event envelope
Section titled “The event envelope”Every delivery is one JSON object with the same shape, whatever the event type.
Only data.object differs between types.
| Field | Present | Meaning |
|---|---|---|
id |
Always | This event’s own identifier, repeated in the Maxana-Event-Id header. Deduplicate on it. |
type |
Always | The event type, repeated in the Maxana-Event-Type header. One of the names in the event vocabulary. |
created |
Always | When the event was recorded, as an integer count of Unix seconds. Not an ISO 8601 timestamp. |
data.object |
Always | The resource the event reports on. Its fields depend on type. |
replay_of_event_id |
Only on a replay | The id of the event this one repeats. Its presence is the signal that you may have processed the original already. |
Two identifiers a receiver often looks for are not at the top level. Your own
order identifier is not there: it travels inside data.object, on the events
that describe an order. Neither is the merchant — data.object.merchant_id is
the attribution key, and it is carried by every event of every type.
data.object.id is the resource’s identifier, never the event’s. On
payment.captured it is the transaction; the event’s own id is the top-level
one.
A captured payment, end to end
Section titled “A captured payment, end to end”A complete delivery body for payment.captured, the event to fulfil an order
on because it is the first one that means the money has been taken. It reports
the checkout session created in Getting
started.
{
"created": 1768469400,
"data": {
"object": {
"amount_cents": 4995,
"captured_at": "2026-01-15T09:30:00+00:00",
"checkout_session_id": "cs_00000000000000000000000000000000",
"created_at": "2026-01-15T09:30:00+00:00",
"currency": "USD",
"external_order_id": "order_1001",
"id": "4923d60e-516f-4a1e-95cc-5c101e5f2fa7",
"merchant_id": "b3f1c8de-7a20-4c55-9e64-1d8f2a6b40c7",
"platform_fee_cents": 150,
"provider": "ppcp",
"provider_capture_id": "3XY45678AB901234C",
"provider_order_id": "1AB23456CD789012E",
"refunded_at": null,
"status": "captured"
}
},
"id": "2f1c0d9a-6b4e-4a1f-9c3d-0e5a7b8c9d01",
"type": "payment.captured"
}Amounts are integer minor units of currency, so 4995 is 49.95 USD.
platform_fee_cents is the Maxana fee recorded against this payment.
provider_order_id and provider_capture_id name the same money at the
payment provider, for reconciling against a provider report.
The body reaches you on a single line, with sorted keys and no whitespace between tokens; it is indented above only to be read. That is why a signature has to be computed over the bytes you received and never over anything you re-serialize.
A partial refund of that payment
Section titled “A partial refund of that payment”The same order, refunded in part, and refunded through Maxana’s own API — the
body below is that path’s, which carries the fullest shape. payment.refunded
carries every field payment.captured did, and up to seven more that describe
the refund itself. Not all seven are on every payment.refunded: which of them
you receive depends on how the refund was initiated, as the fields below say. A
refund initiated at the provider — a seller refunding from PayPal’s own
dashboard — reaches you as the same event with fewer of them, and a reversal
(chargeback) carries only the totals and a reversal flag.
{
"created": 1768469400,
"data": {
"object": {
"amount_cents": 4995,
"captured_at": "2026-01-15T09:30:00+00:00",
"checkout_session_id": "cs_00000000000000000000000000000000",
"created_at": "2026-01-15T09:30:00+00:00",
"currency": "USD",
"external_order_id": "order_1001",
"id": "4923d60e-516f-4a1e-95cc-5c101e5f2fa7",
"merchant_id": "b3f1c8de-7a20-4c55-9e64-1d8f2a6b40c7",
"partial": true,
"paypal_refund_id": "7QW89012RS345678T",
"platform_fee_cents": 150,
"platform_fee_refund_cents": 30,
"provider": "ppcp",
"provider_capture_id": "3XY45678AB901234C",
"provider_order_id": "1AB23456CD789012E",
"refund_amount_cents": 1000,
"refund_request_id": "mx-refund-b3ee15f7fcc6aec427238af4dd86c10b",
"refunded_at": null,
"status": "captured",
"total_platform_fee_refunded_cents": 30,
"total_refunded_cents": 1000
}
},
"id": "6d8b2e47-1c05-4f39-8a6b-2f9e4c7d1a53",
"type": "payment.refunded"
}Read the two totals before the two marginal amounts. refund_amount_cents and
platform_fee_refund_cents are this refund; total_refunded_cents and
total_platform_fee_refunded_cents are everything returned against the payment
so far, this refund included. On a first partial refund the pairs agree, which
is why a receiver that reads the wrong one of each survives until the second
refund and then double-counts.
The two platform-fee fields are the conditional pair: platform_fee_refund_cents
and total_platform_fee_refunded_cents appear only when Maxana recorded a fee
return for this refund. A payment.refunded that carries no fee figure — a
provider-initiated refund among them — leaves both keys out rather than sending
a zero, so read the platform fee back through a default and never as a required
key.
partial is true exactly while the refunded total is below the captured
amount.
The trap is the pair beside them: status is still captured and refunded_at
is still null. Neither is a mistake and neither is stale. A payment moves to
refunded, and stamps refunded_at, only when the refunded total reaches the
captured amount — so on every partial refund this event reports a captured,
never-refunded-at payment that has nonetheless returned money. Reconcile from
total_refunded_cents, not from status.
paypal_refund_id is the provider’s identifier for this refund, for matching a
provider report. refund_request_id is the idempotency material Maxana derived
for the provider call; two deliveries carrying the same one describe one refund,
not two. It is present only on refunds issued through Maxana’s API: a refund
initiated at the provider reaches you without it, so deduplicate on
paypal_refund_id for the refunds you did not issue.
Verify the signature
Section titled “Verify the signature”Five webhook-specific headers arrive with every delivery, alongside
Content-Type and the ordinary headers any HTTP client sends.
| Header | Value |
|---|---|
Maxana-Signature |
The timestamped signature described below. |
Maxana-Timestamp |
The same Unix seconds as the signature’s t. |
Maxana-Event-Id |
The envelope’s id. |
Maxana-Event-Type |
The envelope’s type. |
User-Agent |
Maxana-Webhooks/1.0. Never treat it as an authentication signal. |
Treat the signature and timestamp as verification inputs; use
Maxana-Event-Id for deduplication and Maxana-Event-Type only for routing
after verification.
Every delivery includes the
Maxana-Signature header with this
grammar:
t=<unix-timestamp>,v1=<hex-digest>
Read the request’s raw body bytes before JSON parsing. Compute
HMAC-SHA256 with the endpoint
secret over
<timestamp>.<raw-body>. Reject a
timestamp whose absolute difference from your current Unix time exceeds
300 seconds, then compare the
received and expected hexadecimal digests with a
timing-safe comparison. Re-serializing the
parsed JSON changes the signed bytes and invalidates verification.
Acknowledge and retry
Section titled “Acknowledge and retry”Return a 200-299 response
only after signature verification and durable receipt. A
non-2xx or delivery-errorfailure is eligible for another delivery only while the event has attempts
remaining. New events default to a maximum of
8 delivery
attempts. After that limit, the event becomes
failed and Maxana
does not schedule another attempt. Persist the event before acknowledging it,
return quickly, and move slow business work to your own queue.
Eight attempts include the first delivery, leaving seven retries. The current contract does not promise an ordering or retry interval, so do not infer an outage or a terminal failure from one delayed attempt.
Maxana waits 10 seconds for the next bytes of your response — for the first of them, and again between every later chunk. The wait restarts each time bytes arrive, so this is a limit on silence rather than a deadline for the whole reply: a response that keeps sending stays open past it, and one that starts and then goes quiet for that long is abandoned whether or not it had begun replying. An abandoned attempt counts against the limit above. Size the gap between bytes, not the total: send the status as soon as the event is durably yours and do the work afterwards.
Redirects are not followed, so a 3xx fails like any other non-2xx; move a
receiver by updating its endpoint record rather than by redirecting from the
old host.
Your response body is read and then discarded. Only the status code is kept, so nothing you put in the body is recorded or acted on. Keep it short all the same: the limit on silence above applies while that body is being received, so a reply that stalls partway through fails the attempt even though its status had already arrived.
An endpoint that has already answered with a success status for an event is never sent that event again. Where one event reaches more than one endpoint and only some of them fail, the next attempt goes to those and to no others.
Handler sequence
Section titled “Handler sequence”- Read the raw request body before parsing or transforming it.
- Treat the request as untrusted until its signature has been verified with the endpoint’s current secret.
- Deduplicate on the
Maxana-Event-Idheader before applying a business transition, and persist receipt before returning a 2xx status. - Reconcile the referenced resource through its API when business correctness depends on current state.
Build for duplicate and out-of-order arrival. Deduplicate before applying a business transition, and make every transition safe to replay. This guide does not promise an event ordering or retry interval; do not base correctness on either one.
Event vocabulary
Section titled “Event vocabulary”The authoritative outbound registry currently contains:
checkout.session.createdpayment.createdpayment.approvedpayment.capturedpayment.failedpayment.refundeddispute.createddispute.updateddispute.resolveddispute.evidence_deadline_approachingmerchant.onboarding_updatedmerchant.payments_disabledsubscription.createdsubscription.chargedsubscription.payment_failedsubscription.past_duesubscription.canceledtext_to_pay.paid
When updating a channel endpoint, send the complete event_types set you want;
the operation replaces the subscription rather than merging it.
Dispute payloads
Section titled “Dispute payloads”dispute.created, dispute.updated, and dispute.resolved share one
data.object shape:
| Field | Meaning |
|---|---|
id |
Maxana’s UUID for the dispute. Use it with Maxana dispute API routes. |
event_type |
The emitted dispute.created, dispute.updated, or dispute.resolved name. |
merchant_id |
Maxana merchant UUID whose payment was disputed. |
transaction_id |
Maxana transaction UUID, or null when the provider payment was not matched locally. |
provider |
Payment provider for the dispute. |
provider_dispute_id |
The provider’s dispute identifier. For PayPal, this is the PayPal dispute ID. |
provider_transaction_id |
Provider transaction identifier, or null when unavailable. |
status |
Current provider dispute status. |
reason |
Provider dispute reason, or null when unavailable. |
amount |
Disputed amount as a JSON number, or null when unavailable. |
currency |
Three-letter currency code. |
response_deadline |
Provider response deadline as an ISO 8601 timestamp, or null when unavailable. |
outcome |
Provider outcome, or null before resolution. |
checkout_session_id |
Maxana checkout session ID, or null when the payment was not matched to a session. |
external_order_id |
Your checkout-session order ID, or null when none was supplied or matched. |
dispute.evidence_deadline_approaching has a smaller data.object: it carries
dispute_id, status, reason, response_deadline,
checkout_session_id, and external_order_id. Its dispute_id is the
provider’s identifier—not Maxana’s UUID—and this event does not carry the
Maxana dispute id.
To correlate a deadline warning with a stored lifecycle event, match the
warning’s data.object.dispute_id to the earlier event’s
data.object.provider_dispute_id. Do not compare it with the earlier event’s
data.object.id.
Payments-disabled reasons
Section titled “Payments-disabled reasons”merchant.payments_disabled carries a reason of
paypal_consent_revoked, paypal_status_not_ready,
merchant_disconnected, paypal_capture_payee_not_verified, or
admin_paypal_link_released. paypal_capture_payee_not_verified means PayPal
refused a capture because the seller’s account can no longer receive it, so
Maxana disabled the merchant at the moment of that refusal rather than waiting
to be told separately. The last value means that Maxana reassigned a PayPal
account from this merchant record to another while resolving an ownership
conflict. It disables this merchant in Maxana; it does not disconnect the seller
or change the account’s readiness at PayPal.
For admin_paypal_link_released, paypal_merchant_id identifies the Maxana
connection that stopped. If this merchant record still needs a PayPal account,
send the seller through a fresh onboarding flow.
Payment-failure reasons
Section titled “Payment-failure reasons”payment.failed reports the end of one payment attempt, not the end of the
checkout session. A session is only closed by a payment that was taken, so a
failed attempt leaves it open and the buyer can start another against it. Do not
release the order on this event; decide what is still owed from the session and
your own record.
Its data.object carries the transaction fields the worked
example shows, and these of its own:
| Field | Meaning |
|---|---|
reason |
Why the attempt failed, in the provider’s own words. Present on every payment.failed. |
provider_status |
The provider’s own status for the attempt, when the provider supplied one. |
debug_id |
The identifier the provider’s support asks for about this attempt, when the provider sent one. |
Read the last two as optional. A provider that does not supply one leaves the
key out rather than sending null, so a handler that reads them straight from
the body fails on the attempts the provider described least.
reason is also on the transaction the event reports, as failure_code, with
the provider’s own words beside it in failure_message. Read them back with
GET /api/transactions/{transaction_id} when a delivery was missed or arrived
before the subscriber existed: the event is a notification, and the row is what
reconciles.
Request fields are strict: an unrecognized property returns 422 Unprocessable Entity when you create or update an endpoint. Send only the documented fields
and field names.
Disable, reuse, and resume channel endpoints
Section titled “Disable, reuse, and resume channel endpoints”Reuse a disabled channel endpoint
Section titled “Reuse a disabled channel endpoint”Channel webhook endpoints use durable slots: disabling an endpoint preserves its delivery history while making the slot available for a replacement receiver. Disable the endpoint, PATCH the replacement, install any returned secret, and then enable it.
For a same-host change, PATCH keeps the endpoint ID and signing secret. A
different host requires rotate_secret: true in the PATCH body; this is an
explicit hard cutover, and the response returns the replacement secret exactly
once. Store it before enabling the endpoint at the new receiver. PATCH never
rotates a secret implicitly.
Send the complete event_types list when you update it: the list replaces the
subscription rather than merging with it. If the endpoint URL changed before
the PATCH serializes, the API returns 409 Conflict; read the latest endpoint
state and retry from that state. A successful enable resumes only future
delivery. This does not retry events that reached the terminal no_subscriber
state while the endpoint was disabled. Contact Maxana with the retained event
identifier when recovery is required; explicit replay is an operator action,
not a channel API operation.
Current limits
Section titled “Current limits”Webhook delivery is not globally ordered and can be duplicated. Enabling a
channel endpoint resumes future delivery only; it cannot replay retained
no_subscriber events through the channel API. Contact Maxana with the retained
event identifier when explicit operator replay is required.