stripe
stripe (stripe-node) is Stripe's official server-side SDK for Node.js. It wraps the entire Stripe REST API (payments, customers, subscriptions, invoices, Connect, Checkout) in typed resource methods like stripe.paymentIntents.create(), and adds the operational pieces you would otherwise hand-roll: automatic retries with idempotency keys, webhook signature verification, auto-pagination, and TypeScript types generated from the latest API version. It is server-only; card collection in the browser is Stripe.js, a different package.
If you use Stripe on a Node backend there is no real debate: this is the SDK, it is actively maintained (pushed within the last day), and the retry/webhook/pagination helpers are worth it. Budget real time for webhook raw-body handling and for TypeScript churn across API version upgrades.
Use it if
- You are accepting payments with Stripe from a Node, Deno, or serverless backend; this is the official, always-current SDK
- You want webhook signature verification (webhooks.constructEvent) instead of writing your own HMAC check
- You need to walk large lists (customers, charges) and want for-await auto-pagination instead of manual cursor handling
- You want typed request/response params: TypeScript definitions ship with the package and track the latest Stripe API version
- You are not using Stripe as your payment processor; this SDK is useless outside Stripe's API
- You need it in the browser or React Native; it is server-side only and embedding it would leak your secret key
- You need types pinned to an old Stripe API version: types only reflect the latest version, and the README's own advice for older versions is scattering @ts-ignore comments through your code
- You dislike frequent majors: Stripe cuts a new major with every backwards-incompatible API version (v22 now), and minor upgrades can surface new TypeScript errors by design under their versioning policy
Setup reality
npm install stripe is trivial (zero runtime dependencies, Node 18+ required) but the integration around it is where the time goes. You need a secret key from the dashboard, and since v17 instantiating the client at import time without the key present breaks build steps, so you end up writing a lazy getter. Webhooks are the classic trap: constructEvent needs the raw request body, so Express/Next.js body parsing must be disabled for that route or verification fails. Telemetry to Stripe is on by default. Testing means juggling sk_test keys and the Stripe CLI for webhook forwarding.
Patterns
Initialize the client safelyinit-client
import Stripe from 'stripe';
let _stripe: Stripe | null = null;
export const getStripe = (): Stripe => {
if (!_stripe) {
_stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
}
return _stripe;
};Since v17, constructing Stripe at module top level fails builds where the env var is absent; the README recommends this lazy pattern or a placeholder key.
Create a PaymentIntentcreate-payment-intent
const intent = await stripe.paymentIntents.create({
amount: 2000, // smallest currency unit: 2000 = $20.00
currency: 'usd',
automatic_payment_methods: { enabled: true },
});
// send intent.client_secret to the browser for Stripe.js to confirmAmounts are integers in the smallest currency unit; passing 20.00 for twenty dollars is the classic first-week bug.
Verify a webhook signature (Express)verify-webhook
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // raw Buffer, not parsed JSON
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
if (event.type === 'payment_intent.succeeded') { /* fulfill */ }
res.json({ received: true });
});constructEvent needs the raw body exactly as received; any global express.json() middleware on this route breaks verification with a signature error.
Create a hosted Checkout sessioncreate-checkout-session
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: 'price_123', quantity: 1 }],
success_url: 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://example.com/cancel',
});
// redirect the user to session.urlThe literal {CHECKOUT_SESSION_ID} placeholder is substituted by Stripe; do not template it yourself.
Iterate all results with auto-paginationauto-paginate-list
for await (const customer of stripe.customers.list({ limit: 100 })) {
await handle(customer);
if (shouldStop()) break;
}
// or collect a bounded batch:
const charges = await stripe.charges.list().autoPagingToArray({ limit: 1000 });autoPagingToArray refuses unbounded collection; you must pass a limit, and for-await is the safer default for big lists.
Make a create call idempotentidempotency-key
const charge = await stripe.paymentIntents.create(
{ amount: 2000, currency: 'usd', automatic_payment_methods: { enabled: true } },
{ idempotencyKey: `order-${orderId}` }
);Reusing the same key within 24 hours returns the original response instead of double-charging; derive it from your own order ID, not a random UUID per attempt.
Configure automatic network retriesconfigure-retries
const stripe = new Stripe(key, {
maxNetworkRetries: 2, // exponential backoff
timeout: 20000, // default is 80000 ms
});
// per-request override:
await stripe.customers.create({ email }, { maxNetworkRetries: 3 });Since v13 the default is already 1 retry with idempotency keys added automatically; set 0 to disable, and remember the default timeout is a long 80 seconds.
Expand a nested object in one callexpand-nested-objects
const intent = await stripe.paymentIntents.retrieve('pi_123', {
expand: ['customer', 'latest_charge'],
});
const email = (intent.customer as Stripe.Customer).email;Expandable fields are typed as string | Object, so TypeScript forces a cast; without expand you only get the id string.
Act on behalf of a Connect accountconnect-account-call
const transactions = await stripe.balanceTransactions.list(
{ limit: 10 },
{ stripeAccount: 'acct_1ABC...' }
);stripeAccount is a per-request option, not a param; putting it inside the first argument is silently wrong.
Handle declined cards and API errorshandle-card-errors
try {
await stripe.paymentIntents.confirm('pi_123');
} catch (err) {
if (err instanceof Stripe.errors.StripeCardError) {
console.log(err.code, err.decline_code); // e.g. 'card_declined', 'insufficient_funds'
} else if (err instanceof Stripe.errors.StripeRateLimitError) {
// back off and retry
} else {
throw err;
}
}Card declines are thrown as errors, not returned as statuses; log err.requestId when contacting Stripe support.
Mock a signed webhook event in teststest-webhook-locally
const payload = JSON.stringify({ id: 'evt_test', object: 'event' });
const header = stripe.webhooks.generateTestHeaderString({
payload,
secret: 'whsec_test_secret',
});
const event = stripe.webhooks.constructEvent(payload, header, 'whsec_test_secret');This tests your verification path without the Stripe CLI; for live-ish local testing use 'stripe listen --forward-to localhost:3000/webhook'.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| braintree | npm | Your processor is Braintree/PayPal; its official Node SDK covers similar ground for that platform |
| square | npm | You are on Square for payments, especially with in-person/POS needs |
| @paypal/paypal-server-sdk | npm | You only need PayPal buttons and orders rather than a full billing stack |