Payments
A payment is the unit of orchestration: one object, one status, and as many provider attempts as the routing rules and the failure categories allow. This page covers its lifecycle, the operations on it, and the two things every integration gets wrong — idempotency and money representation.
Lifecycle
The status machine is enforced in the API, not in the client. An invalid move returns 409 invalid_state_transition rather than silently doing nothing, and the guard is applied at the database level so two concurrent writers can never produce an invalid path.
| Status | Meaning | Can move to |
|---|---|---|
| created | The payment object exists; risk and routing have not produced an attempt yet. | processing, pending, failed, cancelled |
| pending | Waiting on something outside NATIO: a customer action, or a manual risk review. | processing, failed, cancelled |
| processing | An attempt is in flight at a provider, or the provider outcome is not yet known. | authorized, successful, failed, cancelled, pending |
| authorized | Funds are authorised and held. Capture within the provider window or cancel. | successful, cancelled, failed |
| captured | A capture was accepted by the provider and is being finalised. | successful |
| successful | The payment completed. Refunds can be created from here. | partially_refunded, refunded |
| partially_refunded | At least one refund settled but the full amount has not been returned. | partially_refunded, refunded |
| refunded | The full captured amount was refunded. | — |
| failed | No eligible provider completed the payment. Read failure.code and failure.category. | — |
| cancelled | The payment was cancelled before completion. | — |
successful after a provider failed: that failure is an attempt outcome, not the payment outcome. Read status for the decision you show the customer, and attempts[] for what happened on the way there.Create a payment
POST /v1/payments runs risk evaluation, routing and the provider calls synchronously. The response is 201 Created with the final status for synchronous rails, or processing with a next_action for rails that need the customer.
curl https://api.natio.me/v1/payments \
-H "Authorization: Bearer natio_sk_test_..." \
-H "Idempotency-Key: order-1001" \
-H "Content-Type: application/json" \
-d '{
"amount": 10000,
"currency": "USD",
"payment_method": "card",
"capture_method": "automatic",
"country": "US",
"reference": "ORD-1001",
"description": "Order 1001",
"customer": { "external_id": "cust_42", "email": "buyer@example.com" },
"return_url": "https://merchant.example/return",
"metadata": { "cart_id": "c_991" }
}'| Field | Required | Notes |
|---|---|---|
amount | yes | Positive integer in the minor unit of the currency. |
currency | yes | Three-letter ISO 4217 code from the supported list. |
payment_method | yes | A type string (card, bank_transfer, qr, open_banking, wallet, instant, local), or an object { type, token?, id? } referencing a stored provider token. |
capture_method | no | automatic (default) or manual. |
country | no | Two-letter country code. Used by routing rules and by provider eligibility. |
customer | no | id, external_id, email, name, country. Used for risk signals and for support lookups. |
reference | no | Your order identifier. Searchable and carried onto the ledger. |
description | no | Free text, up to 500 characters. |
return_url | no | Where the customer returns after a redirect or QR action. |
metadata | no | Up to 50 keys of string, number, boolean or null. Returned on the payment and on webhook payloads. |
test_scenario | no | Test keys only. See Sandbox. |
device | no | ip, user_agent, fingerprint. Risk signals from the customer session. |
POST /v1/payment-methods. Sensitive entry belongs on a provider-hosted page or a PCI-compliant tokenisation service; NATIO receives the type or the token reference only.Capture method
| capture_method | What happens | When to use it |
|---|---|---|
automatic | Authorisation and capture in one provider call. A successful payment lands directly on successful with captured_amount equal to amount. | Goods or services delivered immediately: digital products, subscriptions, top-ups. |
manual | Funds are authorised and held. The payment stops at authorized with captured_amount: 0 until you capture or cancel. | Anything you confirm before charging: stock checks, shipping, fraud review, bookings. |
# capture_method: "manual" holds the funds instead of taking them
{
"id": "pay_Jj6lYt0liW18i9K5LM7g",
"status": "authorized",
"amount": 10000,
"captured_amount": 0,
"capture_method": "manual",
"route": {
"provider": { "id": "prv_mKJKu0bhvoQO3EMPKTzt", "code": "demo_acquirer_a", "name": "NATIO Demo Acquirer A" },
"provider_account_id": "pa_lj6mWPXP7g7EAFN9QlC9",
"provider_account_name": "Acquirer A · Test",
"provider_payment_id": "mp_mock_acquirer_bfpwNJ4L19U3HQ",
"attempts": 1,
"routing_decision_id": "rd_RFkIqEV0vx61j5jicY0B",
"rule": "Cards → Acquirer A, fallback Acquirer B"
}
}Capture
POST /v1/payments/{id}/capture takes an optional amount; omit it to capture the full authorised amount. Partial capture is supported where the provider supports it.
curl -X POST https://api.natio.me/v1/payments/pay_Jj6lYt0liW18i9K5LM7g/capture \
-H "Authorization: Bearer natio_sk_test_..." \
-H "Idempotency-Key: capture-1001" \
-H "Content-Type: application/json" \
-d '{ "amount": 10000 }'
# 200 OK
{
"id": "pay_Jj6lYt0liW18i9K5LM7g",
"status": "successful",
"amount": 10000,
"captured_amount": 10000,
"refunded_amount": 0
}If the provider rejects the capture, a capture.failed timeline event is written and the payment stays authorized — it does not fail. Authorisations also expire at the provider; capture inside the window the provider grants, or cancel.
Cancel
POST /v1/payments/{id}/cancel releases an authorisation or abandons a payment that has not completed. The optional reason is recorded on the timeline and in the audit trail.
curl -X POST https://api.natio.me/v1/payments/pay_aAdBl7rkJnhZC8xZeIER/cancel \
-H "Authorization: Bearer natio_sk_test_..." \
-H "Content-Type: application/json" \
-d '{ "reason": "customer abandoned checkout" }'
# 200 OK
{
"id": "pay_aAdBl7rkJnhZC8xZeIER",
"status": "cancelled",
"amount": 5000,
"currency": "EUR",
"captured_amount": 0
}Refund
POST /v1/payments/{id}/refund returns money through the provider that took the payment. Omit amount for a full refund; send a smaller amount for a partial one. Refunds can be repeated until the captured amount is exhausted.
curl -X POST https://api.natio.me/v1/payments/pay_Jj6lYt0liW18i9K5LM7g/refund \
-H "Authorization: Bearer natio_sk_test_..." \
-H "Idempotency-Key: refund-1001" \
-H "Content-Type: application/json" \
-d '{ "amount": 2500, "reason": "customer_request" }'
# 201 Created
{
"id": "rf_MvEcSaFW6vw9YX76swGu",
"object": "refund",
"mode": "test",
"payment_id": "pay_Jj6lYt0liW18i9K5LM7g",
"amount": 2500,
"currency": "USD",
"status": "successful",
"reason": "customer_request",
"provider_account_id": "pa_lj6mWPXP7g7EAFN9QlC9",
"provider_refund_id": "mrf_mock_acquirer_PNORRwIkWWLQbV",
"failure": null,
"metadata": {},
"created_at": "2026-09-22T02:19:05.459Z",
"updated_at": "2026-09-22T02:19:05.521Z"
}A settled refund moves the payment to partially_refunded or refunded and increases refunded_amount. A rejected refund leaves the payment amounts untouched and carries its own failure object. Read refunds back with GET /v1/payments/{id}/refunds or GET /v1/refunds/{id}.
next_action: redirect and QR rails
When the provider needs the customer, the create call returns processing and a next_action object. Your code hands that to the customer and then waits for the webhook; it does not poll and it does not decide the outcome itself.
{
"id": "pay_9yLEFy41uUGI0CMhbswo",
"status": "processing",
"next_action": {
"type": "redirect",
"url": "https://api.natio.me/sandbox/hosted/pa_.../mp_mock_acquirer_tTa8hRyaVFXNdq",
"expiresAt": "2026-09-22T02:47:58.149Z"
}
}| next_action.type | What you do |
|---|---|
redirect | Send the browser to url. The customer returns to your return_url when the provider is done. |
qr_code | Render qrPayload as a QR image for the customer to scan. url is the provider page that completes it. |
display_details | Show the instructions the provider returned, for rails where the customer pushes money themselves. |
GET /v1/payments/{id}. Treat the return as a navigation hint and re-read the payment.next_action.expiresAt is when the action stops being completable. After that the provider expires the payment, which surfaces as a failure with code payment_expired.
The route and attempts objects
route is the summary of where the payment ended up; attempts[] is the full history of how it got there. Both are returned inline on every payment, so support questions rarely need a second call.
"route": {
"provider": { "id": "prv_v96z...", "code": "demo_acquirer_b", "name": "NATIO Demo Acquirer B" },
"provider_account_id": "pa_cePo211O81vjjA3TKEFR",
"provider_account_name": "Acquirer B · Test",
"provider_payment_id": "mp_mock_acquirer_6o3bRyJA3t84f8",
"attempts": 2,
"routing_decision_id": "rd_Z16Ekb6EV4vQ1rpFNDjl",
"rule": "Cards → Acquirer A, fallback Acquirer B"
},
"attempts": [
{
"attempt_number": 1,
"status": "failed",
"outcome": "technical_error",
"provider_name": "NATIO Demo Acquirer A",
"provider_code": "GW-500",
"provider_message": "Internal gateway error",
"failure": { "code": "technical_error", "category": "technical", "message": "The provider returned a technical error" },
"fee_amount": 0,
"latency_ms": 46
},
{
"attempt_number": 2,
"status": "succeeded",
"outcome": "success",
"provider_name": "NATIO Demo Acquirer B",
"provider_payment_id": "mp_mock_acquirer_6o3bRyJA3t84f8",
"failure": null,
"fee_amount": 320,
"latency_ms": 82
}
]| route field | Meaning |
|---|---|
provider | The provider that processed the payment: id, code and display name. Null while no attempt has succeeded. |
provider_account_id | The specific provider account (credentials, fees, limits) that was used. |
provider_payment_id | The provider side identifier. Quote this in a provider support ticket. |
attempts | How many provider calls the payment took. Greater than 1 means a cascade occurred. |
routing_decision_id | The recorded routing decision, including every candidate and its score. |
rule | The routing rule that matched, or null when the default scoring strategy was used. |
| attempt field | Meaning |
|---|---|
attempt_number | 1-based position in the cascade for this payment. |
status | created · processing · unknown · authorized · succeeded · failed · cancelled |
outcome | success · requires_action · soft_decline · hard_decline · technical_error · timeout · provider_unavailable · unknown |
provider_code | The raw code the provider returned, unmapped. Useful when talking to the provider. |
provider_message | The provider message, unmapped. Do not show it to customers. |
failure | The normalised failure object: code, category and message. This is what you branch on. |
fee_amount | Fee charged by that provider account, in minor units. Zero on attempts that did not move money. |
latency_ms | Provider round-trip for that attempt. |
Whether a failed attempt is followed by another one is decided by the failure category: soft declines and technical failures may cascade to the next eligible provider, hard declines never do. The full dictionary is in Errors and failure codes.
Timeline
GET /v1/payments/{id}/timeline returns every decision and provider interaction in order. It is the same data the dashboard renders, and it is the fastest answer to “why did this payment go there”.
curl https://api.natio.me/v1/payments/pay_lfsWb45Pf5wJ1ZGgROzH/timeline \
-H "Authorization: Bearer natio_sk_test_..."
{
"payment_id": "pay_lfsWb45Pf5wJ1ZGgROzH",
"data": [
{
"id": "pev_M2XNd2ZI3qslCEU3vP8H",
"type": "failover.initiated",
"title": "Fallback initiated",
"description": "technical error is retryable → next provider NATIO Demo Acquirer B",
"attempt_id": "att_cAuoJEW1DCRqt3mth0OJ",
"data": { "reason": "technical_error_is_retryable", "next_provider_account_id": "pa_cePo211O81vjjA3TKEFR" },
"created_at": "2026-09-22T02:10:34.047Z"
}
]
}Each event has a stable type, a human title and description, an optional attempt_id tying it to one provider call, and a data object whose shape depends on the type. Branch on type; treat title and description as display text.
| type | What it records |
|---|---|
payment.created | The payment was accepted. Carries amount, currency, method and reference. |
risk.evaluated | The risk engine returned ALLOW, REVIEW or BLOCK, with the score and the rules that matched. |
risk.review | The payment was parked for manual review; it waits in pending until an operator decides. |
risk.reviewed | An operator approved or rejected a payment that was held for review. |
routing.evaluated | The routing decision: strategy, the rule that matched and every candidate provider account with its score. |
provider.selected | A provider account was chosen for this attempt, with the attempt number and the fee terms applied. |
provider.request_sent | The request left NATIO for the provider adapter. In test mode it also names the simulated primitive. |
provider.declined | The provider declined the attempt. Carries the normalised failure code and the raw provider code. |
provider.error | The provider returned a technical error rather than a decision. |
provider.unavailable | The provider could not be reached at all (for example HTTP 503). |
provider.timeout | The provider did not answer inside the adapter timeout; the outcome is unknown at this point. |
provider.lookup | NATIO queried the provider after an unknown outcome and recorded what actually happened. |
provider.webhook | An asynchronous notification arrived from the provider and moved the payment forward. |
failover.initiated | The failure was retryable, so the next eligible provider account was selected. |
retry.stopped | No further attempt will be made: the failure was final or no candidate was left. |
payment.requires_action | The provider needs the customer to act; next_action on the payment carries the redirect URL or QR payload. |
payment.authorized | The attempt authorised funds for later capture. |
payment.successful | The attempt succeeded. Carries provider payment id, fee and latency. |
payment.failed | The payment ended as failed, with the failure code that closed it. |
payment.captured | A capture was accepted by the provider. |
capture.failed | A capture request was rejected by the provider; the payment stays authorized. |
payment.cancelled | The payment was cancelled, with the reason supplied by the caller. |
refund.created | A refund was requested against this payment. |
refund.successful | The refund was accepted by the provider and the refunded amount was updated. |
refund.failed | The refund was rejected by the provider. |
sync.scheduled | The provider outcome stayed unknown; a background reconciliation of that attempt was scheduled. |
sync.resolved | The scheduled sync resolved the unknown attempt one way or the other. |
webhook.queued | A merchant event was queued for delivery, with the event type and how many endpoints subscribe to it. |
Idempotency
Every mutating endpoint accepts an Idempotency-Key header, up to 255 characters. Use one per logical operation — your order id for the payment, your refund id for the refund — and reuse it on every retry of that operation, including retries after a timeout or a 429.
| Situation | Result |
|---|---|
| Same key, same body | The stored response is replayed verbatim with the original status code and the header Idempotent-Replayed: true. The handler does not run again. |
| Same key, different body | 422 idempotency_key_reused. Nothing is created. |
| Same key, first call still in flight | 409 idempotency_in_progress. Retry shortly; the stored response will be replayed once the first call finishes. |
| No key at all | The request executes normally. A retry creates a second payment — which is exactly the failure mode the header exists to prevent. |
# First call
POST /v1/payments Idempotency-Key: order-1001
→ 201 Created { "id": "pay_lfsWb45Pf5wJ1ZGgROzH", ... }
# Same key, byte-equivalent body → the stored response, not a second payment
POST /v1/payments Idempotency-Key: order-1001
→ 201 Created
idempotent-replayed: true
{ "id": "pay_lfsWb45Pf5wJ1ZGgROzH", ... }
# Same key, different body
POST /v1/payments Idempotency-Key: order-1001 { "amount": 20000, ... }
→ 422 Unprocessable Entity
{
"error": {
"type": "idempotency_error",
"code": "idempotency_key_reused",
"message": "Idempotency-Key was already used with a different request payload"
},
"request_id": "req_eAHZ5GTlg7cHsoER"
}
# Same key while the first call is still running
→ 409 Conflict
{
"error": {
"type": "idempotency_error",
"code": "idempotency_in_progress",
"message": "A request with this Idempotency-Key is still being processed"
},
"request_id": "req_..."
}Bodies are compared by a stable hash that ignores key order, so re-serialising the same object is safe. Keys are scoped per merchant, per mode and per operation, so the same key can be used for a payment and for a capture without colliding. Keys expire after a retention window; a replay is only guaranteed while the record is retained.
Money representation
All amounts — amount, captured_amount, refunded_amount, fee.amount, ledger and settlement amounts — are integers in the minor unit of the currency. There are no decimals anywhere in the API, which removes rounding and float ambiguity from the contract.
| Decimals | Currencies | Example |
|---|---|---|
0 | JPY, KRW, VND, CLP, ISK, UGX, XAF, XOF | amount 10000 with currency JPY is 10000 JPY |
2 | every other supported currency, including USD, EUR, GBP | amount 10000 with currency USD is 100.00 USD |
3 | BHD, KWD, OMR, JOD, TND | amount 10000 with currency KWD is 10.000 KWD |
10000 is 100.00 USD but 10000 JPY — a hundredfold difference. Convert from your own representation using the exponent of the currency, never a fixed multiplication by 100.Currency is a three-letter ISO 4217 code and must be one of the supported list; anything else is refused at validation with 422 validation_failed before a provider is ever contacted.
Reading payments back
| Call | Returns |
|---|---|
GET /v1/payments/{id} | The payment with its route, risk, failure, fee and full attempts array. |
GET /v1/payments | A cursor-paginated list. Filter by status, currency, country, payment_method, reference, search, from and to. |
GET /v1/payments/{id}/timeline | Every decision and provider interaction, in order. |
GET /v1/payments/{id}/refunds | Every refund created against the payment. |
GET /v1/transactions | The ledger lines the payment produced, with the provider reference used in reconciliation. |
Lists are cursor based: send limit (1–200, default 50) and pass next_cursor from the previous page back as cursor while has_more is true. Every parameter is documented in the API reference.