Stripe payments¶
stripe-payments
Take card and wallet payments with Stripe PaymentIntents or Checkout Sessions, idempotency keys, and signed webhooks that move the order to paid. Use when adding Stripe to a store, handling Stripe webhooks, or refunding through the Stripe API.
Version 0.3.2 · MIT · Source · Rule
Install this skill
Copy the skill and its rule into your project:
The skill goes to .agents/skills/ and the rule to .agents/rules/. See Command line for other directories.
This skill applies the general rules in the payment gateway skill to Stripe. Event names, objects, and status values are in the Stripe objects notes.
Procedure¶
- Pick one integration per checkout. A Checkout Session is a Stripe-hosted page. A PaymentIntent with the Payment Element keeps the shopper on your page. Both keep card data on Stripe.
- Create the shop order first, unpaid. Then create the PaymentIntent or Checkout Session on the server with the amount from the order, never from the browser.
- Put the shop order id in
metadata(metadata[order_id]) and send anIdempotency-Keyheader derived from the order and attempt, such asorder-1042-attempt-1. - Return only the
client_secret(PaymentIntent) or the sessionurl(Checkout) to the browser. The secret keysk_...stays on the server. - Mark the order paid from a verified webhook:
payment_intent.succeededfor PaymentIntents, orcheckout.session.completedwithpayment_statusequal topaidfor Checkout. - Pin the API version for the account and the webhook endpoint, so a dashboard upgrade does not change payloads under you.
Amounts¶
Stripe amounts are integers in the currency's smallest unit: 1999 is 19.99 USD. Zero-decimal currencies such as JPY are sent as the whole amount: 500 is 500 JPY. Compute the integer from the order total with the currency's exponent and compare the webhook's amount_received and currency with the order before marking it paid.
Webhooks¶
- Read the raw body. A JSON parser that reformats the body breaks the signature.
- Verify the
Stripe-Signatureheader with the endpoint secretwhsec_.... The header has a timestamptand one or morev1signatures. The signed payload ist, a dot, and the raw body, hashed with HMAC-SHA256 and hex encoded. Reject timestamps older than the tolerance, 300 seconds by default. - Run
node scripts/verify-stripe-signature.mjs <body-file> "<Stripe-Signature header>"withSTRIPE_WEBHOOK_SECRETset to check a captured delivery. - Store
event.idin a table with a unique constraint before acting. Stripe can send the same event more than once, and out of order. - Return
2xxquickly after storing the event, then do slow work in a job. - For events where order matters, retrieve the object (
GET /v1/payment_intents/{id}) and act on its current status instead of the event's snapshot.
Status¶
PaymentIntent status |
Shop effect |
|---|---|
requires_payment_method |
Unpaid. After a decline, the shopper can try again with the same intent. |
requires_action |
Unpaid. The shopper must finish 3-D Secure or a redirect. |
processing |
On hold. Bank debits and some wallets settle later. |
requires_capture |
Authorized only. Capture before the authorization expires, or release it. |
succeeded |
Paid. Store the latest charge id. |
canceled |
Cancel the order only if it is still unpaid. |
Refunds and disputes¶
- Refund with
POST /v1/refundsandpayment_intent, plusamountfor a partial refund. Send an idempotency key per refund attempt and store the refund id. - A refund can stay
pendingand can later fail. Update the shop refund fromrefund.updatedorcharge.refund.updated, not from the create response alone. charge.dispute.createdopens a dispute. Flag the order and collect evidence. Do not refund automatically, because a refund does not close a dispute.
Gotchas¶
- Creating a new PaymentIntent on every checkout page load leaves many incomplete intents and can charge twice if two are confirmed. Reuse the intent stored on the order.
- Marking the order paid on the
return_urlpage is wrong. The redirect arrives before, after, or without the webhook. - Test-mode and live-mode keys, webhook secrets, and object ids are separate. A live event never verifies with the test secret.
- A
v1signature compare with==leaks timing. Use a constant-time compare. - Idempotency keys are kept for at least 24 hours. Reusing a key with different parameters returns an error, not a new payment.
Verification¶
- A forged webhook, or a real one older than five minutes, is rejected with
400. - Delivering the same
payment_intent.succeededevent twice sends one receipt and reduces stock once. - A
payment_intent.succeededwhoseamount_receiveddiffers from the order total leaves the order unpaid and flags it. - A Checkout Session with
payment_statusunpaid(a delayed bank method) does not mark the order paid untilcheckout.session.async_payment_succeeded. - A partial refund through
POST /v1/refundsleaves the order paid for the remainder.
Evaluation¶
Worked examples and the checks they must satisfy are in evals/tasks.json.
References¶
Stripe objects and events¶
Objects¶
| Object | Id prefix | Use |
|---|---|---|
| PaymentIntent | pi_ |
One payment attempt cycle for one order. Holds amount, currency, status, metadata, and latest_charge. |
| Checkout Session | cs_ |
A hosted payment page. Has payment_status (paid, unpaid, no_payment_required) and a payment_intent. |
| Charge | ch_ |
The movement of money behind a PaymentIntent. Refunds and disputes point at it. |
| Refund | re_ |
A full or partial refund. status is pending, requires_action, succeeded, failed, or canceled. |
| Dispute | dp_ |
A chargeback. Respond with evidence before evidence_details.due_by. |
| Event | evt_ |
A webhook delivery body. id is stable across retries, so use it for deduplication. |
Events a store usually handles¶
| Event | Action |
|---|---|
payment_intent.succeeded |
Mark paid after checking amount and currency |
payment_intent.payment_failed |
Record the decline. Keep the order unpaid. |
payment_intent.canceled |
Cancel the order if still unpaid |
checkout.session.completed |
Mark paid if payment_status is paid. Otherwise wait. |
checkout.session.async_payment_succeeded |
Mark paid for a delayed method |
checkout.session.async_payment_failed |
Mark failed for a delayed method |
charge.refunded |
Reconcile refunds made outside the shop admin |
refund.updated |
Update a pending refund to succeeded or failed |
charge.dispute.created |
Flag the order and start the evidence task |
Subscribe the endpoint only to the events you handle. Every other event still has to return 2xx if it arrives.
Signature header¶
Compute HMAC-SHA256(secret, "1730300000." + rawBody) as lowercase hex and compare it with each v1 value. During secret rotation the header can carry two v1 values. Ignore v0.
Requests¶
curl https://api.stripe.com/v1/payment_intents \
-u "$STRIPE_SECRET_KEY:" \
-H "Idempotency-Key: order-1042-attempt-1" \
-d amount=1999 -d currency=usd \
-d "automatic_payment_methods[enabled]=true" \
-d "metadata[order_id]=1042"
curl https://api.stripe.com/v1/refunds \
-u "$STRIPE_SECRET_KEY:" \
-H "Idempotency-Key: refund-1042-1" \
-d payment_intent=pi_123 -d amount=500
Evaluation¶
Fixtures from evals/tasks.json. See evaluation fixtures for how the checks work.
mark-paid-from-webhook¶
A store creates a Stripe PaymentIntent at checkout. Explain how and when the order should become paid, including how to handle a duplicate delivery.
Must include: payment_intent.succeeded, Stripe-Signature, event.id, amount_received
Must not include: return_url marks the order paid
Worked example
Create the PaymentIntent on the server from the order total with metadata[order_id] and an Idempotency-Key per attempt.
The order becomes paid only in the webhook handler. Verify the Stripe-Signature header against the raw body with the endpoint secret, then insert event.id into a table with a unique constraint. If the insert conflicts, return 200 and stop, because the event was already handled.
For payment_intent.succeeded, load the order from metadata.order_id, compare amount_received and currency with the order total in minor units, and mark it paid once. The browser redirect only shows a pending page until the order record says paid.
partial-refund¶
Refund 5.00 USD of a 19.99 USD Stripe payment from the shop admin and keep the shop record in sync when the refund settles.
Must include: POST /v1/refunds, 500, Idempotency-Key, refund.updated
Must not include: amount=5.00
Worked example
Call POST /v1/refunds with payment_intent=pi_123 and amount=500, because Stripe amounts are integers in cents. Send Idempotency-Key: refund-1042-1 so a retried request does not refund twice.
Create the shop refund record as pending with the returned re_ id. Update it to succeeded or failed from refund.updated. The order stays paid for the remaining 14.99 USD.
checkout-session-delayed¶
A Stripe Checkout Session completes with a bank debit that settles days later. Say what the webhook handler does now and later.
Must include: checkout.session.completed, payment_status, checkout.session.async_payment_succeeded
Worked example
On checkout.session.completed, read payment_status. For a bank debit it is unpaid, so record the PaymentIntent id and leave the order on hold without fulfilling it.
When checkout.session.async_payment_succeeded arrives, verify it, check the amount, and mark the order paid. If checkout.session.async_payment_failed arrives instead, mark the order failed and email the shopper a link to pay again.