# Ekx Stripe

> Fiat payments with Stripe — Checkout Sessions, Payment Element, subscriptions and price IDs, webhook signature verification, and the test-vs-live key split. Use when accepting a card payment, building a booking or subscription flow, handling a webhook, reconciling a payment to a database row, or debugging a webhook signature failure.

- Skill: `ekinoxis-evm/ekx-stripe` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ekinoxis-evm/ekx-stripe`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ekinoxis-evm/ekx-stripe/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: Ekinoxis-evm (https://skillmd.com/u/ekinoxis-evm)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ekinoxis-evm/ekx-stripe

---


# Stripe

Our fiat rail everywhere except Colombia. (Onchain payments are USDC — see
[`../ekx-circle-usdc/SKILL.md`](../ekx-circle-usdc/SKILL.md); for Colombia see
[`../ekx-mercadopago/SKILL.md`](../ekx-mercadopago/SKILL.md).)

Authoritative: the **`stripe` MCP server** plus the bundled `stripe-best-practices` skill. Docs: https://docs.stripe.com

---

## Environment

```bash
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=   # pk_test_… / pk_live_…
STRIPE_SECRET_KEY=                    # SECRET  sk_test_… / sk_live_…
STRIPE_WEBHOOK_SECRET=                # SECRET  whsec_…
STRIPE_PREMIUM_PRICE_ID=              # price_…
```

The webhook secret is **per endpoint**. Local (Stripe CLI), preview and production
each have their own. Using production's secret against a CLI-forwarded event fails
signature verification — the most common Stripe bug we hit.

---

## Checkout Session — the default

Prefer a hosted Checkout Session over a custom Payment Element unless the design
genuinely requires an embedded form. It handles SCA, wallets, tax, and receipts for free.

```ts
// app/api/checkout/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const { bookingId } = await req.json();

  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: process.env.STRIPE_PREMIUM_PRICE_ID!, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_URL}/booking/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url:  `${process.env.NEXT_PUBLIC_URL}/booking/cancelled`,
    metadata: { bookingId },                 // ← how you reconcile later
    client_reference_id: bookingId,
  });

  return Response.json({ url: session.url });
}
```

**Always set `metadata`.** It is the only reliable link from a Stripe payment back to
a row in Supabase. Without it, reconciliation means matching on amount and timestamp.

**Never trust the success_url.** A user can navigate there without paying. Fulfilment
happens in the webhook, never in the success page.

---

## Webhooks

```ts
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
  const body = await req.text();                       // RAW body — not req.json()
  const sig  = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return new Response("bad signature", { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const s = event.data.object as Stripe.Checkout.Session;
      await admin.from("bookings")
        .update({ status: "paid", stripe_session_id: s.id })
        .eq("id", s.metadata!.bookingId)
        .eq("status", "pending");            // idempotency: only pending → paid
      break;
    }
    case "customer.subscription.deleted":
      /* revoke access */ break;
  }

  return new Response(null, { status: 200 });           // 200 fast, always
}
```

Three non-negotiables:

1. **Raw body.** `req.json()` re-serialises and the signature no longer matches.
2. **Idempotent handlers.** Stripe retries, and delivers at-least-once. The `.eq("status","pending")` guard above makes a double-delivery a no-op.
3. **Return 200 quickly.** Do slow work (email, onchain call) after responding, or via a queue. Stripe retries anything slower than ~10s, compounding the problem.

Local testing:

```bash
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
```

The CLI prints the `whsec_…` to use locally.

---

## Subscriptions

Create products and prices in the dashboard (or via the MCP server), store the
`price_id` in env — never hardcode amounts in code, or test and live diverge.

Gate on the subscription status stored in your own database, synced from
`customer.subscription.*` webhooks. Do not call Stripe on every request to check
access; it is slow and rate-limited.

---

## Gotchas

1. **Wrong webhook secret** per environment. Check this first on any 400.
2. **`req.json()` in the webhook** breaks the signature.
3. **Fulfilling in `success_url`** — free products for anyone who guesses the URL.
4. **Amounts are integer minor units.** `1000` = $10.00 USD, but zero-decimal currencies (JPY, COP has 2 in Stripe) differ — check per currency, especially for our Colombian products.
5. **Test/live key mixing** produces "No such price" — the ids are environment-scoped.
6. **`stripe` npm and the API version** — pin `apiVersion` in the constructor so a dashboard-side upgrade does not change behaviour under you.

