mcpbeat Sign in

Stripe Agent Skill

Use when wiring an app to Stripe for payments, subscriptions, Checkout, webhooks or the billing portal — adding a paywall or paid plan, charging recurring fees, and debugging why a completion event never fires, why signature verification fails, or why a webhook fires twice and duplicates the order. NOT a generic non-Stripe inbound event receiver (that is `webhooks`), NOT the invoice document's legal form (that is `invoicing`), NOT deciding what to charge (that is `pricing`).

7k tokens
context cost
the whole folder, loaded on every use
6
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill stripe

What comes with it

14 554 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/going-live.md
references/webhook-events.md
scripts/verify.sh

The instruction itself

15 sections, as written by the author

Stripe — Checkout, subscriptions, and webhooks that survive production

Wire an app to Stripe for one-time payments and subscriptions: a Checkout

Session to take money, a signature-verified webhook to learn the outcome, and

the billing portal so customers manage themselves. Targets stripe-node v22.x

(API version 2026-05-27.dahlia).

The one rule everything else hangs off:

  • **Stripe is the source of truth; your DB is a cache kept current by verified

webhooks.** A subscription's real state lives in Stripe. You mirror it locally

only so you can render a paywall without a round-trip. The webhook is what

keeps the mirror honest — never the client redirect, never polling.

When to use / When NOT to use

Use when: adding Checkout or a paywall; taking a one-time or recurring

charge; receiving Stripe webhooks; letting users cancel/upgrade via the portal;

debugging No signatures found matching, double-fired events, or a

checkout.session.completed that never arrives.

Do NOT use for:

  • A generic, non-Stripe inbound webhook receiver (arbitrary HMAC, a

retry/queue consumer for any provider) → webhooks. This skill only covers

Stripe's Stripe-Signature scheme and event model.

  • The invoice document's legal form — what must appear on it, dunning copy,

payment-status as a business process → invoicing.

  • Cash-flow forecasting, bank reconciliation, month-close

finance-ops.

  • What to charge / plan tiers / packaging → pricing.

This skill implements a price; it does not decide it.

  • A typed client for some other REST APIapi-connector-builder.

Pick the surface

| You need | Use | Why |

|---|---|---|

| A link to sell one product, zero code | Payment Link | No backend; Stripe hosts everything. Outgrow it fast. |

| Hosted checkout, full control of session | Checkout Session | The default. Stripe hosts the page, handles SCA/3DS, PCI scope is minimal. |

| Your own embedded payment form | Payment Element + Checkout Session | Custom UI, but you render the form. Use the Element *with* a Checkout Session, not raw PaymentIntents, unless you have a reason. |

Default to Checkout Session unless a requirement forces otherwise.

Mental model

Customer ──> Price (you defined it in Stripe) ──> Checkout Session
   │                                                    │
   │                                          customer pays (hosted)
   ▼                                                    ▼
your DB  <── webhook (verified) ── Event <── Subscription / Invoice
(a cache)                                    (the real state, in Stripe)

Env vars used throughout — read from the environment, never hard-code:

  • STRIPE_SECRET_KEYsk_test_… in dev, sk_live_… in prod.
  • STRIPE_WEBHOOK_SECRETwhsec_…, one per endpoint; differs in test vs live.

Construct the client (pin the API version)

import Stripe from "stripe";

// Pin apiVersion explicitly so a dashboard-level version bump can never change
// your API behavior under you. Match the version your SDK release ships with.
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2026-05-27.dahlia",
});

Create a Checkout Session

// Subscription mode — recurring price.
const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: "price_123", quantity: 1 }], // a recurring Price ID
  success_url: `${BASE}/success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${BASE}/pricing`,
  // Tie the session to YOUR user so the webhook can find the right row.
  client_reference_id: userId,
  // 14-day trial; omit for immediate billing.
  subscription_data: { trial_period_days: 14 },
});
// Redirect the browser to session.url.
// Payment mode — one-time charge (e.g. a digital download).
const session = await stripe.checkout.sessions.create({
  mode: "payment",
  line_items: [{ price: "price_onetime", quantity: 1 }],
  success_url: `${BASE}/success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${BASE}/`,
  client_reference_id: userId,
});

success_url is a UX redirect, not proof of payment. The customer can close the

tab before it loads, or hit the URL directly. **Grant access from the webhook,

not the redirect.**

The webhook handler (load-bearing)

This is where integrations break. Three non-negotiables:

  • Verify against the RAW body. constructEvent recomputes the signature

over the exact bytes Stripe sent. Any middleware that parses/re-serializes

the body (e.g. express.json() on this route) changes those bytes and you

get No signatures found matching the expected signature.

  • Return 2xx before slow work. Verify, record the event, return 200

immediately. Stripe treats a timeout as failure and retries — for up to 3

days in live mode — so slow handlers cause duplicate delivery.

  • Be idempotent. Events are unordered and may arrive more than once.

Dedupe on event.id, persisting the idempotency record in the *same

transaction* as the business write. Default replay tolerance is 5 minutes.

