API One payment object, from intent to settlement

The Checkout API behind your embedded payment step

Naturpay is a small, predictable API and a typed SDK built around a single object: the payment intent. Create it on your server, confirm it from the element in the browser, then capture, refund or replay against the same resource. Cards, wallets and authentication run underneath — you just move the intent through its states.

Idempotent writes Signed, replayable webhooks Typed errors, never HTML
201 Createdpi_3Q · requires_confirmation
Idempotency-Key honoredretry returned the same intent
One resource, every state
createdconfirmedauthenticatedcapturedrefunded

What the API is

A request/response model built around one object

There is no sprawling surface to learn. You create a payment intent, attach a method to it from the embedded element, and confirm. Every later action — capture, refund, retry, dispute — references that same intent id, so your code and your ledger always point at one source of truth.

  • REST over HTTPS with JSON bodies, predictable resource paths and standard status codes — nothing exotic to special-case.
  • A secret key signs server calls; a publishable key initializes the element in the browser. Raw card data only ever flows between the buyer and Naturpay.
  • Amounts are integers in the smallest currency unit, so 12800 is €128.00 — no floating point ever touches a charge.
  • Every object carries a stable id, a status, a created timestamp and an expandable set of related resources you can request inline.
the payment object
const intent = {
  id: 'pi_3Qm0…',
  object: 'payment_intent',
  status: 'requires_confirmation',
  amount: 12800,
  amount_captured: 0,
  currency: 'eur',
  capture: 'automatic',
  method: null,
  client_secret: 'pi_3Qm0_secret_…',
  created: 1718870400,
  metadata: { order: '8841' },
};

The payment-intent lifecycle

Five states, and you drive the transitions

An intent moves through a known set of statuses. You can read the current status at any time, and every transition emits a signed event. Nothing happens implicitly that you can't observe.

requires_confirmation

You created the intent on the server with an amount and currency. The element collects a method and holds the client secret, ready to confirm.

server create

requires_action

Confirmation triggered authentication. The SDK runs the 3-D Secure step inline in the browser and returns control once the challenge resolves.

SCA handled by SDK

processing

The method is authorized and the charge is moving through the network. The intent is locked against conflicting writes until the result lands.

network in flight

succeeded

Funds are captured, or authorized and held if you chose manual capture. A signed payment.succeeded event arrives so you can fulfill the order.

webhook fires
Final and recovery states — canceled, requires_payment_method on decline, and refunded after capture — are reached through the same explicit calls.

Server-side calls

Create, confirm, refund — same object

Three calls cover the common path. Each is a write against the intent, each accepts an idempotency key, and each returns the full, updated object so you never have to re-fetch to know the new state.

  • create returns a client secret you hand to the element — and nothing more sensitive leaves your server.
  • confirm and capture let you split authorization from settlement when you ship later than you charge.
  • refund accepts a partial amount and reuses the same intent, so a return is one call, not a new resource.
server
// create an intent for the order
const intent = await wv.intents.create(
  {
    amount: 12800,
    currency: 'eur',
    capture: 'automatic',
    metadata: { order: order.id },
  },
  { idempotencyKey: `create_${order.id}` },
);

// hand the secret to the element
return { clientSecret: intent.client_secret };
// confirm a held intent on the server
const result = await wv.intents.confirm(
  intent.id,
  { method: 'pm_1Nv…' },
  { idempotencyKey: `confirm_${order.id}` },
);

if (result.status === 'requires_action') {
  // element finishes 3-D Secure inline
}
// refund part of a captured intent
const refund = await wv.refunds.create(
  {
    intent: 'pi_3Qm0…',
    amount: 4000,
    reason: 'requested_by_customer',
  },
  { idempotencyKey: `refund_${rma.id}` },
);

// refund.status -> 'succeeded'

How the element talks to the API

The browser holds the secret, never the keys

Your server creates the intent and returns only its client secret. The embedded element uses that secret to tokenize the card and confirm directly with Naturpay, so sensitive details bypass your backend entirely while you keep full control of the surrounding UI.

  • The publishable key scopes the element to confirmation only — it cannot create, capture or refund.
  • The client secret is single-intent and short-lived, so a leaked secret can do nothing but settle that one order.
  • Confirmation returns either the updated intent or a typed error you branch on — there is no ambiguous in-between.

What the API gives you

The capabilities a payments integration actually needs

These are the primitives that keep an integration correct under retries, partial network failures and real-world money movement — not afterthoughts bolted on later.

Idempotent writes

Pass an idempotency key on any write and a retried request returns the original result instead of charging twice. Keys are stored for 24 hours, so a timed-out request is always safe to repeat.

Idempotency-Key · 24h

Signed webhooks

Every state change emits an event signed with your endpoint secret and verifiable in one SDK call. Deliveries are retried with backoff and stay replayable, so a missed event never desyncs your records.

