Skip to content

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.

Treat delivery as an authenticated, asynchronous report. Verify first, persist and deduplicate second, acknowledge promptly, and reconcile the referenced resource whenever current state matters.

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 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.

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.

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.

Return a 200-299 response only after signature verification and durable receipt. A

non-2xx or delivery-error

failure 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.

  1. Read the raw request body before parsing or transforming it.
  2. Treat the request as untrusted until its signature has been verified with the endpoint’s current secret.
  3. Deduplicate on the Maxana-Event-Id header before applying a business transition, and persist receipt before returning a 2xx status.
  4. 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.

The authoritative outbound registry currently contains:

  • checkout.session.created
  • payment.created
  • payment.approved
  • payment.captured
  • payment.failed
  • payment.refunded
  • dispute.created
  • dispute.updated
  • dispute.resolved
  • dispute.evidence_deadline_approaching
  • merchant.onboarding_updated
  • merchant.payments_disabled
  • subscription.created
  • subscription.charged
  • subscription.payment_failed
  • subscription.past_due
  • subscription.canceled
  • text_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.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.

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.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”

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.

Flip a DISABLED endpoint back to ACTIVE. It keeps the same id, URL, signing secret, and event_types; only delivery resumes. This does not retry events enqueued while the endpoint was disabled and already preserved in the no_subscriber state. Contact Maxana if those events need to be replayed explicitly.

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.