Stripe Payments skill for Codex
Charge once, fulfil once, from a verified webhook. Codex builds Stripe one-time payments the way production teams do: the server sets the price, fulfilment runs only from a signature-verified webhook, and every write carries an idempotency key. It picks hosted Checkout by default, tests success, decline, 3D Secure and duplicate deliveries with the Stripe CLI, and asks before anything touches live keys or refunds.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- stripe-payments Installs to ~/.agents/skills/stripe-payments/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install stripe-payments The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project stripe-payments Installs to .agents/skills/stripe-payments/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/stripe-payments.zipunzip -o stripe-payments.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) stripe-payments PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $stripe-payments, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Integrates one-time Stripe payments with Checkout Sessions or Payment Intents and the Payment Element, verifies webhooks against the raw body, fulfils orders idempotently, and tests end to end with the Stripe CLI and test cards. Use when adding a checkout or pay button, wiring a Stripe webhook endpoint, debugging signature verification failures, handling 3D Secure or async payment methods, or moving a Stripe integration from test mode to live.
Use it when
- Adding one-time payments to a web app with Stripe
- A Stripe webhook endpoint returns 400 or fulfils orders twice
- You need to test card declines, 3DS and async payment methods locally
- Preparing a Stripe integration for the switch to live mode
Use something else when
- You are building recurring plans, trials or usage billing: use stripe-billing
- Money must be split between sellers on a platform: use stripe-connect
How to check the result
Every stripe listen delivery returns 200, a replayed checkout.session.completed event fulfils the order only once, and 3DS and decline test cards end in the right order state.
The mistake it prevents
Parsing the request as JSON before calling constructEvent, so the signature never matches, or fulfilling the order on the success redirect instead of the webhook.
Done well means: the server decides the amount, the order is fulfilled exactly once from a verified webhook (never from the success redirect), every Stripe write carries an idempotency key, and the whole flow has been exercised in test mode with the Stripe CLI, including a declined card and a 3D Secure card.
Before writing code
- Read
AGENTS.md, the lockfile and the framework config. Find any existingstripeclient, webhook route and order model; extend them instead of adding a second client. - Check the pinned API version:
apiVersionin the client constructor, or the account default in the Dashboard. Field shapes change between versions; code against the pinned one. - Confirm keys come from environment variables (
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET, publishable key for the client). Never hard-code or log them. - Confirm the user is in test mode (
sk_test_...). Anything that touches live keys, issues refunds or changes Dashboard settings needs explicit user confirmation first.
Installing stripe (npm or pip) and running the Stripe CLI need network access; in a Codex
sandbox with network off, ask for approval or work against what the project already has.
Choose the integration
| Need | Default | Why |
|---|---|---|
| Standard checkout, fastest to ship | Checkout Session, hosted page (mode: 'payment') | Stripe handles SCA, wallets, tax, receipts |
| Checkout inside your page | Checkout Session with ui_mode: 'embedded' | Same server logic, no redirect |
| Fully custom UI and flow | PaymentIntent + Payment Element | Maximum control, more code to own |
| Save a card for later off-session charges | SetupIntent, then PaymentIntent with off_session: true | Correct mandate and SCA handling |
| Subscriptions | Hand off to $stripe-billing | Different lifecycle and events |
| Marketplace payouts to sellers | Hand off to $stripe-connect | Funds flow and onboarding |
Prefer Checkout unless the user has a concrete reason for a custom form. Use dynamic payment
methods (configured in the Dashboard) instead of hard-coding payment_method_types.
Workflow
- Create Products and Prices (Dashboard or API); store Price IDs in config, not amounts
- Server endpoint creates the Checkout Session or PaymentIntent from your own cart data
- Client redirects or mounts the Payment Element with the returned secret
- Webhook endpoint verifies the signature on the raw body
- Fulfilment runs from the webhook, keyed on the Stripe object ID, exactly once
- Test with the CLI: success, decline, 3DS, async method, duplicate delivery
- Live-mode checklist reviewed with the user before switching keys
Create a Checkout Session (Node)
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // pin apiVersion if the project does
export async function createCheckout(orderId: string, items: { priceId: string; qty: number }[]) {
return stripe.checkout.sessions.create(
{
mode: "payment",
line_items: items.map((i) => ({ price: i.priceId, quantity: i.qty })),
client_reference_id: orderId, // your order, echoed back in the webhook
metadata: { orderId },
success_url: `${process.env.APP_URL}/orders/${orderId}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/cart`,
},
{ idempotencyKey: `checkout-${orderId}` }, // retries return the same session
);
}
Never accept a price or amount from the browser. Look the cart up server-side and map it to
Price IDs, or compute price_data.unit_amount in the smallest currency unit (cents; JPY has
no minor unit).
Payment Element (custom flow)
Server: stripe.paymentIntents.create({ amount, currency, automatic_payment_methods: { enabled: true }, metadata: { orderId } }, { idempotencyKey })
and return only client_secret. Client: elements = stripe.elements({ clientSecret }),
mount elements.create('payment'), then stripe.confirmPayment({ elements, confirmParams: { return_url } }).
On the return page, read payment_intent from the query string and show status, but still
fulfil from the webhook.
Webhooks
The signature is computed over the exact bytes Stripe sent. Any body parser that runs first breaks verification.
// Express: raw body for this route only, mounted before express.json()
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"]!, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return res.status(400).send("bad signature");
}
if (await alreadyProcessed(event.id)) return res.sendStatus(200);
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded": {
const s = event.data.object as Stripe.Checkout.Session;
if (s.payment_status === "paid") await fulfil(s.client_reference_id!, s.id);
break;
}
case "checkout.session.async_payment_failed":
await markFailed((event.data.object as Stripe.Checkout.Session).client_reference_id!);
break;
case "payment_intent.succeeded":
await fulfilFromIntent(event.data.object as Stripe.PaymentIntent);
break;
}
await markProcessed(event.id);
res.sendStatus(200);
});
- Next.js App Router: read
await req.text()and pass the string toconstructEvent. - Python:
stripe.Webhook.construct_event(request.get_data(), sig_header, endpoint_secret)(Flask) orawait request.body()(FastAPI). - Return 2xx fast; push slow work (email, PDFs) to a queue. Stripe retries failed deliveries for up to three days in live mode, so handlers must be idempotent.
- Events arrive out of order and can be duplicated. Make
fulfilidempotent on the order (UPDATE orders SET status='paid' WHERE id=$1 AND status<>'paid'), not just onevent.id. - Subscribe the endpoint only to the event types you handle.
Local testing with the Stripe CLI
stripe login # opens a browser; needs network
stripe listen --forward-to localhost:3000/webhooks/stripe # prints a whsec_... secret for local use
stripe trigger checkout.session.completed
stripe trigger payment_intent.payment_failed
stripe events resend evt_123 # replay one event
The whsec_ from stripe listen differs from the Dashboard endpoint secret. Use it locally only.
| Test card | Behaviour |
|---|---|
4242 4242 4242 4242 | Succeeds |
4000 0025 0000 3155 | Requires 3D Secure authentication |
4000 0000 0000 9995 | Declined, insufficient funds |
4000 0000 0000 0002 | Generic decline |
Any future expiry, any CVC, any postcode. Test-mode only; never type real card numbers anywhere.
Refunds and disputes
stripe.refunds.create({ payment_intent, amount? }, { idempotencyKey }). Refunds move money: confirm the order, amount and reason with the user before running one in live mode.- Listen to
charge.refundedandcharge.dispute.createdto keep your order state honest.
Gotchas
- Fulfilling on the
success_urlpage: users close tabs, and the URL can be loaded by anyone. checkout.session.completedwithpayment_status: 'unpaid'is normal for bank debits and vouchers; wait forasync_payment_succeeded.- Using
JSON.stringify(req.body)for verification: re-serialised JSON never matches. - Mixing test and live objects: IDs from one mode do not exist in the other.
- Rounding: amounts are integers in minor units.
19.99 * 100is1998.9999999999998; useMath.roundor integer cents throughout. - Expanding everything: retrieve with
expand: ['line_items']only where you need it. - A webhook endpoint secret per endpoint and per mode; rotating it in the Dashboard breaks verification until the env var is updated.
Going live (confirm each item with the user)
- Live keys stored in the secret manager, not in
.envcommitted to git - Live webhook endpoint created with the same event list; its secret deployed
- Statement descriptor, business details and payment methods set in the Dashboard
- Radar rules reviewed; tax collection decided (Stripe Tax or none)
- One real low-value payment made and refunded by the user, not by the agent
Verification
stripe listenoutput shows each event returning[200].- Run
stripe trigger checkout.session.completedtwice for the same order (orevents resend): the order is fulfilled once. - A 3DS card completes after the challenge; a decline card leaves the order unpaid with a readable message.
- Unit-test the handler with a payload signed by
stripe.webhooks.generateTestHeaderString.
Report template
**Flow:** Checkout Session (hosted), mode=payment, API version pinned to <version>
**Endpoints:** POST /api/checkout, POST /webhooks/stripe (events: checkout.session.*, charge.refunded)
**Idempotency:** session keyed on orderId; fulfilment guarded by orders.status
**Tested (test mode):** 4242 success, 3155 3DS, 9995 decline, duplicate delivery -> single fulfilment
**Not done / needs user:** live webhook endpoint, live keys, Dashboard branding What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Stripe Payments"
short_description: "Charge once, fulfil once, from a verified webhook"
default_prompt: "$stripe-payments Add a Stripe Checkout button to our cart page and mark orders paid from a webhook" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.