Stripe, Twilio & SDK Patterns
Stripe and Twilio are the most common paid SDKs in Node B2B APIs. This page covers timeouts, idempotency, test modes, and webhook pairing.
Recipe
Quick-reference recipe card - copy-paste ready.
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-11-20.acacia",
timeout: 10_000,
maxNetworkRetries: 0,
});
const intent = await stripe.paymentIntents.create(
{ amount: 2500, currency: "usd", automatic_payment_methods: { enabled: true } },
{ idempotencyKey: req.header("Idempotency-Key") ?? undefined }
);When to reach for this:
- Billing, subscriptions, SMS OTP, voice alerts
- You need vendor-tested HTTP client behavior and typed errors
Working Example
// src/integrations/payments-service.ts
import Stripe from "stripe";
import twilio from "twilio";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-11-20.acacia",
timeout: 10_000,
maxNetworkRetries: 0,
});
const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID!, process.env.TWILIO_AUTH_TOKEN!);
export async function createPaymentIntent(
amountCents: number,
currency: string,
idempotencyKey: string
) {
return stripe.paymentIntents.create(
{ amount: amountCents, currency, automatic_payment_methods: { enabled: true } },
{ idempotencyKey }
);
}
export async function sendOtpSms(to: string, code: string) {
return twilioClient.messages.create({
to,
from: process.env.TWILIO_FROM_NUMBER!,
body: `Your code is ${code}`,
statusCallback: `${process.env.PUBLIC_API_URL}/webhooks/twilio/sms`,
});
}
// Express route
app.post("/payments/intent", async (req, res, next) => {
try {
const key = req.header("Idempotency-Key");
if (!key) return res.status(400).json({ error: "missing_idempotency_key" });
const pi = await createPaymentIntent(req.body.amountCents, "usd", key);
res.status(201).json({ clientSecret: pi.client_secret, id: pi.id });
} catch (err) {
next(mapStripeError(err));
}
});What this demonstrates:
- Stripe idempotency on create
- Twilio outbound with status callback URL
- Errors mapped before Express error handler
Deep Dive
Stripe Patterns
| Operation | Pattern |
|---|---|
| One-time charge | PaymentIntent |
| SaaS billing | Customer + Subscription + Prices |
| Connect marketplace | stripeAccount header on requests |
| Refunds | Idempotent on refund create with key |
// Retrieve with expand - use sparingly
const sub = await stripe.subscriptions.retrieve(id, { expand: ["customer", "latest_invoice"] });- Prefer separate retrieves or webhooks over deep expand trees
Stripe Webhooks Pairing
- Listen for
payment_intent.succeeded,invoice.paid,customer.subscription.updated - Fulfillment idempotent on
event.id- see Webhook Verification
Twilio Patterns
// Verify caller on inbound - TwiML webhook
app.post("/voice/inbound", express.urlencoded({ extended: false }), (req, res) => {
const twiml = new twilio.twiml.VoiceResponse();
twiml.say("Hello");
res.type("text/xml").send(twiml.toString());
});- Inbound voice/SMS webhooks need signature validation
- Use messaging service SID for scale and sender pool
Test Modes
# Stripe test card 4242 4242 4242 4242
STRIPE_SECRET_KEY=sk_test_...
# Twilio magic numbers for SMS test- CI runs against test credentials only
- Stripe test clock for subscription time travel
Timeouts and Retries
- Stripe: pass
idempotencyKeywhen enabling SDKmaxNetworkRetries - Twilio: handle 21610 (unsubscribed) as business error, not retry
Gotchas
- No idempotency on PaymentIntent create - double charge risk on client retry. Fix: require key.
- Live key in preview env - real charges. Fix: env-segregated secrets manager paths.
- Twilio callback HTTP - must be public HTTPS. Fix: ngrok dev, real URL prod.
- Logging
client_secret- payment leak in logs. Fix: log payment intent id only. - Stripe API version drift - breaking changes on dashboard default. Fix: pin
apiVersionin code.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Paddle/Lemon Squeezy | Merchant of record | Custom Connect marketplace |
| MessageBird/Vonage | Twilio pricing/features | Already standardized on Twilio |
| Adyen/Braintree | Enterprise payment mix | Team knows Stripe |
| Resend + email OTP | Email codes only | SMS deliverability required |
FAQs
Stripe Elements vs Checkout?
Checkout hosted faster PCI scope. Elements custom UI - still use PaymentIntent server-side.
Twilio Verify product?
Verify API handles OTP lifecycle - less custom SMS code than raw Messages API.
Stripe Connect onboarding?
Account Links API for Express/Standard connected accounts - separate webhook handlers.
Partial captures?
PaymentIntent capture_method: manual for auth then capture less amount later.
Webhook ordering?
Do not assume order. Use idempotent state machine on payment intent status.
Strong Customer Authentication?
Stripe handles 3DS flow client-side; listen for requires_action events.
Twilio rate limits?
Queue SMS sends; exponential backoff on 429; messaging service helps routing.
SDK in worker only?
Good pattern - API enqueues, worker calls Stripe for fulfill after webhook.
TypeScript types?
Stripe ships types; Twilio types included - pin versions for consistency.
NestJS Stripe module?
Community @nestjs/stripe or thin wrapper service - same idempotency rules.
Related
- Webhook Verification - inbound events
- Idempotency Keys - HTTP keys
- Integrations Basics - boundaries
- Retry & Outbound Resilience - backoff
- Integrations Best Practices - checklist
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.