curl --request POST \
--url https://{host}/v1/me/orders/{id}/payment \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/vnd.api+json' \
--data '
{
"data": {
"type": "payments",
"attributes": {
"paymentMethod": "ideal",
"returnUrl": "wristlerapp://checkout/7741"
}
}
}
'{
"data": {
"type": "payments",
"id": "pi_3QhK2mJk9Lm4No5P",
"attributes": {
"order": "7741",
"status": "requires_action",
"paymentMethod": "ideal",
"subtotal": 1295000,
"shipping": 0,
"fee": 0,
"total": 1295000,
"clientSecret": "pi_3QhK2mJk9Lm4No5P_secret_8dGh2Kl9",
"publishableKey": "pk_live_51JG108",
"customerId": "cus_QhK2mJk9Lm4No5P",
"nextAction": {
"type": "redirect_to_url",
"url": "https://hooks.stripe.com/redirect/authenticate/src_1Qh"
}
}
}
}Pay order
Scope: checkout:write
A reservation that has run out is refused. A watch is held for 48 hours; after that
422 reservation_expired is the answer here, and reservedUntil on the order is
null with no paymentMethods. The order itself is cancelled by a scheduled job
shortly afterwards — the refusal does not wait for it, so the answer does not
depend on when that job last ran.
{ "data": { "type": "payments", "attributes": {
"paymentMethod": "ideal",
"returnUrl": "wristlerapp://checkout/7741"
} } }
paymentMethod is a code from the order’s own paymentMethods, and one with
available: true. The list also carries the methods that cannot be used on this
order — a card from outside the EEA, or at a business with card payments switched
off — with available: false and an unavailableReason sentence to show beside
them, disabled. Paying with one of those is refused with 422.
The payment is created for exactly that method and exactly what it costs, and for nothing else: a client cannot confirm an amount quoted for one method as a payment by another. The method’s fee is charged to the buyer and taken off our side rather than the seller’s.
returnUrl decides who runs the payment, and is matched against an allow-list
either way — the app’s own scheme, or https on a host of ours. A return address
a client may choose freely is an open redirect with a payment attached to it.
With a returnUrl, no payment SDK is needed for anything. iDEAL, Bancontact,
EPS and Przelewy24 are confirmed here and answer with the bank to send the buyer
to. A card answers with Stripe’s own hosted page, which takes the card details
— they are not ours to receive — and returns the buyer to that same address; the
app opens it in a browser and needs no native module. Leave returnUrl out and a
card comes back as a clientSecret for Stripe’s SDK instead, which is the other
way to do it.
nextAction says what has to happen: redirect_to_url with somewhere to send
the buyer, display_bank_transfer_instructions with paymentInstructions
alongside it, or nothing at all when the device is about to confirm.
On the hosted card path data.id is the Checkout session rather than a payment
intent, and expiresAt says when Stripe stops accepting it — shorter than the
48-hour reservation, so a buyer who leaves it open asks for a payment again.
An order is paid one way and does not change its mind. The two card paths are
two different payments, so asking for the hosted page while a payment the device
confirms (a card through the SDK) is under way is 409 payment_path_locked with
meta.path: "sdk"; and while a hosted page is open, asking for any other payment
— the SDK card, a bank redirect or a transfer — is 409 payment_path_locked with
meta.path: "hosted". Asking for the same hosted page twice answers the
same page rather than opening a second one, exactly as a bank transfer answers the
same reference. The lock lifts by itself when a hosted session expires.
Decide the path once per order and keep to it; do not fall back to the other one after a failed attempt on the same order.
Asking again with another method opens another payment, which is what a buyer who changed their mind at their bank’s screen needs — and closes the one before it. A bank reference is an account waiting for money and it does not expire on its own, so switching cancels the previous payment rather than leaving two live. Asking again for the same bank transfer answers the transfer that is already outstanding, reference and all: a second reference for money already on its way is money nobody can match.
Paying another way while a transfer is awaited is that same call — the website’s
“Pay another way instead”. An order waiting for a bank transfer (status
awaiting_payment) takes any other method, the hosted card page included: a
transfer is never something the client holds and could confirm, so it is not a
card path and does not lock one. The transfer’s reference is cancelled once the new
payment is open.
One at a time per order. Two of these arriving together are serialised, so the
second finds what the first opened and is answered with it — press the button twice
and you get one payment, not two. Only a request that waits longer than ten seconds
for the one in front of it is refused, with 409 payment_in_progress; retrying it
is safe.
The webhook decides whether it worked, not this answer and not the SDK’s. Show
“payment received, confirming” and re-read the order until its status moves. For
a transfer that is days, not seconds.
| Refusal | Status | code |
|---|---|---|
paymentMethod missing, or not one this order offers | 422 | |
returnUrl not on the allow-list | 422 | |
| Already paid, or being paid | 409 | already_paid |
| Cancelled, refunded or returned | 422 | order_closed |
| The 48-hour reservation has run out | 422 | reservation_expired |
| The shop cannot receive payments at the moment | 422 | seller_cannot_take_payments |
| The watch has since been sold or withdrawn | 422 | watch_not_for_sale |
| Reserved for another buyer | 409 | watch_reserved |
| The order has no billing address | 422 | order_incomplete |
| Stripe refused it | 422 | payment_refused |
| Stripe could not be reached, or is busy with this order | 503 | payment_unavailable |
| Another request is opening a payment for this order | 409 | payment_in_progress |
| The order is committed to the other path | 409 | payment_path_locked |
payment_refused carries a sentence written for the buyer — a declined card, an
expired one, a wrong security code — and is safe to show as it stands. It is no
longer Stripe’s own wording, which named internal identifiers and explained things
the buyer could do nothing about.
curl --request POST \
--url https://{host}/v1/me/orders/{id}/payment \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/vnd.api+json' \
--data '
{
"data": {
"type": "payments",
"attributes": {
"paymentMethod": "ideal",
"returnUrl": "wristlerapp://checkout/7741"
}
}
}
'{
"data": {
"type": "payments",
"id": "pi_3QhK2mJk9Lm4No5P",
"attributes": {
"order": "7741",
"status": "requires_action",
"paymentMethod": "ideal",
"subtotal": 1295000,
"shipping": 0,
"fee": 0,
"total": 1295000,
"clientSecret": "pi_3QhK2mJk9Lm4No5P_secret_8dGh2Kl9",
"publishableKey": "pk_live_51JG108",
"customerId": "cus_QhK2mJk9Lm4No5P",
"nextAction": {
"type": "redirect_to_url",
"url": "https://hooks.stripe.com/redirect/authenticate/src_1Qh"
}
}
}
}Authorizations
Headers
Optional. A UUID per logical operation, not per attempt: a retry carrying the same key gets the first attempt's answer back, headers and all, rather than performing the write twice. Honoured for 24 hours.
A refusal is an answer. A 422 is remembered like any other, so sending a
corrected body under the same key answers 422 idempotency_key_reuse rather
than accepting the correction — the point of a key is that one key means one
decision. Use a new key for a new attempt at the request, and keep the old one
only for retrying the same bytes after a timeout.
A 5xx is the exception: nothing was decided, so the key is released and the
same one may be retried.
| Refusal | Status | code |
|---|---|---|
| The same key with a different body | 422 | idempotency_key_reuse |
| The first request with this key is still running | 409 | idempotency_in_progress |
| The key's record vanished between two requests: retry with a new key | 409 | idempotency_conflict |
| A key longer than 191 characters | 400 | idempotency_key_invalid |
A replayed answer carries Idempotent-Replay: true.
191