Introduction
By day five of integrating with Stripe, you've already set up your keys and authenticated requests. Now comes the foundation that makes or breaks a smooth payment integration: understanding how the Stripe API is actually designed. The Stripe API basics: a practical Payments & Stripe guide means learning not just what endpoints exist, but how to use them efficiently, what patterns recur across every resource, and which features save you from making multiple round trips to the backend.
We've seen teams rebuild entire request layers because they didn't understand API versioning, or burn through rate limits because they didn't know about the expand parameter. This article covers the design principles that run through every Stripe endpoint, so you can write integration code that scales from your first test charge through to millions of transactions.
RESTful resources with predictable URLs
Stripe's API follows REST conventions that become obvious once you see the pattern. Every resource lives at a predictable URL structure: collections at plural nouns, individual resources identified by ID. This consistency means once you understand one endpoint, you can predict the shape of others.
A payment intent lives at /v1/payment_intents for the collection, and /v1/payment_intents/{id} for a specific one. A customer is /v1/customers and /v1/customers/{id}. A charge is /v1/charges and /v1/charges/{id}. HTTP verbs map to actions: POST to create, GET to retrieve, POST again to update, DELETE to remove where applicable.
Here's what a basic flow looks like in practice:
// Create a payment intent
const response = await fetch('https://api.stripe.com/v1/payment_intents', {
method: 'POST',
headers: {
'Authorization': `Bearer ${STRIPE_SECRET_KEY}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
amount: '2000', // cents
currency: 'usd',
payment_method: 'pm_card_visa',
confirm: 'true',
}),
});
const paymentIntent = await response.json();
console.log(paymentIntent.id); // pi_1234567890
// Retrieve that specific payment intent
const getResponse = await fetch(
`https://api.stripe.com/v1/payment_intents/${paymentIntent.id}`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${STRIPE_SECRET_KEY}`,
},
}
);
const retrieved = await getResponse.json();
console.log(retrieved.status); // "succeeded"
This predictability matters because it means your HTTP client logic becomes generic. You're not learning 50 different endpoint shapes; you're learning one pattern and applying it everywhere. When you add support for a new Stripe resource type, the URL structure and HTTP semantics are already familiar.
In production integrations, we typically wrap this into a request helper that handles headers, error codes, and retries automatically, so individual calls become clean one-liners.
Expand related objects in one call
Here's where Stripe's API design really shines and where many teams miss an optimization opportunity. When you fetch a resource, related objects come back as IDs by default. A payment intent returns customer: "cus_ABC123" instead of the full customer object. This is efficient for bandwidth, but it means you'd normally need two API calls to show a user both the intent and its customer details.
The expand parameter solves this. You ask Stripe to include the full nested object in a single response, avoiding a second round trip.
// Without expand: customer is just a string ID
const basicResponse = await fetch(
'https://api.stripe.com/v1/payment_intents/pi_1234567890',
{
method: 'GET',
headers: {
'Authorization': `Bearer ${STRIPE_SECRET_KEY}`,
},
}
);
const basic = await basicResponse.json();
console.log(basic.customer); // "cus_ABC123"
// With expand: customer is the full object
const expandResponse = await fetch(
'https://api.stripe.com/v1/payment_intents/pi_1234567890?expand[]=customer',
{
method: 'GET',
headers: {
'Authorization': `Bearer ${STRIPE_SECRET_KEY}`,
},
}
);
const expanded = await expandResponse.json();
console.log(expanded.customer.email); // "user@example.com"
console.log(expanded.customer.name); // "Jane Doe"
You can chain expansions too. If you need the payment method details inside a payment intent, and the payment method's billing details:
// Multiple nested expansions
const url = new URL('https://api.stripe.com/v1/payment_intents/pi_1234567890');
url.searchParams.append('expand[]', 'customer');
url.searchParams.append('expand[]', 'payment_method');
url.searchParams.append('expand[]', 'payment_method.billing_details');
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
'Authorization': `Bearer ${STRIPE_SECRET_KEY}`,
},
});
const data = await response.json();
console.log(data.payment_method.billing_details.address.country);
This pattern is critical when you're rendering a dashboard or confirmation page. Instead of kicking off three sequential API calls and waiting for the slowest one, you batch the data fetch into a single request. In high-traffic scenarios, this reduces API latency and costs.
Pagination with starting_after
Stripe doesn't support offset-based pagination like ?offset=20&limit=20. Instead, it uses cursor-based pagination with the starting_after parameter. This approach is more efficient at scale and prevents inconsistencies when records are deleted or added during pagination.
When you list resources, the response includes a has_more flag and the last object has an id. You pass that ID as starting_after to get the next page.
// First page
const page1Response = await fetch(
'https://api.stripe.com/v1/charges?limit=10',
{
method: 'GET',
headers: {
'Authorization': `Bearer ${STRIPE_SECRET_KEY}`,
},
}
);
const page1 = await page1Response.json();
console.log(page1.data.length); // 10
console.log(page1.has_more); // true
console.log(page1.data[9].id); // ch_1234567890
// Second page using starting_after
const page2Response = await fetch(
`https://api.stripe.com/v1/charges?limit=10&starting_after=${page1.data[9].id}`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${STRIPE_SECRET_KEY}`,
},
}
);
const page2 = await page2Response.json();
console.log(page2.data.length); // 10
In practice, you'll wrap this into a helper that yields or accumulates results until has_more is false. This becomes especially important when syncing historical data or generating reports where you need to fetch hundreds or thousands of records.
One thing to watch: the cursor points to a specific object, not a position. If records are modified or deleted between requests, you won't accidentally skip rows. However, you might see the same record twice if it's updated and moves within the sort order, which is rare but possible.
API versions pinned per account and request
Stripe's API evolves, and they handle breaking changes through versioning. This is where many teams get tripped up: Stripe has account-level API versions and request-level API versions, and they work together in a specific way.
Your Stripe account has a default API version, set in the dashboard under Settings > API Version. This version applies to all requests unless you override them. Overriding per-request is done via the Stripe-Version header.
// Uses your account's default API version
const defaultResponse = await fetch('https://api.stripe.com/v1/payment_intents', {
method: 'POST',
headers: {
'Authorization': `Bearer ${STRIPE_SECRET_KEY}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
amount: '2000',
currency: 'usd',
}),
});
// Overrides to a specific version for this request
const versionedResponse = await fetch('https://api.stripe.com/v1/payment_intents', {
method: 'POST',
headers: {
'Authorization': `Bearer ${STRIPE_SECRET_KEY}`,
'Stripe-Version': '2023-10-16',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
amount: '2000',
currency: 'usd',
}),
});
Why does this matter? Imagine Stripe releases a new API version that changes a response field. Your production code is expecting the old field name. If you're not explicit about versioning, all your requests suddenly break. By pinning the version explicitly or keeping your account on a stable release, you control when breaking changes affect you.
We recommend keeping your account on a single stable version and only incrementally testing and upgrading when your team is ready. Use request-level versioning sparingly—only when you need a specific feature that's newer than your account default or when you're explicitly testing migration paths.
Common mistakes
Not using expand for related objects: New integrations often fire off multiple API calls where one would suffice. If you're fetching a payment intent and then separately fetching its customer, you've missed the expand parameter. This doubles your API call count and latency.
Assuming expand is free: Expanded objects do add payload size and processing time. Don't expand everything indiscriminately. Expand only what you need to display or process in that code path.
Forgetting that URLs are case-sensitive: /v1/PaymentIntents won't work; it must be lowercase. IDE autocomplete helps here, but hand-written requests often have this typo.
Mixing account and request versioning without clarity: If your account is on an old version and you request a new version header for one endpoint but not others, inconsistent behavior follows. Document your versioning strategy.
Not handling pagination.has_more: If you assume a single page covers all results, you'll miss data when lists grow past the default limit. Always check has_more or use a library wrapper that abstracts this away.
Key takeaways
- Stripe's REST design is predictable: plurals for collections, IDs for individuals, HTTP verbs map to actions. This consistency makes the API approachable once you see the pattern.
- Use the
expandparameter to fetch related objects in a single call. This reduces round trips, especially on dashboards and reporting endpoints. - Pagination is cursor-based via
starting_after, not offset-based. Always checkhas_moreand iterate until it's false when syncing data. - Version control happens at both account and request levels. Keep your account on one stable version, use request headers only when deliberately testing newer features.
- Wrap the raw API calls into a typed client or request helper early. This prevents URL typos, standardizes auth headers, and makes retries and error handling consistent across your codebase.






Comments
Loading comments…