Skip to content

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:

npx @sadrmanesh/commerce-agent-skills add stripe-payments

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

  1. 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.
  2. 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.
  3. Put the shop order id in metadata (metadata[order_id]) and send an Idempotency-Key header derived from the order and attempt, such as order-1042-attempt-1.
  4. Return only the client_secret (PaymentIntent) or the session url (Checkout) to the browser. The secret key sk_... stays on the server.
  5. Mark the order paid from a verified webhook: payment_intent.succeeded for PaymentIntents, or checkout.session.completed with payment_status equal to paid for Checkout.
  6. 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-Signature header with the endpoint secret whsec_.... The header has a timestamp t and one or more v1 signatures. The signed payload is t, 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>" with STRIPE_WEBHOOK_SECRET set to check a captured delivery.
  • Store event.id in a table with a unique constraint before acting. Stripe can send the same event more than once, and out of order.
  • Return 2xx quickly 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/refunds and payment_intent, plus amount for a partial refund. Send an idempotency key per refund attempt and store the refund id.
  • A refund can stay pending and can later fail. Update the shop refund from refund.updated or charge.refund.updated, not from the create response alone.
  • charge.dispute.created opens 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_url page 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 v1 signature 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.succeeded event twice sends one receipt and reduces stock once.
  • A payment_intent.succeeded whose amount_received differs from the order total leaves the order unpaid and flags it.
  • A Checkout Session with payment_status unpaid (a delayed bank method) does not mark the order paid until checkout.session.async_payment_succeeded.
  • A partial refund through POST /v1/refunds leaves 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

Stripe-Signature: t=1730300000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

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.