Stripe API JSON: Charges, Webhooks, Metadata & Error Handling
Last updated:
Stripe API returns all resources — PaymentIntents, Charges, Customers, Subscriptions, Invoices — as JSON objects with a consistent structure: every object has an id string, an object string (e.g., "payment_intent"), and Unix timestamp integers for created. Stripe amounts are always integers in the smallest currency unit (cents for USD) — $19.99 is 1999 in Stripe JSON, avoiding floating-point precision errors that cause $0.001 discrepancies in financial calculations. This guide covers Stripe API JSON object structure, PaymentIntent and webhook event JSON, metadata key-value pairs, idempotency keys, Stripe error JSON format, and Node.js SDK TypeScript patterns.
Stripe API Object JSON Structure
Every Stripe API response is a JSON object with a predictable set of common fields: id (a string with a type prefix — pi_ for PaymentIntents, cus_ for Customers, ch_ for Charges), object (the resource type string), created (Unix timestamp integer), and livemode (boolean distinguishing test vs. live mode). List endpoints return a data array, has_more boolean, and url string — paginate by passing the last object's id as starting_after in the next request.
// PaymentIntent JSON — core fields
{
"id": "pi_3OqXyZ2eZvKYlo2C1ZxBCDEF",
"object": "payment_intent",
"amount": 1999, // integer cents — $19.99
"currency": "usd", // ISO 4217 lowercase
"status": "succeeded", // state machine string
"payment_method": "pm_1OqXyZ2eZvKYlo2CabcdEFGH",
"client_secret": "pi_3OqXyZ2eZvKYlo2C1ZxBCDEF_secret_abc123",
"created": 1716124800, // Unix timestamp (seconds)
"livemode": false,
"metadata": {
"order_id": "ORD-123",
"user_id": "USR-456"
},
"next_action": null // populated for 3D Secure flows
}
// List response — pagination envelope
{
"object": "list",
"url": "/v1/payment_intents",
"has_more": true,
"data": [
{ "id": "pi_abc", "object": "payment_intent", "amount": 1999, ... },
{ "id": "pi_def", "object": "payment_intent", "amount": 4999, ... }
]
}
// Paginate — pass the last id as starting_after
// GET /v1/payment_intents?limit=10&starting_after=pi_def
// Expand related objects inline — avoids a second API call
// GET /v1/payment_intents/pi_abc?expand[]=customer&expand[]=payment_method
{
"id": "pi_abc",
"object": "payment_intent",
"customer": {
"id": "cus_123",
"object": "customer",
"email": "alice@example.com",
"name": "Alice Smith"
// ... full Customer object
},
"payment_method": {
"id": "pm_456",
"object": "payment_method",
"type": "card",
"card": {
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2027
}
}
}Related objects in Stripe JSON responses are returned as ID strings by default (e.g., "customer": "cus_123"). Add expand[]=customer to any retrieve or list request to inline the full object — this eliminates an extra API round trip at the cost of a larger JSON payload. Expansion uses dot notation for nested fields: expand[]=customer.default_source expands both the customer and their default payment source.
PaymentIntent JSON: Creating and Confirming Payments
A PaymentIntent represents the full lifecycle of a payment. Creating one returns a JSON object with a client_secret that the frontend uses to confirm the payment via Stripe.js — the backend never handles raw card data. The PaymentIntent status follows a state machine: requires_payment_method → requires_confirmation → requires_action (3D Secure) → processing → succeeded or canceled.
// Create a PaymentIntent — POST /v1/payment_intents
// Request body (application/x-www-form-urlencoded)
amount=1999¤cy=usd&payment_method_types[]=card&metadata[order_id]=ORD-123
// Response JSON
{
"id": "pi_3OqXyZ2eZvKYlo2C1ZxBCDEF",
"object": "payment_intent",
"amount": 1999,
"currency": "usd",
"status": "requires_payment_method",
"payment_method_types": ["card"],
"client_secret": "pi_3OqXyZ2eZvKYlo2C1ZxBCDEF_secret_xyz789",
"metadata": { "order_id": "ORD-123" }
}
// Confirm with a payment method — POST /v1/payment_intents/pi_.../confirm
payment_method=pm_card_visa&return_url=https://example.com/return
// Succeeded response
{
"id": "pi_3OqXyZ2eZvKYlo2C1ZxBCDEF",
"status": "succeeded",
"amount_received": 1999,
"payment_method": "pm_1OqXyZ2eZvKYlo2CabcdEFGH",
"latest_charge": "ch_3OqXyZ2eZvKYlo2C1zBCDEFG",
"next_action": null
}
// 3D Secure required — next_action populated
{
"id": "pi_3OqXyZ2eZvKYlo2C1ZxBCDEF",
"status": "requires_action",
"next_action": {
"type": "use_stripe_sdk",
"use_stripe_sdk": {
"type": "three_d_secure_redirect",
"stripe_js": "https://js.stripe.com/v3/authenticate-payment/...",
"source": "src_abc123"
}
}
}
// PaymentIntent status state machine
// requires_payment_method → requires_confirmation → requires_action → processing → succeeded
// ↘ canceled
// Cancel: POST /v1/payment_intents/pi_.../cancel
// Capture (manual): POST /v1/payment_intents/pi_.../capture?amount_to_capture=1999When using the Node.js SDK, pass automatic_payment_methods: { enabled: true } to let Stripe automatically enable the optimal payment methods for your currency and region — this avoids hard-coding payment_method_types. The client_secretshould be passed to the frontend as a JSON response field; never log or expose it beyond the current user's browser session.
Stripe Webhook Event JSON Structure
Stripe webhooks deliver event JSON to your endpoint via HTTP POST. The event envelope wraps the resource that triggered the event in data.object — a complete JSON snapshot of the Stripe resource at the time of the event. Verifying the Stripe-Signature header with your webhook signing secret is mandatory; without it, any actor can send forged events to your endpoint.
// Webhook event JSON envelope
{
"id": "evt_3OqXyZ2eZvKYlo2C1ABCDEFG",
"object": "event",
"type": "payment_intent.succeeded", // primary dispatch key
"livemode": false,
"created": 1716124800,
"data": {
"object": {
// Complete PaymentIntent JSON at time of event
"id": "pi_3OqXyZ2eZvKYlo2C1ZxBCDEF",
"object": "payment_intent",
"amount": 1999,
"currency": "usd",
"status": "succeeded",
"metadata": { "order_id": "ORD-123" }
},
"previous_attributes": {
// Only present for *.updated events — only changed fields
"status": "processing"
}
},
"api_version": "2024-11-20"
}
// Node.js webhook handler — Express
import express from 'express'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const app = express()
// CRITICAL: use raw body parser BEFORE json() for the webhook route
app.post(
'/api/webhooks',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'] as string
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
req.body, // raw Buffer — NOT parsed JSON
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return res.status(400).send('Webhook Error')
}
// Dispatch on event type
switch (event.type) {
case 'payment_intent.succeeded': {
const pi = event.data.object as Stripe.PaymentIntent
await fulfillOrder(pi.metadata.order_id, pi.id)
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription
await cancelSubscription(sub.metadata.user_id)
break
}
default:
console.log('Unhandled event type:', event.type)
}
// Return 200 immediately — process side effects asynchronously
res.json({ received: true })
}
)Stripe retries webhook delivery 5 times over 3 days if your endpoint returns a non-2xx status code or times out (30-second timeout). Use the event id for idempotency: store processed event IDs in your database and skip re-processing if the ID already exists. The previous_attributes field in data is only present on *.updated events and contains only the fields that changed — compare it against data.object to determine what specifically changed.
Metadata: Custom JSON Key-Value on Stripe Objects
The metadata field is a flat JSON object of string key-value pairs available on most Stripe objects. It is the primary mechanism for linking Stripe resources back to your own database records — store your internal IDs here at creation time, then read them back from webhook events to look up the corresponding records in your system.
// Set metadata on PaymentIntent creation (Node.js SDK)
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const paymentIntent = await stripe.paymentIntents.create({
amount: 4999,
currency: 'usd',
metadata: {
order_id: 'ORD-789', // your internal order ID
user_id: 'USR-456', // your internal user ID
product_sku: 'WIDGET-PRO', // product reference
session_id: 'sess_abc123', // checkout session
},
})
// Metadata constraints:
// - Max 50 keys per object
// - Max 40 characters per key name
// - Max 500 characters per value
// - All values must be strings (numbers/booleans must be .toString())
// Update metadata — existing keys not mentioned are PRESERVED
const updated = await stripe.paymentIntents.update('pi_abc', {
metadata: {
order_id: 'ORD-789', // keep existing
fulfillment_status: 'sent', // add new key
},
})
// Remove a metadata key — set value to empty string
const cleared = await stripe.paymentIntents.update('pi_abc', {
metadata: {
session_id: '', // empty string removes the key
},
})
// Read metadata from a webhook event
app.post('/api/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature']!, process.env.STRIPE_WEBHOOK_SECRET!)
if (event.type === 'payment_intent.succeeded') {
const pi = event.data.object as Stripe.PaymentIntent
const orderId = pi.metadata.order_id // 'ORD-789'
const userId = pi.metadata.user_id // 'USR-456'
// Look up your order by orderId and fulfill it
}
res.json({ received: true })
})
// Search Stripe Dashboard by metadata — filter in UI:
// metadata[order_id]: ORD-789
// API: GET /v1/payment_intents?metadata[order_id]=ORD-789 (requires Stripe Search API)Metadata values must always be strings — convert numbers and booleans with .toString() before setting them. Metadata is visible in the Stripe Dashboard on each resource detail page, making it easy to look up the corresponding record in your system during support investigations. For Customer objects, metadata is especially useful for storing your internal user ID, allowing you to retrieve the Stripe customer by their metadata if you do not store the cus_ ID in your database.
Stripe Error JSON: Types, Codes, and Decline Codes
Stripe error responses return HTTP 4xx status codes with a JSON body containing a top-level error object. The type field categorizes the error broadly; code provides the specific machine-readable reason; decline_code (only on card errors) gives the card network's reason for declining. Use type and code for programmatic handling — never parse the message string, which can change between Stripe API versions.
// Card error — HTTP 402
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"param": null,
"charge": "ch_3OqXyZ2eZvKYlo2C1ABCDEFG"
}
}
// Validation error — HTTP 400 (bad request parameters)
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid_integer",
"message": "Invalid integer: amount must be a positive integer.",
"param": "amount" // identifies the offending parameter
}
}
// API error — HTTP 500 (Stripe server-side issue, safe to retry)
{
"error": {
"type": "api_error",
"message": "An error occurred in our servers. We have been notified."
}
}
// Node.js SDK error handling with instanceof
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
async function chargeCustomer(paymentMethodId: string, amountCents: number) {
try {
const pi = await stripe.paymentIntents.create({
amount: amountCents,
currency: 'usd',
payment_method: paymentMethodId,
confirm: true,
})
return { success: true, paymentIntentId: pi.id }
} catch (err) {
if (err instanceof Stripe.errors.StripeCardError) {
// card_error — requires user action (show decline message)
const declineCode = err.decline_code
switch (declineCode) {
case 'insufficient_funds':
return { success: false, userMessage: 'Insufficient funds. Try a different card.' }
case 'card_declined':
return { success: false, userMessage: 'Card declined. Contact your bank.' }
case 'expired_card':
return { success: false, userMessage: 'Card expired. Update your card details.' }
default:
return { success: false, userMessage: 'Card error. Try a different payment method.' }
}
} else if (err instanceof Stripe.errors.StripeInvalidRequestError) {
// invalid_request_error — developer bug (log, fix code)
console.error('Stripe invalid request:', err.code, err.param)
throw err
} else if (err instanceof Stripe.errors.StripeAPIError) {
// api_error — Stripe server issue (safe to retry with backoff)
console.error('Stripe API error — retrying:', err.message)
throw err
} else if (err instanceof Stripe.errors.StripeRateLimitError) {
// rate_limit_error — slow down request rate
await new Promise(r => setTimeout(r, 1000))
throw err
}
throw err
}
}
// Common decline codes and handling strategy
// insufficient_funds → ask user to try another card (do not retry same card)
// do_not_honor → contact bank required (do not retry)
// lost_card → fraud — do not inform user of reason, flag account
// stolen_card → fraud — do not inform user of reason, flag account
// expired_card → prompt user to update card details
// incorrect_cvc → prompt user to re-enter CVC
// incorrect_zip → prompt user to re-enter billing zipFor fraud-related decline codes (lost_card, stolen_card, fraudulent), do not reveal the specific reason to the end user — display a generic "card declined" message to avoid tipping off bad actors. The param field on invalid_request_error responses identifies exactly which request parameter caused the error, making API integration debugging much faster. See the JSON API design guide for general error response patterns.
Idempotency Keys and Safe Retries
Idempotency keys guarantee that retrying a failed POST request does not create duplicate Stripe objects. This is critical for payments: if a /v1/payment_intents POST times out and you do not know whether Stripe received it, retrying without an idempotency key may charge the customer twice. Send the same Idempotency-Key header with the retry to receive the cached response from the original request.
import { randomUUID } from 'crypto'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
// Generate a UUID v4 idempotency key per payment attempt
// Store it alongside the order so you can reuse it on retry
async function createPaymentWithIdempotency(orderId: string, amountCents: number) {
// Use a deterministic key tied to the order — safe to retry
const idempotencyKey = `order-${orderId}-payment`
// Or generate UUID v4 and store it: const idempotencyKey = randomUUID()
const paymentIntent = await stripe.paymentIntents.create(
{
amount: amountCents,
currency: 'usd',
metadata: { order_id: orderId },
},
{
idempotencyKey, // SDK option — adds Idempotency-Key header
}
)
return paymentIntent
}
// Raw fetch with Idempotency-Key header
async function createPaymentRaw(orderId: string, idempotencyKey: string) {
const res = await fetch('https://api.stripe.com/v1/payment_intents', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.STRIPE_SECRET_KEY}`,
'Content-Type': 'application/x-www-form-urlencoded',
'Idempotency-Key': idempotencyKey, // same key = cached response
},
body: new URLSearchParams({
amount: String(amountCents),
currency: 'usd',
}),
})
return res.json()
}
// Idempotency behavior:
// - Same key + same params within 24h → returns cached JSON response (HTTP 200)
// - Same key + DIFFERENT params → HTTP 400, type: idempotency_error
// - Same key after 24h → treated as new request (key reusable)
// - Idempotency applies only to POST — GET requests are naturally idempotent
// Safe retry with exponential backoff (for api_error and network timeouts)
async function retryableCreate(orderId: string, amountCents: number, maxRetries = 3) {
const idempotencyKey = `order-${orderId}-payment`
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await stripe.paymentIntents.create(
{ amount: amountCents, currency: 'usd' },
{ idempotencyKey }
)
} catch (err) {
if (err instanceof Stripe.errors.StripeAPIError || err instanceof Stripe.errors.StripeConnectionError) {
if (attempt < maxRetries - 1) {
await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 500))
continue // retry with SAME idempotency key — safe
}
}
throw err // card_error, invalid_request_error — do not retry
}
}
}Use deterministic idempotency keys (e.g., order-${orderId}-payment) rather than random UUIDs when you need to retry across process restarts — you can reconstruct the same key from your order ID without storing it separately. However, if a customer legitimately tries to pay twice for the same order (e.g., after updating their cart), generate a new key for the new attempt. Only retry on api_error and network-level errors; never retry on card_error or invalid_request_error with the same parameters.
Node.js Stripe SDK TypeScript Patterns
The stripe npm package ships with first-class TypeScript types for all Stripe resources and API parameters. Initializing the SDK with your secret key and API version pins the types to a specific Stripe API version, preventing silent breaking changes when Stripe releases new API versions. The SDK handles request signing, pagination helpers, and webhook signature verification out of the box.
// Install: npm install stripe
// Types included — no @types/stripe needed
import Stripe from 'stripe'
// Initialize with API version pinning (always specify a version)
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20',
typescript: true, // enables stricter TypeScript types
})
// Type-safe PaymentIntent creation
async function createIntent(
amountCents: number,
customerId: string,
metadata: Record<string, string>
): Promise<Stripe.PaymentIntent> {
return stripe.paymentIntents.create({
amount: amountCents, // TypeScript enforces number
currency: 'usd',
customer: customerId,
automatic_payment_methods: { enabled: true },
metadata,
})
}
// Type-safe Customer operations
async function getOrCreateCustomer(email: string, userId: string): Promise<Stripe.Customer> {
// Search by metadata to find existing customer
const existing = await stripe.customers.search({
query: `metadata['user_id']:'${userId}'`,
limit: 1,
})
if (existing.data.length > 0) return existing.data[0]
return stripe.customers.create({
email,
metadata: { user_id: userId },
})
}
// Webhook with constructEvent — raw body required
import { buffer } from 'micro' // or express.raw()
import type { NextApiRequest, NextApiResponse } from 'next'
export const config = { api: { bodyParser: false } }
export default async function webhookHandler(req: NextApiRequest, res: NextApiResponse) {
const rawBody = await buffer(req)
const sig = req.headers['stripe-signature'] as string
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
return res.status(400).json({ error: 'Invalid signature' })
}
// TypeScript narrows the event data type with type assertion
if (event.type === 'payment_intent.succeeded') {
const pi = event.data.object as Stripe.PaymentIntent
// pi.amount, pi.metadata, pi.status are all typed
console.log('Succeeded:', pi.id, pi.amount)
}
res.json({ received: true })
}
// Unit test — generate test webhook header without Stripe CLI
import Stripe from 'stripe'
const stripe = new Stripe('sk_test_...')
function buildTestWebhookRequest(eventPayload: object, secret: string) {
const payload = JSON.stringify(eventPayload)
const header = stripe.webhooks.generateTestHeaderString({
payload,
secret,
})
return { payload, header }
}
// Pagination — auto-iterate all pages with autopagingEach
async function getAllCustomers(): Promise<Stripe.Customer[]> {
const customers: Stripe.Customer[] = []
for await (const customer of stripe.customers.list({ limit: 100 })) {
customers.push(customer)
}
return customers
}
// StripeError type guard
import { errors as StripeErrors } from 'stripe'
function isStripeCardError(err: unknown): err is StripeErrors.StripeCardError {
return err instanceof StripeErrors.StripeCardError
}Always pin the apiVersion when initializing the Stripe SDK — without it, Stripe uses your account's default API version, which may change when Stripe rolls out updates, silently altering JSON response shapes. The SDK's autopagingEach and autoPagingToArray helpers iterate through all pages of list endpoints automatically, handling the starting_after cursor pagination under the hood. For webhook handlers in Next.js App Router, use NextRequest and read the raw body with req.arrayBuffer() converted to a Buffer — the App Router does not support bodyParser: false configuration.
Key Terms
- PaymentIntent
- A Stripe API object representing the full lifecycle of a single payment attempt. A PaymentIntent JSON object tracks the payment's
statusthrough a state machine — fromrequires_payment_methodthrough confirmation, optional 3D Secure authentication (requires_action), processing, and finalsucceededorcanceledstates. Theclient_secretfield is passed to the frontend Stripe.js to confirm the payment without exposing your secret API key. Theamountis always an integer in the smallest currency unit (cents for USD). A single PaymentIntent can have multiple confirmation attempts and is the recommended payment flow for Stripe integrations as of 2019. - idempotency key
- A unique string sent as the
Idempotency-KeyHTTP request header on Stripe POST requests, guaranteeing that retrying the same request produces the same result without creating duplicate resources. Stripe stores the idempotency key, request parameters, and response for 24 hours; subsequent requests with the same key within that window return the cached JSON response immediately. Idempotency keys are essential for payment flows where a network timeout leaves the client uncertain whether the original request succeeded. Best practice is to generate a UUID v4 per attempt or use a deterministic key derived from stable identifiers like order IDs. - webhook event
- A JSON payload that Stripe sends via HTTP POST to your registered endpoint when a Stripe resource changes state. The event JSON has a consistent envelope with
id,type(e.g.,payment_intent.succeeded,customer.subscription.deleted),data.object(the full JSON snapshot of the affected resource), anddata.previous_attributes(only on*.updatedevents — only the changed fields). Stripe signs every webhook request with an HMAC-SHA256 signature in theStripe-Signatureheader. Stripe retries delivery 5 times over 3 days on non-2xx responses or timeouts. Process webhooks asynchronously — respond with HTTP 200 immediately. - metadata
- A flat JSON object of string key-value pairs stored on Stripe resources (PaymentIntent, Customer, Charge, Subscription, etc.) for associating your own internal identifiers with Stripe objects. Metadata is returned in all API responses and webhook events for that resource, making it the primary mechanism for mapping Stripe objects back to records in your own database. Constraints: maximum 50 keys, 40 characters per key name, 500 characters per value. All values must be strings. Set a key to an empty string to remove it. Metadata is visible in the Stripe Dashboard and searchable via the Stripe Search API.
- decline code
- A string field in Stripe error JSON (
error.decline_code) that provides the card network's specific reason for declining a card charge. Decline codes are more granular than theerror.codefield (which is alwayscard_declinedfor a generic decline). Common decline codes includeinsufficient_funds,do_not_honor(generic bank refusal),expired_card,incorrect_cvc,lost_card, andstolen_card. For fraud-related codes (lost_card,stolen_card,fraudulent), Stripe recommends displaying a generic "card declined" message rather than the specific reason to avoid tipping off bad actors. - expand
- A Stripe API query parameter that replaces a related object's ID string in the JSON response with the full JSON object inline. By default, Stripe returns related resources as ID strings (e.g.,
"customer": "cus_123") to keep responses compact. Addingexpand[]=customerto a request replaces that string with the complete Customer object, eliminating an additional API round trip. Use dot notation for nested expansion:expand[]=customer.default_source. The Node.js SDK accepts anexpandarray in the request options object. Only expand fields you need — expansion increases response payload size and may slow responses for large nested objects.
FAQ
What does Stripe API JSON look like for a PaymentIntent?
A Stripe PaymentIntent JSON object has a consistent structure: an id string prefixed with pi_, an object field set to "payment_intent", and Unix timestamp integers for created. Core fields include amount (integer cents — $19.99 is 1999), currency (three-letter ISO code, lowercase: "usd"), status (state machine string: requires_payment_method, requires_confirmation, requires_action, processing, succeeded, or canceled), and client_secret (used on the frontend to confirm the payment). The metadata object holds up to 50 custom string key-value pairs. The next_action field is populated when 3D Secure authentication is required and contains a type field plus the corresponding action data for Stripe.js to handle.
How do I handle Stripe webhook JSON events?
Read the raw request body as a Buffer before any JSON parsing middleware — the HMAC signature verification requires the exact raw bytes. Retrieve the Stripe-Signature header and call stripe.webhooks.constructEvent(rawBody, signature, webhookSecret) to verify and parse the event. Dispatch on the type field (e.g., payment_intent.succeeded) using a switch statement. Return HTTP 200 immediately and process any side effects (database writes, emails) asynchronously to avoid Stripe's 30-second timeout. Use the event id for deduplication — store processed event IDs and skip re-processing to handle Stripe's 5 retry attempts. For *.updated events, check data.previous_attributes to see only the changed fields rather than diffing the full object manually.
Why does Stripe use integer amounts in JSON instead of decimals?
Stripe uses integers in the smallest currency unit (cents for USD, pence for GBP) to avoid floating-point precision errors inherent in IEEE 754 double-precision floats. The float 19.99 cannot be represented exactly in binary — it is stored as approximately 19.9900000000000002 — which causes off-by-one-cent discrepancies when multiplied, summed, or rounded across many transactions. Using integer cents means all arithmetic is exact. $19.99 is 1999, £0.50 is 50, €100.00 is 10000. For zero-decimal currencies like JPY, the amount represents the full unit — 1000 yen is 1000, not 100000. Always check whether a currency is zero-decimal before dividing by 100 for display; Stripe's documentation lists all zero-decimal currencies.
How do I add custom metadata to Stripe objects?
Pass a metadata key in the create or update request body containing a JSON object of string key-value pairs. For example, when creating a PaymentIntent: { "metadata": { "order_id": "ORD-123", "user_id": "USR-456" } }. Constraints: up to 50 keys, max 40 characters per key name, max 500 characters per value, all values must be strings. To update metadata on an existing object, PATCH the resource with a new metadata object — keys you include are updated, keys you omit are preserved. To remove a specific key, set its value to an empty string "". Metadata is returned in all API responses and webhook events for that resource, making it the standard way to link Stripe objects back to your internal database records.
What does Stripe error JSON look like and how do I handle it?
Stripe error responses have HTTP 4xx status codes and a JSON body with a top-level error object containing: type (broad category: card_error, invalid_request_error, authentication_error, rate_limit_error, or api_error), code (specific machine-readable code: card_declined, insufficient_funds, parameter_missing, etc.), message (human-readable — never display raw to users), param (for invalid_request_error — identifies the bad parameter), and decline_code (for card errors — the network's specific decline reason). Handle by switching on type first: show a user-facing message for card_error, log and fix for invalid_request_error, and retry with backoff for api_error and rate_limit_error.
What is an idempotency key in Stripe API JSON?
An idempotency key is a unique string sent as the Idempotency-Key HTTP request header on POST requests to Stripe, ensuring that retrying a failed request does not create duplicate resources. When you retry a timed-out payment POST with the same idempotency key within 24 hours, Stripe returns the cached JSON response from the original request instead of processing a new payment — preventing double charges. Generate a UUID v4 per payment attempt, or use a deterministic key like order-${orderId}-payment that you can reconstruct without storing. Sending the same key with different request parameters returns an idempotency_error. After 24 hours the key expires and can be reused. Idempotency keys only apply to POST requests — GET requests are naturally idempotent.
How do I expand related Stripe objects in JSON responses?
By default, Stripe JSON responses include related objects as ID strings (e.g., "customer": "cus_123"). Add expand[] query parameters to any retrieve or list request to inline the full object: GET /v1/payment_intents/pi_abc?expand[]=customer&expand[]=payment_method. In the Node.js SDK, pass an expand array in the request options: stripe.paymentIntents.retrieve("pi_abc", { expand: ["customer", "payment_method"] }). Use dot notation for nested expansion: customer.default_source expands the customer and then their default source. Only expand what you actually need — each expanded field increases response payload size. Expansion is available on retrieve and list endpoints; it is not available on create or update endpoints.
How do I test Stripe webhooks locally with JSON?
Install the Stripe CLI and run stripe listen --forward-to localhost:3000/api/webhooks — this tunnels Stripe webhook events to your local server and prints a local signing secret starting with whsec_. Set STRIPE_WEBHOOK_SECRET in your .env.local to this local secret (different from your production webhook secret). Trigger specific events with stripe trigger payment_intent.succeeded — the CLI sends a real webhook JSON payload to your endpoint with a valid signature. For unit tests without the CLI, use stripe.webhooks.generateTestHeaderString({ payload: jsonString, secret: webhookSecret }) to generate a valid Stripe-Signature header and pass it alongside a constructed event JSON object to test your constructEvent() and handler logic in isolation with Jest or Vitest.
Further reading and primary sources
- Stripe API Reference: PaymentIntents — Full Stripe PaymentIntent JSON object reference including all fields, statuses, and API methods
- Stripe Webhooks: Build a Webhook Endpoint — Official Stripe guide to webhook event JSON, signature verification, and delivery retries
- Stripe Node.js SDK on GitHub — TypeScript-first Stripe Node.js SDK with type definitions for all Stripe resources
- Stripe Error Codes Reference — Complete reference for Stripe error JSON types, codes, and decline codes with handling guidance
- Stripe Idempotency Keys — Official documentation for idempotency keys, retry behavior, and 24-hour key expiration