Async services and webhooks

Start payments without blocking your POS and receive the full transaction lifecycle over webhook, one event at a time

A card-present payment depends on a person: someone has to tap the card, pick installments, enter a PIN. That time frequently exceeds the 15 seconds most POS architectures tolerate.

Async services solve that. Your system sends the payment intent, receives an immediate acknowledgement and moves on. Kushki ONE Connect notifies you of every state change over webhook, all the way to the acquirer’s final answer.

Requirements

How it works

1. POST /async/charge β†’ we answer TERMINAL_ACKNOWLEDGED right away
2. The terminal operates the card
3. POST to your events_webhook_url β†’ one event per state change
4. Final event β†’ APPROVAL or DECLINED

Turn on the events

Send events_webhook_url in the request body of any async operation.

{
"events_webhook_url": "https://api.yourbusiness.com/webhook/terminal-events",
"amount": {
"iva": 0,
"subtotal_iva": 0,
"subtotal_iva0": 12000,
"extra_taxes": { "airport_tax": 0, "iac": 0, "ice": 0, "travel_agency": 0 }
},
"client_transaction_id": "c5a3f3be-9d6f-4d39-8af5-58dbb589af79"
}

One envelope, two uses

There is a single event schema and it reaches you at two moments:

  • The HTTP response to your async call. Always with status: TERMINAL_ACKNOWLEDGED and an empty previous_status.
  • Every webhook delivery, carrying the later state changes up to the acquirer’s result.

That symmetry is deliberate: write one deserializer and use it for both cases.

Lifecycle states

Kushki ONE models a payment as seven states. Five come from the terminal; only APPROVAL and DECLINED come from the acquirer.

statusOriginWhat it means
TERMINAL_ACKNOWLEDGEDTerminalThe terminal received the payment intent. Always the first state
TERMINAL_CANCELEDTerminalThe cardholder canceled on the terminal, or your POS sent an abort
CARD_PRESENTEDTerminalThe cardholder operated the card. reading_type tells you how it was read
TERMINAL_REJECTEDTerminalRejected locally before reaching the acquirer: timeout, maximum retries or validation
APPROVAL_REQUESTEDTerminalThe transaction was sent to the acquirer for authorization
DECLINEDAcquirerThe acquirer declined the transaction
APPROVALAcquirerThe acquirer approved the transaction

Transitions

TERMINAL_ACKNOWLEDGED ─┬─→ CARD_PRESENTED ──┬─→ APPROVAL_REQUESTED ─┬─→ APPROVAL
β”‚ β”‚ β–² β”‚ └─→ DECLINED
β”‚ β”‚ β””β”€β”€β”€β”˜ card presented again
β”‚ └─→ TERMINAL_CANCELED
β”œβ”€β†’ TERMINAL_CANCELED
└─→ TERMINAL_REJECTED

A card rejected at the terminal can be presented again, which produces a new CARD_PRESENTED with an incremented interaction_attempt. That is why the lifecycle is a graph and not a straight line: do not assume a fixed number of events per transaction.

Event structure

{
"event_id": "c08211a1-344c-4f1c-850b-41e33fb08cca",
"previous_status": "TERMINAL_ACKNOWLEDGED",
"occurred_at": "2026-08-03T20:53:34.859Z",
"status": "CARD_PRESENTED",
"merchant_id": "20000000104598300000",
"client_transaction_id": "c5a3f3be-9d6f-4d39-8af5-58dbb589af79",
"interaction_attempt": 1,
"reading_type": "CHIP",
"terminal": {
"serialNumber": "TJ54241P20911",
"model": "P2SE-BPKT",
"wifiMac": "",
"room": "3.0.10"
},
"operation": {
"type": "charge",
"amount": {
"iva": 0.0,
"subtotalIva": 0.0,
"subtotalIva0": 12000.0,
"extraTaxes": { "airportTax": 0.0, "iac": 0.0, "ice": 0.0, "travelAgency": 0.0 }
},
"clientTransactionId": "c5a3f3be-9d6f-4d39-8af5-58dbb589af79",
"eventsWebHook": "https://api.yourbusiness.com/webhook/terminal-events",
"metadata": {
"customerEmail": "customer@example.com",
"device": "SUNMI-T2",
"reference": "ABC12345"
}
}
}
FieldPresenceDescription
event_idAlwaysUnique identifier of the event. Use it to discard duplicates
previous_statusAlwaysThe previous state. Empty string on the first event
occurred_atAlwaysDate and time in UTC, ISO 8601 with milliseconds
statusAlwaysCurrent state, one of the seven above
merchant_idAlwaysKushki merchant identifier
client_transaction_idAlwaysThe one you sent in the request. Use it to correlate
terminalAlwaysserialNumber, model, wifiMac, room
operationAlwaysA copy of the operation that produced the event
interaction_attemptConditionalFrom CARD_PRESENTED onward. Counts how many times the card was operated
reading_typeConditionalFrom CARD_PRESENTED onward: CHIP, CONTACTLESS or MAGNETIC_STRIPE
failure_reasonConditionalOnly on TERMINAL_REJECTED and DECLINED. Contains type, code and message
operation.transactionReferenceConditionalOn capture, re-authorization, post-tip and void

Delivery and retries

Your endpoint must confirm receipt with any 2xx code. Any other response is evaluated against this policy:

OutcomeBehavior
2xxDelivered successfully. No retries
Timeout, closed connection or DNS failureRetries
408, 429, 500, 502, 503, 504Retries
400, 401, 403, 404, 409, 422No retry: treated as a permanent rejection

Retries use exponential backoff with jitter, on a 2-second base:

wait = min(60s, base * 2^attempt) + random(0..base)

Delivery stops after 10 attempts or 15 minutes, whichever comes first.

Build your consumer

Because events are retried and resent from the queue, your endpoint must be idempotent.

  1. Discard duplicates by event_id. Retries and resends repeat the same identifier. Store the ones you already processed.
  2. Correlate by client_transaction_id. Every event of a transaction shares it. The serialNumber tells you which device produced it, but it does not work as a correlation key.
  3. Detect gaps with previous_status. If it does not match the last state you recorded for that transaction, an event is missing or arrived out of order.
  4. Answer fast and process later. Return 2xx right away and queue the payload in your internal system. A slow endpoint triggers retries, and retries cost you duplicates.
  5. Treat APPROVAL and DECLINED as final. No further events follow.
  • Javascript
  • Python
app.post("/webhook/terminal-events", async (req, res) => {
const e = req.body;
res.sendStatus(200); // acknowledge first
if (await alreadyProcessed(e.event_id)) return; // idempotency
await markProcessed(e.event_id);
await queue.publish({
transaction: e.client_transaction_id,
status: e.status,
previous: e.previous_status,
terminal: e.terminal.serialNumber,
reason: e.failure_reason ?? null,
});
});
@app.post("/webhook/terminal-events")
async def events(event: dict, response: Response):
response.status_code = 200 # acknowledge first
if await already_processed(event["event_id"]): # idempotency
return
await mark_processed(event["event_id"])
await queue.publish({
"transaction": event["client_transaction_id"],
"status": event["status"],
"previous": event["previous_status"],
"terminal": event["terminal"]["serialNumber"],
"reason": event.get("failure_reason"),
})
Accept payments with Kushki ONE

Review every available payment flow and its sync variants.

Error catalog

Interpret the failure_reason object inside the transaction lifecycle.