Payment Webhooks
Payments change state asynchronously: a 3-D Secure challenge completes minutes after the redirect, a capture settles, an authorization expires overnight, a chargeback arrives weeks later. Webhooks push every one of these lifecycle changes to your server so you never have to poll GET /payments/:id in a loop — and so your order state can never silently drift from the payment state.
This page covers which payment events exist and when they fire. Endpoint configuration, signature verification and the generic delivery contract are shared across all products — see Webhook setup.
Payment events
Fifteen event types cover the full payment lifecycle; the nine payment link lifecycle events are listed separately below, and the promissory-note events separately:
| Event | Fired when |
|---|---|
payment.authorized | An authorize-only hold succeeds: funds are held on the card, nothing is captured yet (AUTHORIZED). A hold never emits payment.captured — wait for the capture before you fulfil. |
payment.captured | The money is yours — fulfil on this event. It fires on every rail where funds actually move: an immediate sale (card, 3-D or wallet), or when an explicit capture brings the cumulative captured total to the full authorized amount (CAPTURED). Carries the amount actually captured (capturedAmountMinor), and the ledger entry is built from this event. In a split payment the main payment's result also arrives with this event, carrying settlementMode: "SPLIT" and legs[]. |
payment.partially_captured | A partial capture completes (PARTIALLY_CAPTURED). Carries capturedThisCaptureMinor, cumulativeCapturedMinor, remainingCapturableMinor. |
payment.failed | The payment fails and no funds are collected (FAILED). Carries amountMinor plus decline detail when available (normalizedErrorCode, retryability, failureSource). In a split payment the main payment's failed result also arrives with this event (settlementMode: "SPLIT"). |
payment.refunded | A refund brings the payment to fully refunded (REFUNDED). Carries refundId, refundAmountMinor, totalRefundedMinor, fullyRefunded. |
payment.partially_refunded | A partial refund completes (PARTIALLY_REFUNDED). Same payload fields as payment.refunded. |
payment.voided | An uncaptured authorization is released (VOIDED) |
payment.expired | An uncaptured authorization went 7 days without a capture and was closed as EXPIRED (terminal) on the platform. The hold on the card is not released by this event — no cancellation is sent to the bank; the hold drops on the bank's own schedule. To release a hold early, call void (which emits payment.voided). |
payment.split.completed | Split payment: every part captured (COMPLETED). Carries splitPaymentId and part summaries. A payment.captured for the main payment arrives at the same time; close the order once, on that event. The parts' own events are sent only when the payment.split.leg_events option is on → Part events |
payment.split.voided | The split payment's compensation finished: a part failed, holds were released and charged parts refunded — the customer paid net zero (VOIDED) |
payment.split.compensating | The split payment entered operations follow-up (COMPENSATING + reconciliation): cause is CAPTURE_FAILURE or COMPENSATION_INCOMPLETE; fires once per split payment |
payment.split.outcome_pending | In a split payment the result of the non-reversible part is awaited; card holds are kept until the result is final. Carries pendingLegId and pendingLegSequence |
payment.split.reconciliation_required | In a split payment the non-reversible part was charged but the rest of the order could not be collected; you decide about shipping. Carries shortfallAmountMinor, reason and, when present, unsettledLegId |
payment.split.leg_refund_pending | A compensation refund was requested for a charged part of a split payment; it is retried until it succeeds. This is a part event: sent only when the payment.split.leg_events option is on |
payment.disputed | A chargeback is opened or moves phase — carries chargebackId, disputeState, disputedAmountMinor, openChargebackCount; see Chargebacks |
A single payment typically produces several events over its life — for example payment.authorized → payment.captured → months later payment.disputed.
Fulfil on payment.captured. It is the single signal that funds actually moved — on the card, 3-D and wallet rails alike.
payment.succeeded has been removed and is no longer emitted. It described the same payment as payment.captured, with the same intentId and orderId; integrations subscribed to both received two webhooks for one sale, and fulfilled twice if they keyed on orderId. If your code branches on type === 'payment.succeeded', move it to payment.captured — the amount field is capturedAmountMinor, not amountMinor.
Payment link events
Payment Links produce nine lifecycle events: payment.link.created, payment.link.updated, payment.link.paused, payment.link.resumed, payment.link.deactivated, payment.link.expired, payment.link.completed, payment.link.taken_down, payment.link.takedown_reverted.
Payments taken through a link produce the payment events above; source: "PAYMENT_LINK", paymentLinkId, paymentLinkCode and paymentLinkAttemptId are added to their data. Still fulfil the order on payment.captured — payment.link.completed is not a second money signal. Payload and fields: Payment Link Webhooks.
Promissory-note events
A payment taken with Promissory Notes produces payment.captured when the notes are issued; the money is collected at maturity, directly from the debtor into your provider account. The outcome of each note at maturity is a separate event:
| Event | When it fires |
|---|---|
payment.promissory_note.paid | A note was paid |
payment.promissory_note.protested | A note was not paid and was protested |
payment.promissory_note.cancelled | A note was cancelled |
These events are sent once per note and status, do not change the payment's status (status stays CAPTURED) and carry the note's fields in data.promissoryNote: serialNumber, sequence, amountMinor, dueDate, status, occurredAt. Note statuses are read from the provider at regular intervals; the events are not real time. In the panel these events are in the Senetle Ödeme (Promissory Notes) group. Payload and read frequency: Promissory Notes — Webhooks.
Delivery payload
Every delivery is an HTTP POST to your endpoint with a JSON body of this shape:
{
"id": "6a1f0c9e-2b3d-5e4f-8a7b-1c2d3e4f5a6b",
"type": "payment.captured",
"sourceService": "payment-service",
"organizationId": "org_…",
"productCode": "PAYMENT_GATEWAY",
"environment": "LIVE",
"eventVersion": 1,
"schemaVersion": "v1",
"createdAt": "2026-07-06T12:00:02.000Z",
"data": {
"intentId": "9c6f2b1a-4e8d-4a3f-b5e6-2d7c1a9b8e30",
"merchantId": "m_…",
"orderId": "ORD-84722",
"currency": "TRY",
"status": "CAPTURED",
"capturedAmountMinor": "10050"
}
}data always carries intentId, merchantId, orderId, currency and status, plus the event-specific fields listed in the table above. Amount fields inside data are minor-unit strings and show the charged amount: for an installment payment they include the installment fee the system added to the amount (basket total) in your request.
In the main payment events and the payment.split.* events of a split payment, data also carries splitPaymentId. intentId is present only in the main payment events of a Checkout Form split payment, where it is the order's payment record; for a split payment started from the API the main payment's id is splitPaymentId → Split Payments › Events.
status is the status at createdAt — not "the status right now".
A failed delivery is retried up to 12 times across roughly two days, and event order is not guaranteed, so status reports the state the event itself describes. That is enough to decide fulfilment (the body is signed; process it idempotently by id). To read the payment's current state, call GET /payments/:id.
Delivery headers:
| Header | Value |
|---|---|
x-lunixi-event-id | The event id (same as body id) — dedupe key |
x-lunixi-event-type | The event type (same as body type) |
x-lunixi-signature | Comma-separated signature list: one or two v2=<hex HMAC> items (the second one only during the 24-hour secret-rotation grace window) — see below |
x-lunixi-signature-timestamp | ISO-8601 signing time |
The v2 signature is an HMAC of the raw body: HMAC-SHA256(secret, eventId + "." + timestamp + "." + rawBody) — no parsing, no re-serialisation. The header carries one or two v2= items; the second one is present only during the 24-hour secret-rotation grace window. Verification code and details: Webhook setup.
Delivery model
Deliveries follow the platform-wide webhook contract (details in Webhook setup):
- Signed — every delivery carries the
x-lunixi-signatureheader computed with your endpoint's webhook secret. Verify it before trusting the body; reject deliveries that fail verification. - Retried — if your endpoint does not answer with a
2xx, delivery is retried with backoff. Design your handler to be fast: acknowledge first, process asynchronously. - At-least-once — retries mean you may see the same event more than once. Make handlers idempotent: key your processing on the event id (or the
(paymentId, event)pair) and ignore duplicates. - Unordered — do not assume events arrive in the order they occurred. Drive your logic from the payment's current
status(confirm withGET /payments/:idif in doubt), not from event arrival order.
Handling an event
Verify the signature
Compute the expected signature with your webhook secret and compare it to the one on the delivery. Discard anything that does not match.
Acknowledge fast
Return 2xx immediately after persisting the event. Anything slow (email, fulfilment, ledger writes) belongs in a background job — a slow handler looks like a failure and triggers retries.
Process idempotently
Check whether you have already handled this event id. If yes, return 2xx and do nothing. If no, apply the state change to your order.
Webhook vs the 3-D Secure callback
If you use the 3-D Secure flow, the buyer's browser is also POSTed back to your callback URL when the challenge finishes — an HMAC-signed form POST carrying _signature and _timestamp fields.
These two channels have different jobs:
| 3-D callback (form POST) | Webhook | |
|---|---|---|
| Transport | The buyer's browser | Server-to-server from the platform |
| Purpose | Resume the buyer's session, render the result page | Commit the business outcome |
| Reliability | Best-effort — the buyer can close the tab, lose connectivity, or tamper with the redirect | Signed, retried, at-least-once |
The callback informs; the webhook is the authority. Use the callback (after verifying its HMAC signature and _timestamp) only to drive the buyer's UI. Never mark an order as paid from the callback alone — ship goods, issue credits and close orders on the payment.captured webhook, or on a server-side GET /payments/:id check.
Errors & operational notes
| Situation | What to do |
|---|---|
| Signature verification fails | Reject with a non-2xx; investigate — the delivery is not from the platform or the secret is stale. |
| Your endpoint was down | Deliveries are retried automatically; after recovery, reconcile any gaps with GET /payments. |
| Duplicate event received | Expected under at-least-once delivery — your idempotent handler should no-op. |
| Event for an unknown payment id | Log and alert; it usually means your systems and the platform disagree — reconcile via Listing Payments. |
Next steps
- Webhook setup — endpoints, secrets, signature verification in code
- Listing Payments — the pull-based complement for reconciliation
- Chargebacks — what to do after
payment.disputed - Card — 3D Secure — the flow that produces the merchant callback