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 away2. The terminal operates the card3. POST to your events_webhook_url β one event per state change4. 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_ACKNOWLEDGEDand an emptyprevious_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.
status | Origin | What it means |
|---|---|---|
TERMINAL_ACKNOWLEDGED | Terminal | The terminal received the payment intent. Always the first state |
TERMINAL_CANCELED | Terminal | The cardholder canceled on the terminal, or your POS sent an abort |
CARD_PRESENTED | Terminal | The cardholder operated the card. reading_type tells you how it was read |
TERMINAL_REJECTED | Terminal | Rejected locally before reaching the acquirer: timeout, maximum retries or validation |
APPROVAL_REQUESTED | Terminal | The transaction was sent to the acquirer for authorization |
DECLINED | Acquirer | The acquirer declined the transaction |
APPROVAL | Acquirer | The 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"}}}
| Field | Presence | Description |
|---|---|---|
event_id | Always | Unique identifier of the event. Use it to discard duplicates |
previous_status | Always | The previous state. Empty string on the first event |
occurred_at | Always | Date and time in UTC, ISO 8601 with milliseconds |
status | Always | Current state, one of the seven above |
merchant_id | Always | Kushki merchant identifier |
client_transaction_id | Always | The one you sent in the request. Use it to correlate |
terminal | Always | serialNumber, model, wifiMac, room |
operation | Always | A copy of the operation that produced the event |
interaction_attempt | Conditional | From CARD_PRESENTED onward. Counts how many times the card was operated |
reading_type | Conditional | From CARD_PRESENTED onward: CHIP, CONTACTLESS or MAGNETIC_STRIPE |
failure_reason | Conditional | Only on TERMINAL_REJECTED and DECLINED. Contains type, code and message |
operation.transactionReference | Conditional | On 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:
| Outcome | Behavior |
|---|---|
2xx | Delivered successfully. No retries |
| Timeout, closed connection or DNS failure | Retries |
408, 429, 500, 502, 503, 504 | Retries |
400, 401, 403, 404, 409, 422 | No 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.
- Discard duplicates by
event_id. Retries and resends repeat the same identifier. Store the ones you already processed. - Correlate by
client_transaction_id. Every event of a transaction shares it. TheserialNumbertells you which device produced it, but it does not work as a correlation key. - 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. - Answer fast and process later. Return
2xxright away and queue the payload in your internal system. A slow endpoint triggers retries, and retries cost you duplicates. - Treat
APPROVALandDECLINEDas final. No further events follow.
- Javascript
- Python
app.post("/webhook/terminal-events", async (req, res) => {const e = req.body;res.sendStatus(200); // acknowledge firstif (await alreadyProcessed(e.event_id)) return; // idempotencyawait 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 firstif await already_processed(event["event_id"]): # idempotencyreturnawait 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.
Chile
Colombia
Ecuador
Mexico