Register one group webhook endpoint
Register the endpoint before you take the first payment. An event raised while
no endpoint of yours is subscribed to it reaches the retained NO_SUBSCRIBER
state instead of your server, and enabling an endpoint afterwards delivers
future events only.
Create a channel endpoint with POST /api/channel/webhook-endpoints. One
endpoint and one signing secret receive events for every merchant already in
the channel and every merchant provisioned later. Store the plaintext secret
from the create response; list operations return only a hint.
curl --request POST \ --url https://staging.api.maxanapay.com/api/channel/webhook-endpoints \ --header 'Authorization: Bearer sk_test_replace_me' \ --header 'Content-Type: application/json' \ --data @create-webhook-endpoint.request.json{ "url": "https://example.com/maxana/webhooks", "description": "Every merchant in the channel", "event_types": [ "merchant.onboarding_updated", "merchant.payments_disabled", "payment.approved", "payment.captured", "payment.failed", "payment.refunded" ]}{ "id": "f0a6c1d2-8e37-4b95-a2c4-71d6e0538bca", "url": "https://example.com/maxana/webhooks", "description": "Every merchant in the channel", "enabled": true, "status": "active", "secret": "whsec_replace_me", "secret_hint": "whsec_..._me", "event_types": [ "merchant.onboarding_updated", "merchant.payments_disabled", "payment.approved", "payment.captured", "payment.failed", "payment.refunded" ], "created_at": "2026-01-15T09:10:00Z", "updated_at": "2026-01-15T09:10:00Z"}The URL is validated when you send it
Section titled “The URL is validated when you send it”url is checked at registration, not at the first delivery, and the block above
only works because it satisfies every rule. It has to be https, on the default
port, without credentials in it, and its hostname has to resolve in DNS to an
address on the public internet.
| What you send | What comes back |
|---|---|
http://, or a port other than 443 |
422, naming url |
| a hostname DNS does not answer for | 424 and Webhook endpoint hostname could not be resolved by DNS |
| a hostname resolving to a private or reserved address | 400 and resolves to a private or reserved IP address |
The 424 is the one that catches people, because it is the only refusal here
that might be temporary and it reads as though it always is: the message says to
retry later, and it is the same message whether the name does not exist or your
own resolver blinked. Resolve the hostname yourself before deciding which you
have — getent hosts your.receiver.example or dig +short your.receiver.example.
An address back means the name is fine and the call is worth retrying; nothing
back means it does not exist, and no number of retries will store it. The usual
cause is a subdomain nobody has created yet: a parent domain resolving is no
evidence that a name under it does.
Nothing beyond resolution is checked. Your receiver does not have to be running, and Maxana does not call it to find out — an endpoint whose handler you have not deployed registers, and its deliveries fail afterwards on their own terms.
secret is in that response and in no other. Every later read returns
secret_hint instead, so an endpoint whose secret you did not store has to be
rotated rather than recovered.
Only url is required. Omitting event_types does not subscribe you to
nothing — it subscribes you to all 18 event
types in the event vocabulary, which is what
the operation reference’s own minimal example does. Send the list you want, as
the example above does, unless you mean to receive everything.
Deduplicate by Maxana-Event-Id, and follow the shared webhook payload and
delivery reference for signature verification, acknowledgement,
event types, and retries.
Dispute lifecycle events include both Maxana’s dispute UUID in id and the
provider’s identifier in provider_dispute_id. The deadline-warning event is
the exception: dispute.evidence_deadline_approaching calls the provider value
dispute_id and omits Maxana’s UUID. Correlate it by matching dispute_id to a
stored lifecycle event’s provider_dispute_id, not its id. See the shared
dispute payload field reference for the complete
dispute.created, dispute.updated, dispute.resolved, and deadline-warning
shapes.
An endpoint subscription is a snapshot. When Maxana adds an event type, update
the endpoint with the complete list you want; event_types replaces rather
than merges. Rotate a secret as a hard cutover.
What a delivery carries
Section titled “What a delivery carries”Every delivery uses the shared event envelope, and the worked example shows one in full. Two identifiers inside it turn a delivery into one of your merchants’ orders.
Your merchant
Section titled “Your merchant”data.object.merchant_id is the Maxana merchant id you received when you
provisioned that merchant. It is on every event of every type, including the
ones that describe no order at all, and it is the only attribution key a group
endpoint needs. Maxana fills it in from the merchant the event was raised for
and rejects any payload naming a different one, so it cannot disagree with the
merchant whose activity produced the event.
Your order
Section titled “Your order”data.object.external_order_id is the identifier you set when you created the
checkout session. data.object.checkout_session_id is Maxana’s identifier for
that same session, and the one the rest of the API speaks.
Both keys are present on every event that describes an order, carrying null
where they do not apply: a payment taken without a checkout session has no
session, and a session created without an external_order_id has no external
id. A null is not an absent key, so you never have to tell “this payment
carries no order id” apart from “this field is not being sent yet”.
| Events | Order identifiers |
|---|---|
checkout.session.created, payment.created, payment.approved, payment.captured, payment.failed, payment.refunded |
external_order_id and checkout_session_id |
dispute.created, dispute.updated, dispute.resolved, dispute.evidence_deadline_approaching |
external_order_id and checkout_session_id, naming the disputed order |
text_to_pay.paid |
external_order_id, taken from the payment request, not from a session |
subscription.created, subscription.charged, subscription.payment_failed, subscription.past_due, subscription.canceled |
None. Match on subscription_id. |
merchant.onboarding_updated, merchant.payments_disabled |
None. They report a merchant, not an order. |
One event, two deliveries
Section titled “One event, two deliveries”A merchant in your channel can also own an endpoint of their own. Where one does, the same event reaches both endpoints, each signed with its own secret. Yours is an independent delivery rather than a copy of theirs, and each is acknowledged on its own.
Ten endpoints, and how to move one
Section titled “Ten endpoints, and how to move one”A channel owns at most ten webhook endpoints. Creating an eleventh returns
400 with A channel can configure at most 10 webhook endpoints.
Disabled endpoints count toward that ten. DELETE /api/channel/webhook-endpoints/{id} stops delivery and keeps the row, because
its delivery history is part of the record — it is not a removal, and it does
not return the slot.
Point a disabled endpoint at the new destination instead of creating another one. That is the intended way to move a receiver, and it is why the limit does not become a dead end: a channel that has changed its receiver ten times reuses one of the ten it already owns rather than asking for an eleventh.
Moving one to a different host takes three steps, and the middle one is the
step readers miss. Disable the endpoint first — replacing the URL of an
endpoint that is still enabled is refused with Disable this webhook endpoint before replacing its URL. Then send the new URL with rotate_secret set to
true; a host change without it is refused with Replacing a webhook endpoint with a different host requires rotate_secret=true so the old receiver cannot authenticate deliveries. Store the new plaintext secret from that response.
Then enable the endpoint; delivery does not resume until you do. A URL change
within the same host needs neither the rotation nor a new secret.
Current limits
Section titled “Current limits”Channel webhooks do not create one endpoint or secret per merchant. Disabling
and later enabling an endpoint resumes future deliveries only. If an event
reached the retained NO_SUBSCRIBER state while no matching endpoint was
active, enabling does not deliver it automatically. Contact Maxana and identify
the retained event when explicit replay is required; replay is not a channel
API operation.
Continue to Create a checkout session.