Guides

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.

createdprocessingauthorizedsuccessfulpendingcapturedpartially_refundedfailedcancelledrefundedcapture_method = automaticcapturerefundfull refund
Solid arrows are the ordinary path. The dashed rail is the failure and cancellation path: created, pending, processing and authorized can all end as failed or cancelled. A refund for the full amount moves successful straight to refunded. failed, cancelled and refunded are terminal.
StatusMeaningCan move to
createdThe payment object exists; risk and routing have not produced an attempt yet.processing, pending, failed, cancelled
pendingWaiting on something outside NATIO: a customer action, or a manual risk review.processing, failed, cancelled
processingAn attempt is in flight at a provider, or the provider outcome is not yet known.authorized, successful, failed, cancelled, pending
authorizedFunds are authorised and held. Capture within the provider window or cancel.successful, cancelled, failed
capturedA capture was accepted by the provider and is being finalised.successful
successfulThe payment completed. Refunds can be created from here.partially_refunded, refunded
partially_refundedAt least one refund settled but the full amount has not been returned.partially_refunded, refunded
refundedThe full captured amount was refunded.
failedNo eligible provider completed the payment. Read failure.code and failure.category.
cancelledThe payment was cancelled before completion.
Status is about the payment, not the provider
A payment can be 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.

Request
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" }
  }'
FieldRequiredNotes
amountyesPositive integer in the minor unit of the currency.
currencyyesThree-letter ISO 4217 code from the supported list.
payment_methodyesA type string (card, bank_transfer, qr, open_banking, wallet, instant, local), or an object { type, token?, id? } referencing a stored provider token.
capture_methodnoautomatic (default) or manual.
countrynoTwo-letter country code. Used by routing rules and by provider eligibility.
customernoid, external_id, email, name, country. Used for risk signals and for support lookups.
referencenoYour order identifier. Searchable and carried onto the ledger.
descriptionnoFree text, up to 500 characters.
return_urlnoWhere the customer returns after a redirect or QR action.
metadatanoUp to 50 keys of string, number, boolean or null. Returned on the payment and on webhook payloads.
test_scenarionoTest keys only. See Sandbox.
devicenoip, user_agent, fingerprint. Risk signals from the customer session.
Never send card data
The request body has no field for a card number, and the API rejects anything that looks like one on 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_methodWhat happensWhen to use it
automaticAuthorisation 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.
manualFunds 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.
Manual capture — 201 Created
# 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.

Capture
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.

Cancel
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.

Partial refund
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.

201 Created — action required
{
  "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.typeWhat you do
redirectSend the browser to url. The customer returns to your return_url when the provider is done.
qr_codeRender qrPayload as a QR image for the customer to scan. url is the provider page that completes it.
display_detailsShow the instructions the provider returned, for rails where the customer pushes money themselves.
The return URL is not the outcome
A customer can close the tab, lose connectivity or come back on a different device. The authoritative transitions are the webhook events and 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.

Excerpt from a payment that failed over
"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 fieldMeaning
providerThe provider that processed the payment: id, code and display name. Null while no attempt has succeeded.
provider_account_idThe specific provider account (credentials, fees, limits) that was used.
provider_payment_idThe provider side identifier. Quote this in a provider support ticket.
attemptsHow many provider calls the payment took. Greater than 1 means a cascade occurred.
routing_decision_idThe recorded routing decision, including every candidate and its score.
ruleThe routing rule that matched, or null when the default scoring strategy was used.
attempt fieldMeaning
attempt_number1-based position in the cascade for this payment.
statuscreated · processing · unknown · authorized · succeeded · failed · cancelled
outcomesuccess · requires_action · soft_decline · hard_decline · technical_error · timeout · provider_unavailable · unknown
provider_codeThe raw code the provider returned, unmapped. Useful when talking to the provider.
provider_messageThe provider message, unmapped. Do not show it to customers.
failureThe normalised failure object: code, category and message. This is what you branch on.
fee_amountFee charged by that provider account, in minor units. Zero on attempts that did not move money.
latency_msProvider 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”.

Timeline event
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.

typeWhat it records
payment.createdThe payment was accepted. Carries amount, currency, method and reference.
risk.evaluatedThe risk engine returned ALLOW, REVIEW or BLOCK, with the score and the rules that matched.
risk.reviewThe payment was parked for manual review; it waits in pending until an operator decides.
risk.reviewedAn operator approved or rejected a payment that was held for review.
routing.evaluatedThe routing decision: strategy, the rule that matched and every candidate provider account with its score.
provider.selectedA provider account was chosen for this attempt, with the attempt number and the fee terms applied.
provider.request_sentThe request left NATIO for the provider adapter. In test mode it also names the simulated primitive.
provider.declinedThe provider declined the attempt. Carries the normalised failure code and the raw provider code.
provider.errorThe provider returned a technical error rather than a decision.
provider.unavailableThe provider could not be reached at all (for example HTTP 503).
provider.timeoutThe provider did not answer inside the adapter timeout; the outcome is unknown at this point.
provider.lookupNATIO queried the provider after an unknown outcome and recorded what actually happened.
provider.webhookAn asynchronous notification arrived from the provider and moved the payment forward.
failover.initiatedThe failure was retryable, so the next eligible provider account was selected.
retry.stoppedNo further attempt will be made: the failure was final or no candidate was left.
payment.requires_actionThe provider needs the customer to act; next_action on the payment carries the redirect URL or QR payload.
payment.authorizedThe attempt authorised funds for later capture.
payment.successfulThe attempt succeeded. Carries provider payment id, fee and latency.
payment.failedThe payment ended as failed, with the failure code that closed it.
payment.capturedA capture was accepted by the provider.
capture.failedA capture request was rejected by the provider; the payment stays authorized.
payment.cancelledThe payment was cancelled, with the reason supplied by the caller.
refund.createdA refund was requested against this payment.
refund.successfulThe refund was accepted by the provider and the refunded amount was updated.
refund.failedThe refund was rejected by the provider.
sync.scheduledThe provider outcome stayed unknown; a background reconciliation of that attempt was scheduled.
sync.resolvedThe scheduled sync resolved the unknown attempt one way or the other.
webhook.queuedA merchant event was queued for delivery, with the event type and how many endpoints subscribe to it.
Timeline events are not webhook events
The timeline is the internal narrative of one payment and is read on demand. Webhooks are the subset of state changes you subscribe to and are pushed to your endpoints. The names overlap deliberately, but the two lists are not the same.

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.

SituationResult
Same key, same bodyThe 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 body422 idempotency_key_reused. Nothing is created.
Same key, first call still in flight409 idempotency_in_progress. Retry shortly; the stored response will be replayed once the first call finishes.
No key at allThe request executes normally. A retry creates a second payment — which is exactly the failure mode the header exists to prevent.
The three outcomes
# 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.

DecimalsCurrenciesExample
0JPY, KRW, VND, CLP, ISK, UGX, XAF, XOFamount 10000 with currency JPY is 10000 JPY
2every other supported currency, including USD, EUR, GBPamount 10000 with currency USD is 100.00 USD
3BHD, KWD, OMR, JOD, TNDamount 10000 with currency KWD is 10.000 KWD
Zero-decimal currencies are the classic bug
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

CallReturns
GET /v1/payments/{id}The payment with its route, risk, failure, fee and full attempts array.
GET /v1/paymentsA cursor-paginated list. Filter by status, currency, country, payment_method, reference, search, from and to.
GET /v1/payments/{id}/timelineEvery decision and provider interaction, in order.
GET /v1/payments/{id}/refundsEvery refund created against the payment.
GET /v1/transactionsThe 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.