// Express. The route gets the RAW body; do NOT mount express.json() here.
app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,                          // raw Buffer, untouched
      req.headers["stripe-signature"]!,  // header name is lowercase
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${(err as Error).message}`);
  }

  // Dedupe + record in one transaction; skip if we have seen this event.id.
  const fresh = await recordEventOnce(event.id, event.type);
  if (!fresh) return res.status(200).send(); // already processed; ack and stop

  // Return 2xx FAST. Hand heavy work to a queue/background job if it is slow.
  switch (event.type) {
    case "checkout.session.completed": {
      const s = event.data.object as Stripe.Checkout.Session;
      await grantAccess(s.client_reference_id!, s.customer as string);
      break;
    }
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      const sub = event.data.object as Stripe.Subscription;
      await syncSubscription(sub.customer as string, sub.status); // mirror state
      break;
    }
    case "invoice.payment_failed":
      // dunning lives here; see references/webhook-events.md
      break;
  }
  res.status(200).send();
});

See references/webhook-events.md for the full

event catalog per flow (subscription lifecycle, one-time payment, dunning) and

what each event should do to your DB.

Framework gotchas (raw body)

// Next.js App Router — read the raw text yourself; do not use req.json().
// app/api/webhook/route.ts
export async function POST(req: Request) {
  const body = await req.text();                       // raw string
  const sig = req.headers.get("stripe-signature")!;
  const event = stripe.webhooks.constructEvent(body, sig, secret);
  // ...handle, then:
  return new Response(null, { status: 200 });
}
// Edge / Cloudflare Workers — synchronous crypto is unavailable. Use the async
// API with the Web Crypto provider.
const event = await stripe.webhooks.constructEventAsync(
  body, sig, secret, undefined, Stripe.createSubtleCryptoProvider(),
);

Subscribe to only the events you need

For a subscription paywall, listen to exactly these — listening to "all events"

is discouraged and buries you in noise:

checkout.session.completed, customer.subscription.created,

customer.subscription.updated, customer.subscription.deleted,

invoice.paid, invoice.payment_failed.

Billing portal (self-service)

Do not build cancel/upgrade/update-card UI. Stripe hosts it.

const portal = await stripe.billingPortal.sessions.create({
  customer: stripeCustomerId,             // the Customer you stored
  return_url: `${BASE}/account`,
});
// Redirect to portal.url — it is short-lived; mint it on demand, never cache it.

Idempotency on create calls

A retried POST (network blip, double-click) can create two subscriptions.

Pass an idempotency key derived from the operation, not a random one:

await stripe.checkout.sessions.create(params, {
  idempotencyKey: `checkout:${userId}:${planId}`,
});

Local testing — Stripe CLI, never hand-crafted JSON

stripe login
stripe listen --forward-to localhost:3000/api/webhook   # prints a whsec_… secret
stripe trigger checkout.session.completed                # fire a real test event

Put the printed whsec_… in STRIPE_WEBHOOK_SECRET for local runs. Hand-built

JSON will never pass signature verification — that is the point.

Going live

Compact checklist; depth in references/going-live.md:

  • [ ] apiVersion pinned explicitly in the client.
  • [ ] Swap sk_test_/whsec_ (test) for live values via env, not code.
  • [ ] Create the live webhook endpoint; copy its own signing secret.
  • [ ] Restrict the event subscription to the allowlist above.
  • [ ] Use restricted API keys for the server, not the unrestricted secret.
  • [ ] Idempotency keys on all create calls.
  • [ ] Confirm SCA/3DS is handled (Checkout does this for you).

Anti-patterns

| Bad | Good | Why |

|---|---|---|

| express.json() on the webhook route | express.raw() / req.text() | Re-serialized body breaks the signature → No signatures found matching. |

| No constructEvent — trust the payload | Always verify the signature | Anyone can POST fake events to an unverified endpoint. |

| Grant access on the success_url redirect | Grant on checkout.session.completed | The redirect is UX, not proof; it can be skipped or forged. |

| DB writes, then return 200 | Return 200 fast, queue slow work | Timeouts make Stripe retry → duplicate delivery. |

| Process every delivery | Dedupe on event.id in the write txn | Events are unordered and at-least-once. |

| Hard-coded sk_live_… in source | process.env.STRIPE_SECRET_KEY | Leaked keys = drained account; never commit them. |

| No apiVersion | Pin it explicitly | A dashboard version bump silently changes your API behavior. |

| Poll the API for subscription state | React to webhooks | Polling is slow, rate-limited, and races real events. |

| Listen to all event types | Subscribe to the allowlist | Noise, wasted handling, accidental side effects. |

| Cache the portal/session URL | Mint per request | These URLs are short-lived and single-use. |

See also

  • webhooks — generic, non-Stripe inbound event receivers.
  • invoicing — the invoice document and its legal form.
  • pricing — deciding tiers and amounts before you wire them.
  • secure-coding — secret handling, key restriction.

How to use it

Copy the folder

Take ericrisco/stripe from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.