HMAC · retries · replay

Typed errors

Failures return JSON with a stable type, code and human message — card_declined, validation_error, rate_limited — never an HTML page. Decline codes map straight to the message you show the buyer.

type · code · message

Multi-currency

Set the currency on the intent and present prices in the buyer's money while you settle in your own. Zero-decimal currencies are handled correctly, and the element formats amounts to each locale.

135+ currencies

Saved methods and tokenization

Attach a method to a customer once and reuse the token for one-tap returns or off-session charges. Cards become network tokens that survive expiry, so saved-card success rates stay high over time.

vault · network tokens

Manual and partial captures

Authorize at checkout and capture later when you ship, capture less than you authorized, or release the hold entirely. The authorized amount and the captured amount live on the intent as separate fields.

auth then capture

Built for production

What the API guarantees

61ms
Median write latency
Time to create or confirm an intent, measured at the edge across all live regions.
99.99%
API uptime
A trailing-year figure for the payments API, published on the status page.
24h
Idempotency window
How long a key is remembered, so any retried write resolves to one outcome.
3s
Webhook retry start
First retry on a failed delivery, then exponential backoff for up to three days.

Webhooks and idempotency

Make every charge exactly-once

Verify the signature, key your fulfillment off the event id, and a duplicate delivery becomes a no-op. The same intent that confirmed in the browser is the one carried in the event, so your reconciliation never has to guess.

  • Verify with the endpoint secret and reject anything whose signature or timestamp does not check out.
  • Store the event id and skip events you have already processed — deliveries are at-least-once by design.
  • Replay any past event from the dashboard or CLI while you build, so testing never waits on real traffic.
verify and dedupe
// verify the signature, then dedupe
const event = wv.webhooks.verify(
  rawBody,
  req.headers['naturpay-signature'],
  process.env.WV_WHSEC,
);

if (await seen(event.id)) return 200;

switch (event.type) {
  case 'payment.succeeded':
    await fulfill(event.data.intent);
    break;
  case 'payment.failed':
    await notify(event.data.intent);
    break;
}
await remember(event.id);

The error model

Failures you can branch on, not parse

Errors are part of the contract. Each one has a category you can switch on, a machine code that never changes wording, and a buyer-safe message — so declines, validation failures and rate limits each get the handling they deserve.

card_errorbuyer-facing

The card was declined

Carries a decline_code like insufficient_funds you can map to copy. Show the message, keep the intent, let the buyer try another method.

validation_erroryour bug

The request was malformed

Names the offending field and reason, returned as a 400 before any money moves. Fix the call and retry safely with the same key.

rate_limitedback off

Slow down and retry

A 429 with a Retry-After header tells you exactly when to come back. The SDK respects it automatically with jittered backoff.

Before you ask

Questions about the API

Do I confirm the intent on the server or in the browser?

Either, depending on your flow. Most integrations let the element confirm in the browser with the client secret, which keeps card data off your servers and handles 3-D Secure inline. For off-session or merchant-initiated charges you confirm on the server with a saved method instead. Both paths land on the same intent and emit the same events.

What exactly does the idempotency key protect against?

A timed-out or retried write. If a request never returns — a dropped connection, a proxy timeout — you can safely send it again with the same key and Naturpay returns the original result rather than creating a second intent or a second charge. Keys are scoped to your account and remembered for 24 hours.

How do I tell a real decline from a temporary failure?

By the error type. A card_error with a decline_code is a genuine decline you surface to the buyer; a rate_limited or a 5xx is transient and safe to retry with backoff and the same idempotency key. The SDK already distinguishes the two and retries only the ones that should be retried.

Can I capture a different amount than I authorized?

Yes. With manual capture you authorize at checkout and later capture the full amount, a smaller amount, or nothing at all if you release the hold. The intent tracks the authorized and captured amounts separately, which is exactly what you need for ship-on-fulfillment or weight-based pricing.

Is the webhook the only way to know a payment succeeded?

No, but it should be the source of truth. The confirm call returns the updated intent immediately, which is fine for showing the buyer a result. For fulfillment, key off the signed webhook so a closed browser tab or a flaky connection never leaves an order unshipped — and dedupe on the event id since delivery is at-least-once.

Which SDKs wrap the API?

Server SDKs cover Node, Python, Ruby, Go and PHP, each with typed methods for intents, refunds, customers and webhooks. In the browser, the same API sits under the React and Vue packages, the Web Components build and a vanilla JS SDK. If your language is not on the list, the REST API is fully documented and every SDK is a thin wrapper over it.

Move your first intent through Naturpay

Grab a test key, create an intent and confirm it from the element in a few lines. Trigger declines and replay webhooks against the sandbox, then talk to an engineer when you are ready to go live.