
Stripe Inspired Api Design Rules
- 70 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
stripe-inspired-api-design-rules is a Claude Code skill in the AI & Agent Building category.
Key points
- stripe-inspired-api-design-rules
- AI & Agent Building
- AI-coding skill
Stripe Inspired Api Design Rules by the numbers
- 70 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,712 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill stripe-inspired-api-design-rulesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 70 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with stripe-inspired-api-design-rules.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when stripe-inspired-api-design-rules is a claude code skill in the ai & agent building category.
What you get
Structured output aligned to stripe-inspired-api-design-rules: stripe-inspired-api-design-rules; AI & Agent Building; AI-coding skill.
Files
Stripe-Inspired API Design Best Practices
A reference distillation of the design conventions behind Stripe's API — the most widely admired and copied JSON HTTP API in the industry. Contains 52 actionable rules across 8 categories, prioritised by how irreversibly a wrong decision cascades through every endpoint, every SDK, and every client integration. Each rule explains the WHY, shows incorrect-vs-correct code, and links to the canonical source.
When to Apply
Reach for this skill when:
- Designing a new JSON HTTP API or a new endpoint on an existing one
- Reviewing an API design proposal, OpenAPI spec, or PR that adds/changes endpoints
- Debugging an integration where the "wrong" shape of the API is causing client bugs
- Auditing an API for naming consistency, error shape uniformity, or compatibility risks
- Producing an API design report (the kind your inspector tool emits —
Critical / Warning / Suggestion / Positive) - Picking between two designs and looking for an authoritative source to back the choice
- Onboarding to API design — these are the canonical patterns to internalise first
The rules are general — they apply to any JSON HTTP API, not just APIs imitating Stripe. Triggers include "API design", "OpenAPI", "endpoint", "schema", "webhook", "idempotency", "pagination", "API versioning", and reviews of YAML/JSON spec files.
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Resource Modeling & Identifiers | CRITICAL | resource- |
| 2 | URL Structure & HTTP Semantics | CRITICAL | url- |
| 3 | Request & Response Format | HIGH | format- |
| 4 | Errors & Status Codes | HIGH | error- |
| 5 | Idempotency & Safe Retries | HIGH | idem- |
| 6 | Versioning & Backwards Compatibility | HIGH | ver- |
| 7 | Naming, Polymorphism & Metadata | MEDIUM-HIGH | naming- |
| 8 | Authentication, Webhooks & Search | MEDIUM-HIGH | ops- |
Earlier categories cascade harder: a wrong choice in resource modeling (numeric IDs, no object discriminator) propagates to every endpoint forever; a wrong choice in webhook event naming is a single category to fix.
Quick Reference
1. Resource Modeling & Identifiers (CRITICAL)
- `resource-prefixed-string-ids` — Use Prefixed String IDs for Every Resource
- `resource-object-discriminator` — Include a Read-Only
objectDiscriminator on Every Resource - `resource-opaque-ids` — Treat IDs as Opaque Strings up to 255 Characters
- `resource-unix-seconds-timestamps` — Use Unix Seconds (Integer) for All Datetimes
- `resource-iso-date-only` — Use ISO 8601 Date Strings for Date-Only Values
- `resource-birthdate-hash` — Represent Birth Dates as
{day, month, year}Hashes - `resource-integer-minor-units` — Use Integer Minor Units for Money, Never Floats
- `resource-currency-field-not-name` — Colocate a
currencyField; Never Bake Currency into Field Names - `resource-decimal-suffix-strings` — Use
_decimalString Suffix for Precise Decimals That Can't Be Integers
2. URL Structure & HTTP Semantics (CRITICAL)
- `url-plural-collections` — Pluralize Collection URLs; Singularize Object Types
- `url-post-for-updates` — Use POST for Updates (Not PUT or PATCH)
- `url-action-verbs-as-subpaths` — Express Non-CRUD Actions as Imperative Sub-Paths
- `url-no-bulk-endpoints` — One Object Per Request — No Bulk Endpoints
- `url-version-in-path-and-header` — Version in URL Path and
Stripe-VersionHeader - `url-dedicated-search-endpoint` — Use a Dedicated
/searchEndpoint for Complex Queries
3. Request & Response Format (HIGH)
- `format-form-encoded-requests` — Accept Form-Encoded Requests, Always Return JSON
- `format-bracket-notation-nesting` — Use Bracket Notation for Nested Fields in Form Bodies
- `format-list-envelope` — Return Lists in a
{object, url, has_more, data}Envelope - `format-cursor-pagination` — Paginate by Cursor (
starting_after/ending_before), Not by Offset - `format-no-total-counts` — Use
has_moreBoolean; Never Return Total Counts - `format-expand-parameter` — Expand Related Objects with
expand[]in One Round Trip - `format-dot-notation-expansion` — Allow Dot-Notation for Nested Expansion (Max Depth 4)
4. Errors & Status Codes (HIGH)
- `error-top-level-object` — Always Wrap Failures in a Top-Level
errorObject - `error-four-type-enum` — Use a Small Fixed
typeEnum, Don't Proliferate Types - `error-message-mandatory-code-optional` — Require
message; MakecodeOptional and Only for Programmatic Handling - `error-lowercase-snake-case-codes` — Use Lowercase snake_case for Error Codes, Not SCREAMING_SNAKE_CASE
- `error-http-status-mapping` — Map Error Types to HTTP Status Codes Consistently
- `error-doc-url-on-every-error` — Include
doc_urlLinks and Request IDs on Every Error
5. Idempotency & Safe Retries (HIGH)
- `idem-key-header` — Accept
Idempotency-KeyHeader on All Mutating Requests - `idem-scoped-per-account` — Scope Idempotency Keys per Account, Not Globally
- `idem-24h-ttl` — Keep Idempotency Keys for 24 Hours, Reap at 72
- `idem-fail-on-key-reuse` — Return 409 When a Key Is Reused with Different Params
- `idem-recovery-points` — Use Recovery Points for Multi-Step Idempotent Operations
6. Versioning & Backwards Compatibility (HIGH)
- `ver-dated-versions` — Use Dated Versions (
YYYY-MM-DD), Not v1/v2/v3 - `ver-account-pinning` — Pin Each Account to Its First-Request Version
- `ver-additive-changes` — Define What Counts as a Backwards-Compatible Change
- `ver-version-change-modules` — Encapsulate Each Breaking Change in a Version-Change Module
- `ver-tolerate-unknown` — Document That Clients Must Tolerate Unknown Fields, Events, and Enum Values
7. Naming, Polymorphism & Metadata (MEDIUM-HIGH)
- `naming-snake-case-wire-format` — Use snake_case for All Wire Identifiers
- `naming-american-english` — Use American English Spelling (
canceled, Notcancelled) - `naming-simple-unambiguous` — Names — Simple, Unambiguous, No Leading Digits, No Jargon
- `naming-type-discriminator-polymorphism` — Discriminate Polymorphic Types with a
typeField and Sibling Objects - `naming-metadata-pattern` — Provide a
metadataPass-Through with Strict Limits - `naming-boolean-past-tense` — Booleans — Past-Tense Verbs and Plain Adjectives, Not
is_/has_Prefixes - `naming-enums-over-booleans` — Prefer Enums over Booleans for New Status/Flag Fields
8. Authentication, Webhooks & Search (MEDIUM-HIGH)
- `ops-prefixed-api-keys` — Prefix API Keys with Scope and Mode (
sk_live_,pk_test_,rk_) - `ops-https-only-basic-auth` — Enforce HTTPS Only and Use HTTP Basic Auth with the Key as Username
- `ops-on-behalf-of-header` — Use a Dedicated
On-Behalf-OfHeader for Multi-Tenant Calls - `ops-webhook-event-envelope` — Webhook Events Use a Fixed
{id, object, type, data, created}Envelope - `ops-webhook-event-naming` — Event Type Naming —
<resource>.<past_tense_action> - `ops-webhook-signature` — Sign Webhook Deliveries with HMAC and a Timestamp Tolerance Window
- `ops-webhook-at-least-once-handlers-idempotent` — Document At-Least-Once Delivery; Handlers Must Dedupe on
event.id
How to Use
For a focused question ("should this field be a boolean or an enum?"), jump directly to the relevant rule (naming-enums-over-booleans) — each rule is self-contained with the WHY, code examples, and the canonical source.
For a full API review or audit, work through the categories top-to-bottom. The order matches Stripe's own design priority: get resource modeling and URL structure right first; format, errors, idempotency, and versioning are the next layer; naming and operational surface come last because they're the easiest to evolve.
For producing a structured findings report (the kind an inspector tool emits), cite rules by their slug — resource-unix-seconds-timestamps, format-no-total-counts — so each finding traces back to a specific, defensible source.
Read section definitions for the cascade-impact rationale behind the category ordering, or the rule template when adding a new rule.
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering by design-propagation impact |
| assets/templates/_template.md | Template for authoring new rules |
| metadata.json | Version and reference URLs |
| gotchas.md | Failure points discovered when applying the rules |
API Design
Version 0.1.0 Stripe-Inspired May 2026
Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring codebases. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Comprehensive API design guide distilled from the Stripe API — the industry's most-copied reference for JSON HTTP APIs. Contains 52 rules across 8 categories, prioritised by design-propagation impact from critical (resource modeling, URL structure) to medium-high (naming, operational surface). Each rule includes a quantified impact, the reason it matters, incorrect-vs-correct code examples, and links to canonical sources. Suitable as the source of truth for an automated API design inspector that emits structured findings (Critical / Warning / Suggestion / Positive) and cites a specific rule per finding.
---
Table of Contents
1. Resource Modeling & Identifiers — CRITICAL
- 1.1 Colocate a `currency` Field; Never Bake Currency into Field Names — CRITICAL (prevents multi-currency support becoming a breaking schema change)
- 1.2 Include a Read-Only `object` Discriminator on Every Resource — CRITICAL (enables polymorphic deserialisation and self-describing responses)
- 1.3 Represent Birth Dates as `{day, month, year}` Hashes — CRITICAL (eliminates locale-string parsing ambiguity for collected dates)
- 1.4 Treat IDs as Opaque Strings up to 255 Characters — CRITICAL (preserves freedom to change ID format without a version bump)
- 1.5 Use `_decimal` String Suffix for Precise Decimals That Can't Be Integers — HIGH (preserves exact precision for sub-minor-unit values like FX rates and tax rates)
- 1.6 Use Integer Minor Units for Money, Never Floats — CRITICAL (prevents floating-point precision errors in monetary calculations)
- 1.7 Use ISO 8601 Date Strings for Date-Only Values — CRITICAL (prevents off-by-day errors from timezone-shifted timestamps)
- 1.8 Use Prefixed String IDs for Every Resource — CRITICAL (prevents type confusion in logs, support, and codegen)
- 1.9 Use Unix Seconds (Integer) for All Datetimes — CRITICAL (prevents milliseconds/seconds confusion that silently produces wrong times)
2. URL Structure & HTTP Semantics — CRITICAL
- 2.1 Express Non-CRUD Actions as Imperative Sub-Paths — CRITICAL (prevents overloaded update endpoints and ambiguous idempotency scope)
- 2.2 One Object Per Request — No Bulk Endpoints — HIGH (prevents partial-success ambiguity and broken idempotency scope)
- 2.3 Pluralize Collection URLs; Singularize Object Types — CRITICAL (prevents inconsistent endpoints and hand-written SDK glue per resource)
- 2.4 Use a Dedicated `/search` Endpoint for Complex Queries — MEDIUM-HIGH (prevents eventual-consistency leak into strongly-consistent list endpoints)
- 2.5 Use POST for Updates (Not PUT or PATCH) — CRITICAL (prevents PUT/PATCH replacement footguns and proxy-layer dropping)
- 2.6 Version in URL Path and `Stripe-Version` Header — HIGH (prevents path-version forks on every backwards-incompatible change)
3. Request & Response Format — HIGH
- 3.1 Accept Form-Encoded Requests, Always Return JSON — HIGH (prevents JSON serialisation bugs in client code and makes curl trivial)
- 3.2 Allow Dot-Notation for Nested Expansion (Max Depth 4) — MEDIUM-HIGH (prevents N+1 round trips for chained relationship traversal)
- 3.3 [Expand Related Objects with
expand[]in One Round Trip](references/format-expand-parameter.md) — MEDIUM-HIGH (prevents N+1 round trips when consumers need related resources) - 3.4 Paginate by Cursor (`starting_after`/`ending_before`), Not by Offset — HIGH (prevents skipped and duplicated items when the dataset changes mid-iteration)
- 3.5 Return Lists in a `{object, url, has_more, data}` Envelope — HIGH (prevents inconsistent list shapes across endpoints and enables generic SDK iterators)
- 3.6 Use `has_more` Boolean; Never Return Total Counts — HIGH (prevents slow full-table scans on every paginated request)
- 3.7 Use Bracket Notation for Nested Fields in Form Bodies — MEDIUM-HIGH (prevents ambiguous nesting and supports arbitrary depth in form-encoded requests)
4. Errors & Status Codes — HIGH
- 4.1 Always Wrap Failures in a Top-Level `error` Object — HIGH (prevents bespoke error parsing per endpoint)
- 4.2 Include `doc_url` Links and Request IDs on Every Error — MEDIUM-HIGH (prevents support round trips by giving developers direct links to docs and the failed request)
- 4.3 Map Error Types to HTTP Status Codes Consistently — HIGH (prevents intermediaries from misrouting errors and clients from miscategorising them)
- 4.4 Require `message`; Make `code` Optional and Only for Programmatic Handling — HIGH (prevents hardcoded code-to-text mappings in every client)
- 4.5 Use a Small Fixed `type` Enum, Don't Proliferate Types — HIGH (prevents error-type explosion that defeats generic handling)
- 4.6 Use Lowercase snake_case for Error Codes, Not SCREAMING_SNAKE_CASE — MEDIUM-HIGH (prevents casing inconsistency from becoming a breaking-change debt)
5. Idempotency & Safe Retries — HIGH
- 5.1 Accept `Idempotency-Key` Header on All Mutating Requests — HIGH (prevents duplicate charges/transfers under network retries)
- 5.2 Keep Idempotency Keys for 24 Hours, Reap at 72 — MEDIUM-HIGH (prevents unbounded storage growth while covering near-term retry windows)
- 5.3 Return 409 When a Key Is Reused with Different Params — HIGH (prevents silent execution of unintended operations under retry)
- 5.4 Scope Idempotency Keys per Account, Not Globally — HIGH (prevents key collisions across tenants in a multi-tenant API)
- 5.5 Use Recovery Points for Multi-Step Idempotent Operations — MEDIUM-HIGH (prevents partial-completion bugs when a multi-step operation crashes mid-execution)
6. Versioning & Backwards Compatibility — HIGH
- 6.1 Define What Counts as a Backwards-Compatible Change — HIGH (prevents breaking changes from shipping by accident)
- 6.2 Document That Clients Must Tolerate Unknown Fields, Events, and Enum Values — HIGH (prevents additive changes (the safe kind) from breaking existing clients)
- 6.3 Encapsulate Each Breaking Change in a Version-Change Module — MEDIUM-HIGH (prevents version-conditional logic from sprawling through the codebase)
- 6.4 Pin Each Account to Its First-Request Version — HIGH (prevents existing integrators breaking when a new version ships)
- 6.5 Use Dated Versions (`YYYY-MM-DD`), Not v1/v2/v3 — HIGH (prevents big-bang migrations and parallel SDK universes)
7. Naming, Polymorphism & Metadata — MEDIUM-HIGH
- 7.1 Booleans — Past-Tense Verbs and Plain Adjectives, Not `is_`/`has_` Prefixes — LOW-MEDIUM (prevents inconsistent prefix conventions cluttering field names)
- 7.2 Discriminate Polymorphic Types with a `type` Field and Sibling Objects — HIGH (prevents untagged unions that require runtime type-sniffing)
- 7.3 Names — Simple, Unambiguous, No Leading Digits, No Jargon — MEDIUM (prevents naming debt that requires version bumps to fix)
- 7.4 Prefer Enums over Booleans for New Status/Flag Fields — MEDIUM-HIGH (prevents needing a breaking change when a binary flag gains a third state)
- 7.5 Provide a `metadata` Pass-Through with Strict Limits — MEDIUM-HIGH (prevents per-customer schema requests for arbitrary tagging needs)
- 7.6 Use American English Spelling (`canceled`, Not `cancelled`) — MEDIUM-HIGH (prevents British/American spelling debt that requires a version bump to fix)
- 7.7 Use snake_case for All Wire Identifiers — HIGH (prevents casing inconsistency from forcing breaking renames later)
8. Authentication, Webhooks & Search — MEDIUM-HIGH
- 8.1 Document At-Least-Once Delivery; Handlers Must Dedupe on `event.id` — HIGH (prevents double-processing under retries and parallel delivery)
- 8.2 Enforce HTTPS Only and Use HTTP Basic Auth with the Key as Username — HIGH (prevents key leakage over plaintext channels and trivialises curl usage)
- 8.3 Event Type Naming — `<resource>.<past_tense_action>` — MEDIUM-HIGH (prevents inconsistent event-type strings that defeat generic routing)
- 8.4 Prefix API Keys with Scope and Mode (`sk_live_`, `pk_test_`, `rk_`) — HIGH (prevents production keys leaking into client code and enables secret-scanning)
- 8.5 Sign Webhook Deliveries with HMAC and a Timestamp Tolerance Window — HIGH (prevents forged webhook calls and replay attacks)
- 8.6 Use a Dedicated `On-Behalf-Of` Header for Multi-Tenant Calls — MEDIUM-HIGH (prevents acting-account confusion in platforms with thousands of tenants)
- 8.7 Webhook Events Use a Fixed `{id, object, type, data, created}` Envelope — HIGH (prevents per-event-type parsers in every integrator)
---
References
1. https://github.com/stripe/openapi 2. https://docs.stripe.com/api 3. https://docs.stripe.com/upgrades 4. https://docs.stripe.com/api/pagination 5. https://docs.stripe.com/api/errors 6. https://docs.stripe.com/api/expanding_objects 7. https://docs.stripe.com/api/metadata 8. https://docs.stripe.com/webhooks 9. https://docs.stripe.com/webhooks/signatures 10. https://docs.stripe.com/search 11. https://docs.stripe.com/api/authentication 12. https://stripe.com/blog/api-versioning 13. https://stripe.com/blog/idempotency 14. https://brandur.org/idempotency-keys 15. https://brandur.org/api-versioning
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Title — repeat the frontmatter title}
{1-3 sentences explaining WHY this matters. State the design-propagation cascade: what goes wrong without this pattern, and how the wrong decision propagates to every endpoint / every SDK / every client integration. This is the highest-signal part of the rule — the model generalises from understood reasoning, not from dictation.}
{Optional 1-2 more sentences with context: when the pattern applies, why Stripe in particular landed on this approach, or the historical incident that motivated it.}
Incorrect ({short label naming what's wrong}):
```{language: json | text | python | javascript | sql | yaml | bash} {Bad code — production-realistic, not strawman. Include enough context that the problem is visible, but no more than needed.}
// Annotation block explaining the failure mode. // What breaks, when, how often, and why. // Keep concrete — name the specific bug, not "this might cause issues".
**Correct ({short label naming what's right}):**
{Good code — minimal diff from the incorrect version where possible, so the reader sees the exact transformation. Include enough context to be a complete example.}
// Annotation block explaining why this works. // What's better, what's prevented, what's enabled. // Cross-reference related rules with [[slug]] or markdown link.
{Optional sections — include only when they add value. Don't include empty sections.}
**Alternative ({context}):**
{When two approaches are both valid, describe the alternative and when to pick it.}
**When NOT to use this pattern:**
- {Specific exception, with the trigger condition that flips the recommendation}
- {Another exception, if any}
**Benefits:**
- {Enumerable advantage that didn't fit in the prose above}
- {Another, if any — keep the list short}
**Common use cases:**
- {Where the pattern shows up in practice}
- {Another instance, if any}
**Warning ({context}):**
{Something that's easy to get subtly wrong even when following the rule.}
Reference: [{Source Title}]({URL}), [{Second Source}]({URL2})
---
## Authoring checklist
When adding a new rule, verify:
- [ ] Filename is `{category-prefix}-{slug}.md` (e.g., `resource-prefixed-string-ids.md`)
- [ ] First tag in frontmatter matches the category prefix
- [ ] Title starts with an imperative verb (`Use`, `Avoid`, `Prefer`, `Discriminate`, `Pluralize`, ...)
- [ ] `impactDescription` is quantified — starts with "prevents X" or names a measurable metric
- [ ] WHY explanation is 1-3 sentences, not boilerplate
- [ ] Both incorrect and correct code blocks have language tags (`json`, `text`, `python`, `bash`, ...)
- [ ] Annotation blocks (the `//`-comment blocks) use `text` as the language
- [ ] At least one Reference link to a canonical source (Stripe docs, Stripe blog, Brandur, etc.)
- [ ] Cross-references to related rules use `[`slug`](slug.md)` markdown links
## Naming conventions for new categories
If proposing a new top-level category:
- Prefix: 3-8 lowercase chars, hyphen-terminated when used in filenames
- Add to `references/_sections.md` with an `**Impact:**` line and a one-sentence rationale
- Position in the file by **design propagation impact** — earlier = harder to undo
- Re-run `validate-skill.js --sections-only` after editing `_sections.md`
- Re-run `build-agents-md.js` after adding rules so `AGENTS.md` reflects the new category
Gotchas
Failure points discovered when applying the Stripe-inspired API design rules. Append-only — keep dated entries with concrete fix instructions.
No known gotchas yet
This file is initialised empty. Add an entry the first time a rule produces a surprising failure or has an edge case worth documenting.
Template for new gotchas
### {Short, specific symptom title}
{One sentence describing what went wrong.}
{One sentence on the root cause.}
Fix: {Concrete action — what to do, where, when.}
Added: {YYYY-MM-DD}{
"version": "0.1.0",
"organization": "Stripe-Inspired",
"technology": "API Design",
"discipline": "distillation",
"type": "code-quality",
"date": "May 2026",
"abstract": "Comprehensive API design guide distilled from the Stripe API — the industry's most-copied reference for JSON HTTP APIs. Contains 52 rules across 8 categories, prioritised by design-propagation impact from critical (resource modeling, URL structure) to medium-high (naming, operational surface). Each rule includes a quantified impact, the reason it matters, incorrect-vs-correct code examples, and links to canonical sources. Suitable as the source of truth for an automated API design inspector that emits structured findings (Critical / Warning / Suggestion / Positive) and cites a specific rule per finding.",
"references": [
"https://github.com/stripe/openapi",
"https://docs.stripe.com/api",
"https://docs.stripe.com/upgrades",
"https://docs.stripe.com/api/pagination",
"https://docs.stripe.com/api/errors",
"https://docs.stripe.com/api/expanding_objects",
"https://docs.stripe.com/api/metadata",
"https://docs.stripe.com/webhooks",
"https://docs.stripe.com/webhooks/signatures",
"https://docs.stripe.com/search",
"https://docs.stripe.com/api/authentication",
"https://stripe.com/blog/api-versioning",
"https://stripe.com/blog/idempotency",
"https://brandur.org/idempotency-keys",
"https://brandur.org/api-versioning"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
Categories are ordered by design propagation impact — how irreversibly a wrong decision cascades. Early decisions (resource shape, URL structure) lock in every endpoint, every SDK, every client integration; changing them later forces a new major API version. Later categories (naming, operational surface) are pervasive but locally scoped.
---
1. Resource Modeling & Identifiers (resource)
Impact: CRITICAL Description: Identifier scheme, type discriminators, and the wire shape of dates, money, and currency. These decisions appear on every object the API ever returns — getting them wrong forces a coordinated migration of every endpoint and every SDK.
2. URL Structure & HTTP Semantics (url)
Impact: CRITICAL Description: URL pluralization, HTTP verb conventions, action endpoints, and single-object semantics. Once SDKs ship and integrators wire routes into their codebases, the URL shape is effectively frozen.
3. Request & Response Format (format)
Impact: HIGH Description: Wire encoding (form-encoded requests, JSON responses), list envelope shape, cursor pagination, and the expand mechanism for inlining related resources. Defines the contract every client parser depends on.
4. Errors & Status Codes (error)
Impact: HIGH Description: Top-level error object shape, the small fixed type enum, HTTP status mapping, and the rule that message is mandatory while code is optional. Clients build their error handling once against this shape and reuse it across every endpoint.
5. Idempotency & Safe Retries (idem)
Impact: HIGH Description: Idempotency-Key header semantics, scoping rules, TTL, key-reuse-with-different-params detection, and recovery-point patterns for multi-step operations. Without idempotency from day one, retries cause duplicate charges and transfers — retrofitting after the first incident is brutal.
6. Versioning & Backwards Compatibility (ver)
Impact: HIGH Description: Date-based version strings, account pinning, the strict definition of what counts as a backwards-compatible change, and version-change modules that transform responses between versions. The wrong versioning model forces /v2/ endpoints and parallel SDK universes.
7. Naming, Polymorphism & Metadata (naming)
Impact: MEDIUM-HIGH Description: snake_case wire format, American English spelling, type-discriminated polymorphism, the customer-defined metadata pattern, and the preference for enums over booleans on new properties. Per-field guidance that compounds across the surface — every renamed field is a breaking change.
8. Authentication, Webhooks & Search (ops)
Impact: MEDIUM-HIGH Description: Mode-and-scope-prefixed API keys (sk_live_, pk_test_, rk_), HTTPS-only Basic Auth, Stripe-Account header for multi-tenant calls, signed event delivery (Stripe-Signature HMAC), and the dedicated search endpoint with its field:value query syntax. Operational surface around the resource API where get-it-wrong incidents are visible to integrators on day one.
Include doc_url Links and Request IDs on Every Error
Every error response includes a doc_url field pointing to the documentation page for that error code, and a request_log_url pointing to the request in the dashboard. The doc_url lets a developer jump straight from a stack trace to "what does card_declined mean and what should I do about it"; the request_log_url lets them open the exact request in the dashboard to see the full payload, headers, and response without forwarding logs to support.
Both are tiny additions — a single string field each — but they collapse the support funnel dramatically. A developer who hits an unfamiliar error in production usually has two questions: "what is this?" and "what request actually failed?" Putting both answers in the error response itself eliminates the back-and-forth that would otherwise route through documentation search, log queries, and a support ticket.
Incorrect (error with no documentation pointer or request identifier):
{
"error": {
"type": "card_error",
"code": "card_declined",
"message": "Your card was declined."
}
}// Developer searches docs for "card_declined" — might find the right page, might not.
// Reproducing the failure requires hunting through logs to find the request.
// Support ticket attaches a screenshot; engineer asks for request ID; another round trip.Correct (doc_url + request_log_url on every error):
HTTP/1.1 402 Payment Required
Request-Id: req_abc123XYZ
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"param": "card[number]",
"doc_url": "https://stripe.com/docs/error-codes/card-declined",
"request_log_url": "https://dashboard.stripe.com/test/logs/req_abc123XYZ",
"charge": "ch_3MqZ..."
}
}// Developer clicks doc_url → docs page for this exact error with handling guidance.
// Developer clicks request_log_url → dashboard view of the full request and response.
// Support ticket includes both URLs; engineer reproduces in seconds.Also return a `Request-Id` header on every response (success or failure) — this is what request_log_url is built from, and it's what users send to support:
HTTP/1.1 200 OK
Request-Id: req_abc123XYZ
Content-Type: application/json
{ "id": "ch_3MqZ...", "object": "charge", ... }The `doc_url` is keyed by `code`, not by `type` — card_error is too coarse to document specifically, but card_declined, expired_card, incorrect_cvc each have actionable handling guidance. Generate the URL mechanically:
doc_url = `https://docs.example.com/error-codes/${error.code}`Include resource-specific identifiers when the error is about a specific resource — Stripe includes "charge": "ch_X" or "payment_intent": "pi_X" so the developer can navigate to the affected resource without parsing the request.
Request IDs are also useful for idempotency debugging:
{
"request": {
"id": "req_abc123",
"idempotency_key": "4ab9c8a1-7e3d-4c8f-9b21-7d1f3c5e8a91"
}
}When a retry hits a cached idempotent response, surfacing the original request ID and idempotency key in the response makes "is this a replay?" instantly answerable.
Reference: Stripe errors, Stripe request IDs
Use a Small Fixed type Enum, Don't Proliferate Types
Stripe ships exactly four error types: api_error, card_error, idempotency_error, invalid_request_error. Every error in the API falls into one of these four buckets — and the bucket determines how the client should react (retry, surface to user, alert engineering, fix the request). New error categories don't get new types; they get new code values within an existing type.
The discipline matters because the type field is the classification axis clients use to write generic handlers. With four types, every integrator can write a four-branch switch and cover the entire error surface. With twenty types, integrators end up grouping them anyway, and they group them inconsistently. The constraint forces the API team to think carefully about which client reaction each error class implies.
The four types and what they tell the client to do:
type | Meaning | Client reaction |
|---|---|---|
api_error | Server-side problem (5xx) | Retry with backoff; alert engineering if persistent |
card_error | Payment method rejected by the card network | Surface to the end user; let them try another card |
idempotency_error | Idempotency key reused with different params | Fix the bug — never retry |
invalid_request_error | Malformed request (4xx, not card-related) | Fix the request — never retry |
Incorrect (type-per-failure-mode proliferation):
{ "error": { "type": "card_declined_generic" } }
{ "error": { "type": "card_declined_insufficient_funds" } }
{ "error": { "type": "card_expired" } }
{ "error": { "type": "card_cvc_check_failed" } }
{ "error": { "type": "rate_limit_exceeded" } }
{ "error": { "type": "request_field_missing" } }
{ "error": { "type": "request_field_invalid" } }
{ "error": { "type": "internal_server_error" } }
{ "error": { "type": "database_timeout" } }// Generic error handling becomes infeasible — must enumerate every type.
// New failure modes need SDK updates to recognise their type.
// Grouping logic ("is this a user-facing payment failure?") differs across integrators.Correct (four types, infinite codes within them):
// Card declines — all type: card_error, differ by code/decline_code
{ "error": { "type": "card_error", "code": "card_declined", "decline_code": "generic_decline" } }
{ "error": { "type": "card_error", "code": "card_declined", "decline_code": "insufficient_funds" } }
{ "error": { "type": "card_error", "code": "expired_card" } }
{ "error": { "type": "card_error", "code": "incorrect_cvc" } }
// Validation problems — all type: invalid_request_error, differ by code
{ "error": { "type": "invalid_request_error", "code": "parameter_missing", "param": "amount" } }
{ "error": { "type": "invalid_request_error", "code": "parameter_invalid_integer", "param": "amount" } }
// Server-side — type: api_error
{ "error": { "type": "api_error", "message": "An unexpected error occurred." } }
// Idempotency violation — separate type because the action is unique (fix bug, never retry)
{ "error": { "type": "idempotency_error", "code": "idempotency_key_in_use" } }// Four-branch client switch covers every error in the API:
// card_error → show to user
// invalid_request_error → log and surface to developer
// idempotency_error → never retry, fix the bug
// api_error → retry with backoffWhy `idempotency_error` is its own type even though it's structurally a request error: the action is unique. Retry is dangerous (the same key with different params is a bug); surfacing to users is wrong (they didn't do anything). Putting it in its own type forces integrators to handle it correctly.
Adding a new type is a breaking change because every client's exhaustive switch needs a new branch. The four-type ceiling is intentional — additions require a major review.
Reference: Stripe errors
Map Error Types to HTTP Status Codes Consistently
Each error type maps to a specific HTTP status code, and the mapping never varies. Clients route on status code first (because every HTTP library exposes it cheaply, and intermediaries like load balancers, CDNs, and retry middleware see it before the body), then drill into the body for the type and code. Inconsistent mapping — returning 200 with an error envelope, or 500 for a card decline — defeats every layer of the request stack.
This is also what lets HTTP infrastructure do the right thing automatically: 4xx responses are not retried, 5xx and 429 are retried with backoff, 401 triggers re-auth. Get the status code wrong and clients either pound a failing endpoint forever (4xx returned as 5xx → retries) or silently lose data (5xx returned as 4xx → no retry).
The canonical mapping:
| Status | type | When |
|---|---|---|
| 200 | — (success) | Resource returned |
| 400 | invalid_request_error | Malformed request, missing/invalid params |
| 401 | invalid_request_error | Missing/invalid API key |
| 402 | card_error | Card declined by issuer or network |
| 403 | invalid_request_error | Authenticated but lacks permission (e.g., restricted key) |
| 404 | invalid_request_error | Resource doesn't exist |
| 409 | idempotency_error | Idempotency key reused with different params |
| 424 | api_error | External dependency failed (rare, payment-network-specific) |
| 429 | api_error (or rate-limit-specific) | Too many requests — client must back off |
| 500 | api_error | Server-side bug — retry safely |
| 502, 503, 504 | api_error | Transient infrastructure failure — retry with backoff |
Incorrect (200 + error envelope — defeats every HTTP layer):
HTTP/1.1 200 OK
{
"error": {
"type": "card_error",
"code": "card_declined",
"message": "Your card was declined."
}
}// CDN caches it as a success.
// Retry middleware doesn't fire.
// `fetch().ok` returns true — naive clients treat it as success.
// Monitoring dashboards show 100% success rate during a card-decline incident.Incorrect (500 for a card decline — triggers infinite retries):
HTTP/1.1 500 Internal Server Error
{
"error": {
"type": "card_error",
"code": "card_declined"
}
}// Client retry library sees 5xx → retries with backoff.
// Card network sees N declines per minute for the same card → flags account.
// User charged for retry storm if the card stops declining mid-loop.Correct (402 for card decline — exactly what HTTP intended):
HTTP/1.1 402 Payment Required
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"doc_url": "https://stripe.com/docs/error-codes/card-declined"
}
}// 4xx → client retry libraries don't retry automatically (correct).
// `fetch().ok` returns false → client error handler runs.
// CDN sees 4xx → doesn't cache.
// Monitoring distinguishes payment failures (402) from server bugs (5xx).Correct (429 for rate limiting — clients back off natively):
HTTP/1.1 429 Too Many Requests
Retry-After: 30
{
"error": {
"type": "api_error",
"message": "Rate limit exceeded. Retry after 30 seconds.",
"doc_url": "https://stripe.com/docs/rate-limits"
}
}Include `Retry-After` on 429 and 503 — clients should honour it for backoff timing rather than guessing.
Never return 2xx with an error envelope. "Soft failures" returned as 200 are a common anti-pattern that breaks every HTTP-layer assumption.
Reference: Stripe errors, Stripe rate limits
Use Lowercase snake_case for Error Codes, Not SCREAMING_SNAKE_CASE
Error codes are lowercase snake_case: card_declined, incorrect_cvc, expired_card, idempotency_key_in_use, slot_taken. Not CARD_DECLINED, not CardDeclined, not card-declined. The casing is the same as every other identifier on the wire (field names, enum values, event types — see `naming-snake-case-wire-format`) because mixed casing breaks every developer's mental model of "all wire identifiers look like this."
Once codes are published they're part of the API contract — integrators write conditional logic on them (if (error.code === 'card_declined') promptForNewCard()). Renaming for casing later is a breaking change that requires the dated-version migration machinery. Get it right at launch.
Incorrect (SCREAMING_SNAKE_CASE — visually shouts, inconsistent with rest of API):
{
"error": {
"code": "SLOT_TAKEN",
"message": "This slot is no longer available."
}
}// Inconsistent with snake_case field names everywhere else.
// Looks like a C-style constant, suggesting it's a value the client should #define.
// Renaming to slot_taken later is a breaking change for every consumer.Incorrect (camelCase — looks like a JavaScript property, not a wire identifier):
{
"error": {
"code": "cardDeclined"
}
}Incorrect (kebab-case — inconsistent with field name casing):
{
"error": {
"code": "card-declined"
}
}Correct (lowercase snake_case — matches every other identifier):
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds."
}
}The convention extends to every code-valued field in the error:
| Field | Value pattern |
|---|---|
type | snake_case (card_error, invalid_request_error) |
code | snake_case (card_declined, parameter_missing) |
decline_code | snake_case (insufficient_funds, generic_decline) |
advice_code | snake_case (try_again_later, do_not_try_again) |
Don't mix casings even within one error response — every machine-readable string follows the same rule.
Adding new code values is non-breaking (clients must tolerate unknown codes gracefully, falling back to the type-level handler). Renaming or recasing an existing code is breaking and requires the version-change machinery.
Reference: Stripe error codes
Require message; Make code Optional and Only for Programmatic Handling
Every error response includes a human-readable message — it is never absent. The code field is optional and only present when a developer needs to handle the error programmatically (card_declined, expired_card, idempotency_key_in_use). Integration-misuse errors (sent a malformed request, used the wrong endpoint) don't get codes because there's no useful programmatic reaction — the right fix is to read the message and fix the integration.
Omitting message forces every client to maintain a hardcoded code → text map. Omitting code for handleable failures forces every client to do error matching via brittle string comparison on message. The split — message always, code selectively — gives both audiences what they need without redundancy.
Incorrect (code-only error, no message):
{
"error": {
"code": "SLOT_TAKEN"
}
}// Client must hardcode: "SLOT_TAKEN" → "This slot is no longer available. Please choose another."
// Every integrator writes the same translation table.
// Updating the user-facing text requires every client SDK to update.
// Codes also use SCREAMING_SNAKE_CASE — see error-lowercase-snake-case-codes.Incorrect (codes for everything, including non-handleable errors):
{
"error": {
"type": "invalid_request_error",
"code": "amount_must_be_positive_integer",
"message": "amount must be a positive integer"
}
}// "amount_must_be_positive_integer" is not programmatically handleable — what would the client do
// differently than reading the message? The code is dead weight.
// The integrator's job is to read the message and fix their request.Correct (message always, code only when handleable):
// Card decline — code is essential because clients route differently per decline type
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"param": "card[number]",
"doc_url": "https://stripe.com/docs/error-codes/card-declined"
}
}// Integration misuse — no code; the message is the actionable signal
{
"error": {
"type": "invalid_request_error",
"message": "Received unknown parameter: amout. Did you mean: amount?",
"param": "amout",
"doc_url": "https://stripe.com/docs/api/charges/create"
}
}// Cards: integrator handles `code: "card_declined"` programmatically (retry? prompt? alternate method?)
// Integration error: no code — read the message, fix the typo, redeploy.Writing a good `message`:
- Specific about the problem (
"amount must be at least 50"not"invalid amount") - Actionable (
"Use a different card or contact your bank"not"Card declined") - Includes the offending value when safe to do so (
"Received unknown parameter: amout. Did you mean: amount?") - Free of placeholder text (
"An error occurred"is a bug)
When you do include a `code`, it must follow the lowercase snake_case convention — see `error-lowercase-snake-case-codes`. Once published, codes are part of the API contract; renaming is a breaking change.
For card declines, also include `decline_code` (the issuer's reason) and advice_code (what to do next). See `error-decline-code-extras`.
Reference: Stripe error codes
Always Wrap Failures in a Top-Level error Object
Every error response has the same envelope: a single top-level error object containing the failure details. Successful responses are the resource itself; failures are { "error": { ... } }. The shape is consistent across every endpoint and every status code, so clients write error handling once and reuse it everywhere.
Without a uniform envelope, integrators end up with per-endpoint error parsing (if response.status === 400 && response.body.violations, if response.body.error_code, if response.body.errors[0].message), and every new error format requires an SDK update. With the envelope, a single if (response.error) check works for the entire API.
Incorrect (ad-hoc error shapes per endpoint):
// One endpoint:
{ "ok": false, "code": "SLOT_TAKEN" }
// Another endpoint:
{ "errors": [{ "field": "email", "msg": "invalid" }] }
// Another endpoint (success-looking shape with hidden failure):
{ "result": null, "warning": "card declined" }// Every endpoint forces a different parser.
// Integrators write switch statements over status code AND body shape.
// A new endpoint's error format breaks generic logging and monitoring.Incorrect (errors in a `data` field — looks like success):
HTTP/1.1 400 Bad Request
{
"data": {
"code": "card_declined",
"message": "Your card was declined."
}
}// A naive `if (response.data)` check treats the failure as success.
// No structural cue that this is an error envelope.Correct (top-level `error` object, consistent across the API):
HTTP/1.1 402 Payment Required
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "generic_decline",
"message": "Your card was declined.",
"param": "card[number]",
"doc_url": "https://stripe.com/docs/error-codes/card-declined",
"charge": "ch_3MqZ...",
"request_log_url": "https://dashboard.stripe.com/test/logs/req_abc"
}
}// Single envelope shape across every endpoint and every status.
// Client check: if (response.error) handleError(response.error)
// Generic logger can extract type, code, message uniformly.Validation errors use the same envelope — there is no separate errors[] array for multi-field validation. Stripe returns a single invalid_request_error with param naming the offending field; the client retries with a fix. This forces APIs to fail fast on the first invalid field rather than collecting validation across a whole submission. See `error-message-mandatory-code-optional`.
Required and optional fields inside `error`:
| Field | Required | Purpose |
|---|---|---|
type | required | one of 4 enum values (see `error-four-type-enum`) |
message | required | human-readable explanation |
code | optional | machine-readable identifier (only when programmatically handleable) |
param | optional | name of the offending request field |
doc_url | recommended | link to error documentation (see `error-doc-url-on-every-error`) |
request_log_url | recommended | link to the request in the dashboard for support workflows |
Reference: Stripe errors
Use Bracket Notation for Nested Fields in Form Bodies
Form-encoded bodies are flat key-value pairs, so nested structures use bracket notation in the key: metadata[order_id]=6735, card[number]=4242..., shipping[address][line1]=510%20Townsend. Arrays are expressed with [] suffix and repeated keys: expand[]=customer&expand[]=payment_intent.customer. The convention is consistent enough that any nested JSON request body has a mechanical translation into form-encoded brackets.
This matters because it preserves the form-encoded-request ergonomics (`format-form-encoded-requests`) without giving up the ability to send structured data. Every Stripe SDK serialises nested objects to bracket notation transparently — integrators write idiomatic objects in their language and the SDK handles the encoding.
Incorrect (flattened keys with custom delimiters):
POST /v1/customers HTTP/1.1
Content-Type: application/x-www-form-urlencoded
metadata_order_id=6735&metadata_referrer=affiliate&shipping_address_line1=510%20Townsend&shipping_address_city=SF// Custom underscore-delimited flattening — every consumer reinvents the parser.
// Ambiguous: `metadata_order_id` could mean `metadata.order_id` or `metadata_order.id`.
// SDKs cannot mechanically derive this from native nested objects.Incorrect (JSON-in-a-form-field workaround):
POST /v1/customers HTTP/1.1
Content-Type: application/x-www-form-urlencoded
metadata=%7B%22order_id%22%3A%226735%22%7D&shipping=%7B%22address%22%3A%7B%22line1%22%3A%22510%20Townsend%22%7D%7D// JSON-escaped inside a form field — worst of both worlds.
// Server has to JSON-parse individual fields. Defeats the form-encoded ergonomics.Correct (bracket notation, recursive):
POST /v1/customers HTTP/1.1
Content-Type: application/x-www-form-urlencoded
metadata[order_id]=6735&metadata[referrer]=affiliate&shipping[address][line1]=510%20Townsend&shipping[address][city]=SF// Unambiguous nesting. Recursive — works to any depth.
// SDKs mechanically translate nested objects: { metadata: { order_id: "6735" } }
// → metadata[order_id]=6735Correct (arrays with `[]` suffix and repeated keys):
POST /v1/charges/ch_X?expand[]=customer&expand[]=invoice.subscription// Each expand value is a repeated `expand[]=` parameter.
// Order is preserved; the server sees a list, not a single value.Curl example:
curl https://api.stripe.com/v1/customers \
-u sk_test_X: \
-d "metadata[order_id]=6735" \
-d "shipping[address][line1]=510 Townsend" \
-d "shipping[address][city]=SF"Reference: Stripe metadata docs
Paginate by Cursor (starting_after/ending_before), Not by Offset
List endpoints paginate by object ID, not by numeric offset. Parameters: limit (default 10, max 100), starting_after=<id> to move forward, ending_before=<id> to move backward. The cursor is the ID of the last (or first) item on the previous page. The two cursor params are mutually exclusive — you can paginate in one direction at a time.
Offset pagination (?page=3&page_size=10 or ?offset=30&limit=10) breaks under concurrent inserts and deletes: an item added between requests can be skipped, an item deleted can cause the next page to repeat. Cursor pagination is stable — each page is anchored to an immutable ID, so iteration produces the correct items even when the underlying dataset is changing.
Incorrect (offset-based pagination):
GET /v1/customers?page=2&page_size=10
GET /v1/customers?offset=20&limit=10// Between page 2 and page 3, a new customer is inserted → page 3 repeats one item.
// Or a customer is deleted → page 3 skips one item.
// Total counts (page count) require expensive table scans.Correct (cursor pagination with object IDs):
# First page
GET /v1/customers?limit=10
# Response: data = [cus_a, cus_b, ..., cus_j], has_more = true
# Next page — cursor is the ID of the last item on the previous page
GET /v1/customers?limit=10&starting_after=cus_j
# Response: data = [cus_k, cus_l, ..., cus_t], has_more = true// Cursor is the immutable ID `cus_j` — points to a specific item.
// Inserts/deletes elsewhere in the dataset don't shift the iteration position.
// No skipped or duplicated items even under heavy concurrent writes.Backward pagination:
GET /v1/customers?limit=10&ending_before=cus_k
# Returns the 10 items immediately preceding cus_kDefault ordering is reverse-chronological (newest first). The cursor implicitly inherits this order — starting_after means "give me items older than this one" because the list is sorted newest-first.
Iterate until `has_more` is false, not by checking `data.length`:
let starting_after = null;
while (true) {
const params = { limit: 100 };
if (starting_after) params.starting_after = starting_after;
const page = await stripe.customers.list(params);
for (const customer of page.data) {
// process customer
}
if (!page.has_more) break;
starting_after = page.data[page.data.length - 1].id;
}Provide SDK auto-pagination helpers that hide the cursor mechanics behind a generator or async iterator — most integrators want to iterate, not paginate.
Limit clamping: if a caller sends limit=10000, clamp silently to the maximum (Stripe uses 100). Don't error — let them iterate.
Reference: Stripe pagination
Allow Dot-Notation for Nested Expansion (Max Depth 4)
Expansion supports chained traversal via dot notation: expand[]=payment_intent.customer inflates the charge's payment_intent, and within that, the customer field as well. The chain can go up to four levels deep (expand[]=invoice.subscription.customer.default_source). The depth limit exists because each level multiplies join cost; beyond four, the right tool is usually a dedicated denormalised endpoint or a search query.
This is what makes expand competitive with GraphQL for read-heavy traversal workloads. A complex page view ("show the charge, the customer who owns it, the subscription tied to the customer, the customer's default payment method") becomes a single round trip with one expand string instead of four sequential fetches.
Incorrect (only single-level expansion → still N+1 for chains):
GET /v1/charges/ch_X?expand[]=payment_intent
# Returns payment_intent inline, but customer inside is still an ID
GET /v1/customers/cus_X
# Second round trip to inflate the customerCorrect (dot notation traverses relationships):
GET /v1/charges/ch_X?expand[]=payment_intent&expand[]=payment_intent.customer{
"id": "ch_X",
"object": "charge",
"payment_intent": {
"id": "pi_X",
"object": "payment_intent",
"customer": {
"id": "cus_X",
"object": "customer",
"email": "jenny@example.com",
...
}
}
}Multi-level chain (up to 4 levels):
GET /v1/invoices/in_X?expand[]=subscription.customer.default_source// 1. Inflate invoice.subscription
// 2. Within subscription, inflate subscription.customer
// 3. Within customer, inflate customer.default_source
// Single round trip; would have been 3 sequential without expansion.For list responses, prefix with `data.`:
GET /v1/charges?expand[]=data.customer&expand[]=data.payment_intent.invoice&limit=20Enforce the depth limit server-side and return an explicit error if exceeded:
HTTP/1.1 400 Bad Request
{
"error": {
"type": "invalid_request_error",
"code": "expansion_depth_exceeded",
"message": "Expansion paths cannot exceed 4 levels deep. Got 5 levels in 'invoice.subscription.customer.default_source.usage'.",
"param": "expand[]"
}
}When NOT to use deep expansion:
- When the same expansion is needed on many list items (consider denormalising on the server, returning the related fields directly)
- When the expanded data is huge (large arrays inside the expansion) — pay the N+1 cost rather than balloon the payload
- When the consumer only needs one field of the related object — design a specific endpoint or use the search API
Document expansion paths in `x-expansionResources` (OpenAPI vendor extension) so SDK codegen can produce type-safe nested expansion APIs.
Reference: Stripe expanding objects
Expand Related Objects with expand[] in One Round Trip
By default, related resources are returned as ID strings: a charge's customer field is "cus_NffrFeUfNV2Hib", not the full customer object. Integrators that need the related object can request inflation with expand[]=<field> on the same request: GET /v1/charges/ch_X?expand[]=customer. The server returns the customer object inline, replacing the ID. This is Stripe's answer to the GraphQL "I want this object plus those related objects" problem without introducing a query language.
Without expand, the alternative is N+1 round trips — fetch the charge, then fetch the customer, then for each invoice fetch its line items. For a list of 100 charges with three expansions, that's 301 sequential requests vs. one. expand collapses all of them into a single response while still letting the caller opt out of payload bloat when they don't need the related objects.
Incorrect (no expansion mechanism → N+1):
const charge = await stripe.charges.retrieve('ch_X');
const customer = await stripe.customers.retrieve(charge.customer);
const paymentIntent = await stripe.paymentIntents.retrieve(charge.payment_intent);
const paymentMethod = await stripe.paymentMethods.retrieve(paymentIntent.payment_method);
// 4 sequential round trips for one logical viewIncorrect (eager hydration of all relationships):
// API always returns full customer object — bloats every response
GET /v1/charges/ch_X
{
"id": "ch_X",
"customer": {
"id": "cus_X",
"email": "...",
"subscriptions": { ... }, // and these...
"invoices": { ... } // and these...
}
}// Payloads grow unboundedly. Consumers that just need the charge ID pay for everything.
// Cyclic references (customer → charges → customer) require ad-hoc cycle breaking.Correct (`expand[]` opt-in inflation):
# Default — related fields are ID strings
GET /v1/charges/ch_X
# Response
{
"id": "ch_X",
"object": "charge",
"customer": "cus_NffrFeUfNV2Hib",
"payment_intent": "pi_3MqZ..."
}
# With expansion — related fields are full objects
GET /v1/charges/ch_X?expand[]=customer&expand[]=payment_intent
# Response
{
"id": "ch_X",
"object": "charge",
"customer": {
"id": "cus_NffrFeUfNV2Hib",
"object": "customer",
"email": "jenny@example.com",
...
},
"payment_intent": {
"id": "pi_3MqZ...",
"object": "payment_intent",
...
}
}`expand` works on every shape of endpoint — list, retrieve, create, update. For list responses, expansions are prefixed with data.:
GET /v1/charges?expand[]=data.customer&limit=10Document which fields are expandable in the OpenAPI spec using the x-expandableFields vendor extension so SDK code generators can produce type-safe expansion helpers:
Charge:
properties:
customer:
type: string # default
# ...
x-expandableFields:
- customer
- payment_intent
- invoice
- balance_transactionAlso use `expand` to surface fields that are hidden by default (sensitive data like card numbers on Issuing Cards — opt-in via expand[]=number).
For nested expansion (payment_intent.customer), see `format-dot-notation-expansion`.
Reference: Stripe expanding objects
Accept Form-Encoded Requests, Always Return JSON
Request bodies are application/x-www-form-urlencoded. Response bodies are always application/json. The asymmetry is deliberate — form encoding makes curl-from-the-terminal trivial, sidesteps a long tail of JSON serialisation bugs in client code (null vs missing, integer overflow, key ordering), and works in every HTTP library without configuration. JSON is the right format for structured responses (nested objects, arrays of mixed types), but it's overkill for the flat key-value pairs that requests almost always are.
This choice also makes the API approachable. A developer copying a curl command from documentation can drop in their key and run it; there's no Content-Type to set, no body to escape, no client library required to debug a 400 response.
Incorrect (JSON request body — heavier for a flat input):
POST /v1/charges HTTP/1.1
Content-Type: application/json
{
"amount": 2000,
"currency": "usd",
"source": "tok_visa",
"description": "Order #1234"
}// Requires client to set Content-Type, serialise to JSON, handle JSON parse errors.
// Curl one-liner needs --data-raw with escaped JSON.
// Common bugs: trailing commas, integer-as-string (`"amount": "2000"`), missing brackets.Correct (form-encoded request, JSON response):
POST /v1/charges HTTP/1.1
Content-Type: application/x-www-form-urlencoded
amount=2000¤cy=usd&source=tok_visa&description=Order%20%231234// Trivial curl: curl https://api.stripe.com/v1/charges -u sk_test_X: -d amount=2000 -d currency=usd ...
// Works in every HTTP library — form encoding is the HTTP default body type.
// Response is JSON: { "id": "ch_3MqZ...", "object": "charge", "amount": 2000, ... }Optional sections of the wire format:
| Aspect | Choice | Reason |
|---|---|---|
| Request body | form-encoded | trivial curl, no JSON parse errors |
| Response body | JSON | structured, nested, typed |
| Nested request fields | bracket notation (metadata[order_id]=...) | see `format-bracket-notation-nesting` |
| File uploads | multipart/form-data | only for binary content |
Decision rule for new APIs in 2026: form-encoded is the right default when imitating Stripe v1's resource API specifically (and gets you the curl ergonomics). For a fully JSON-native API (e.g., a modern internal-platform API with no curl-first audience, or one with genuinely rich nested request bodies that benefit from JSON's structure), accepting JSON for request bodies is acceptable — but commit to one format across the entire API surface. Mixed form-encoded and JSON requests across endpoints is the worst outcome; pick one. Stripe's own v2 API moves to JSON for endpoints that need rich nesting; the v1 resource API stays form-encoded.
Reference: Stripe API root — request format
Return Lists in a {object, url, has_more, data} Envelope
Every list response uses the same envelope: object: "list", url (the endpoint that produced it), has_more (boolean — see `format-no-total-counts`), and data (the array of resources). No other fields. The shape is fixed across every list endpoint in the API so SDKs can ship a single auto_paginate() helper, integrators can write one generic list handler, and the convention compounds with `resource-object-discriminator` (every item in data self-describes its type).
Single-object responses are not wrapped — the resource is the root. The envelope is only for lists. Don't add a {"data": {...}} wrapper to single-object endpoints "for consistency" — the cost is making every consumer of every endpoint walk into a data key for no benefit.
Incorrect (bespoke list shape per endpoint):
{
"customers": [
{ "id": "cus_1", ... },
{ "id": "cus_2", ... }
],
"page": 1,
"page_size": 10,
"total_pages": 47
}// Different field names per resource: customers / charges / invoices array.
// Generic SDK iterator must switch on endpoint.
// Page-based pagination breaks under inserts/deletes (see format-cursor-pagination).Incorrect (envelope used for single objects too):
GET /v1/customers/cus_X
{
"data": {
"id": "cus_X",
"email": "jenny@example.com"
}
}// Pointless wrapper — every consumer unwraps `data` to get to the resource.
// Inconsistent with retrieval shape of other APIs; surprises integrators.Correct (fixed list envelope; single objects unwrapped):
GET /v1/customers?limit=2
{
"object": "list",
"url": "/v1/customers",
"has_more": true,
"data": [
{ "id": "cus_1", "object": "customer", "email": "jenny@example.com", ... },
{ "id": "cus_2", "object": "customer", "email": "lou@example.com", ... }
]
}GET /v1/customers/cus_1
{
"id": "cus_1",
"object": "customer",
"email": "jenny@example.com",
...
}The four fields and only the four fields:
| Field | Type | Value |
|---|---|---|
object | string | always "list" |
url | string | the request path (helps logging/debugging) |
has_more | boolean | are there more results beyond this page? |
data | array | the resources for this page |
No total_count, no page, no next_cursor field — see `format-no-total-counts` for why counts are deliberately omitted, and `format-cursor-pagination` for how the cursor lives in starting_after/ending_before query params instead.
Reference: Stripe pagination
Use has_more Boolean; Never Return Total Counts
The list envelope returns has_more: boolean and nothing else about size. There is no total_count, no total_pages, no page_count. Total counts on a paginated list endpoint are operationally expensive (every paginated request triggers a COUNT(*) or scan on a huge table) and semantically meaningless on a changing dataset — by the time the count is reported, items may have been added or removed.
If integrators truly need an approximate count, expose it as a separate, explicitly-approximate endpoint that can be cached aggressively (GET /v1/customers/count returning { approximate_count: 142000, as_of: 1747699200 }). Don't let count-aggregation cost ride on every page request.
Incorrect (totals on every page response):
{
"object": "list",
"data": [ ... ],
"total_count": 142387,
"total_pages": 14239,
"current_page": 73
}// Every page request triggers a COUNT(*) on a 142k-row table.
// Counts go stale immediately — by the time the response renders, the number is wrong.
// SQL plans for COUNT(*) often dominate the request cost for large tables.Correct (boolean only — `has_more`):
{
"object": "list",
"url": "/v1/customers",
"has_more": true,
"data": [ ... ]
}// `has_more` is cheap: SELECT (limit+1), check if extra row exists.
// No expensive aggregation. No stale numbers. No false confidence.How `has_more` is implemented cheaply:
- Fetch
limit + 1rows from the index - If you got
limit + 1, drop the last one and sethas_more: true - Otherwise return what you have and set
has_more: false
This is O(limit) regardless of total table size, whereas COUNT(*) is O(n).
When integrators really need a count: ship a dedicated endpoint and document its semantics explicitly:
GET /v1/customers/count
{
"approximate_count": 142000,
"as_of": 1747699200
}- Mark it
approximate_so consumers don't treat it as authoritative - Cache the value (refresh hourly, not on every request)
- Document that "for exact counts, iterate the list endpoint"
Don't ship totals "for the UI to render a page picker." UI page pickers are themselves an anti-pattern with cursor pagination — there's no Nth page when the dataset is changing. Show "Load more" or infinite scroll instead.
Reference: Stripe pagination
Keep Idempotency Keys for 24 Hours, Reap at 72
Idempotency keys are stored for ~24 hours after the operation; a background reaper deletes records older than 72 hours. The two windows separate the guarantee (24h — within this window, retries are safe and return the cached response) from the housekeeping (72h — by this time the record is gone regardless). Keys are for near-term correctness during the retry window of a failed operation, not for permanent deduplication.
If integrators need permanent deduplication ("only ever charge for invoice #6735 once, forever"), the right mechanism is at the business-domain layer — store a unique constraint on invoice_id in the database, or check before submitting. Idempotency keys aren't designed for, and shouldn't be used as, a permanent fact store.
Why 24 hours is the right guarantee window:
| Time since first request | What's likely happening |
|---|---|
| 0-5 minutes | Active retry storm — network blip, immediate retry |
| 5 min - 1 hour | Backoff retries from outage recovery |
| 1-6 hours | Async job retried after partial failure |
| 6-24 hours | Cron job or queue worker retrying overnight |
| > 24 hours | Almost certainly not a retry — it's a new logical operation |
Why 72 hours for the reaper:
- Buffer beyond the 24h guarantee for safety
- Matches typical incident-response and post-mortem windows
- Keeps the keyspace bounded for storage and index size
Incorrect (permanent storage — unbounded growth):
-- No expiration. Table grows forever.
CREATE TABLE idempotent_responses (
account_id VARCHAR(255),
idempotency_key VARCHAR(255),
response_body JSONB,
created_at TIMESTAMPTZ,
PRIMARY KEY (account_id, idempotency_key)
);// After a year: billions of rows, index is huge, queries slow down.
// Integrators start using keys as a business-level deduplication store — wrong abstraction.
// Backup and migration costs balloon.Incorrect (5-minute TTL — too short for real retry patterns):
-- Cached for 5 minutes only
DELETE FROM idempotent_responses WHERE created_at < NOW() - INTERVAL '5 minutes';// Async retry from a queue worker an hour later → key already expired.
// Server treats it as a fresh request → duplicate charge.
// Defeats the whole point of idempotency for any non-immediate retry.Correct (24h guarantee + 72h reaper):
-- Reaper job runs hourly
DELETE FROM idempotent_responses
WHERE created_at < NOW() - INTERVAL '72 hours';Document the guarantee window explicitly:
Idempotency keys are guaranteed for 24 hours after the original request. Retries within this window will return the cached response. Keys submitted after this window may be treated as new requests.
For longer-running operations (a multi-day batch import), the right mechanism is a job/task resource (POST /v1/import_jobs returning a job ID, then GET /v1/import_jobs/{id} to poll). The job ID is the permanent deduplication key for that operation — not the idempotency key.
For multi-step idempotency within the 24h window (creating a charge involves multiple foreign-state mutations), see `idem-recovery-points`.
Reference: Brandur — idempotency key TTLs
Return 409 When a Key Is Reused with Different Params
When a request arrives with an idempotency key that's been seen before but with different parameters, the server returns 409 Conflict with type: "idempotency_error". This is non-negotiable — "sending different parameters with the same idempotency key is a bug" (Brandur Leach). The server fails loudly because the alternatives — silently executing the new params, or silently returning the old response — both hide a real bug in the integrator's retry logic.
The check is implemented by hashing the request parameters at insert time and comparing on subsequent requests. If the hash matches, return the cached response (the normal idempotent path). If the hash doesn't match, return 409. Either way, the side effect happens at most once.
Incorrect (silently re-execute with new params):
def post_charge(account_id, key, params):
cached = get_cached(account_id, key)
if cached and cached.params == params:
return cached.response
# Different params? Just execute again with the new ones.
charge = create_charge(params)
save_cached(account_id, key, params, charge)
return charge// Bug: integrator's retry logic mutates the params between attempts (different amount? different source?).
// Server creates a charge with the WRONG amount, returns it as if everything's fine.
// Customer is charged incorrectly; no error signal anywhere.Incorrect (silently return old response even though params differ):
def post_charge(account_id, key, params):
cached = get_cached(account_id, key)
if cached:
return cached.response # ignore param differences
...// Integrator retries with new params expecting the new behaviour.
// Server returns the OLD response. Integrator thinks the new request succeeded; it didn't.
// Worst kind of bug: looks like success, isn't.Correct (hash the params, return 409 on mismatch):
def post_charge(account_id, key, params):
params_hash = sha256(canonical_form(params))
cached = get_cached(account_id, key)
if cached:
if cached.params_hash == params_hash:
return cached.response # idempotent replay — return cached
else:
return error_response(
status=409,
type='idempotency_error',
code='idempotency_key_in_use',
message=(
'Idempotency key "' + key + '" was previously used with '
'different parameters. Each retry must use the exact same '
'parameters as the original request.'
),
doc_url='https://docs.example.com/error-codes/idempotency_key_in_use'
)
# First time with this key — store params hash and execute
charge = create_charge(params)
save_cached(account_id, key, params_hash, charge)
return chargeWire response on conflict:
HTTP/1.1 409 Conflict
Content-Type: application/json
Request-Id: req_abc123
{
"error": {
"type": "idempotency_error",
"code": "idempotency_key_in_use",
"message": "Idempotency key '4ab9c8a1...' was previously used with different parameters. Each retry must use the exact same parameters as the original request.",
"doc_url": "https://stripe.com/docs/error-codes/idempotency-key-in-use"
}
}Why this is its own error `type` (idempotency_error, not invalid_request_error): the action is unique. Retry is dangerous; surfacing to users is wrong (they didn't do anything); the right response is "fix the integration bug." Giving it a dedicated type forces integrators to handle it correctly. See `error-four-type-enum`.
Canonical form for hashing: sort fields lexicographically, normalise whitespace, exclude transient fields (timestamps, request IDs). The goal is "same logical request → same hash" regardless of serialisation order.
Don't include the API version in the hash — clients that upgrade their pinned version shouldn't get false 409s for retries of pre-upgrade requests.
Concurrent requests with the same key (request 2 arrives while request 1 is still executing): hold request 2 until request 1 completes (with a timeout), then return the cached response. Don't 409 — that would be a false positive.
Reference: Brandur — idempotency key conflicts
Accept Idempotency-Key Header on All Mutating Requests
Every POST (create, update, action endpoint) accepts an Idempotency-Key header. The server stores the response keyed by (account, key) for a TTL window; subsequent requests with the same key return the cached response instead of re-executing. GET, HEAD, and DELETE are inherently idempotent at the HTTP level and don't need keys.
This is the only safe answer to "what happens when the client retries a charge because the network dropped the response?" Without idempotency keys, the safe options are (a) never retry — losing data on transient failures — or (b) hope the operation is naturally idempotent — which charges and transfers absolutely aren't. With keys, the client retries freely; the server guarantees the side effect happens at most once.
Incorrect (no idempotency mechanism — retries double-charge):
// Client retries on network timeout
async function charge(amount) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await fetch('/v1/charges', { method: 'POST', body: ... });
} catch (e) {
if (e.code === 'ETIMEDOUT') continue; // retry
throw e;
}
}
}// Attempt 1: server creates charge ch_a, response times out before reaching client.
// Attempt 2: client retries, server creates charge ch_b. Customer is double-charged.
// No way for client to know the first attempt actually succeeded.Incorrect (custom request-deduplication scheme):
const requestId = uuid();
await fetch('/v1/charges', {
method: 'POST',
body: `request_id=${requestId}&amount=2000&...`
});// `request_id` is a request body field, not a header — middleware can't see it.
// No standard way for SDKs to retry automatically with the same dedup token.
// Server has to parse the body to dedupe; can't reject duplicates at the edge.Correct (Idempotency-Key header on every POST):
POST /v1/charges HTTP/1.1
Idempotency-Key: 4ab9c8a1-7e3d-4c8f-9b21-7d1f3c5e8a91
Content-Type: application/x-www-form-urlencoded
amount=2000¤cy=usd&source=tok_visa// SDK generates a key per logical operation; retries reuse the key
const idempotencyKey = uuid();
async function chargeWithRetry(amount) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await stripe.charges.create(
{ amount, currency: 'usd', source: 'tok_visa' },
{ idempotencyKey }
);
} catch (e) {
if (e.type === 'api_error' && attempt < 2) continue;
throw e;
}
}
}
// Attempt 1: server creates charge ch_a, response lost.
// Attempt 2: server sees same key, returns cached response for ch_a — no second charge.Key format: client-generated; the server doesn't impose a format. UUIDs work; any unique string ≤255 chars is fine.
SDK behaviour: every official SDK should generate keys automatically when none is provided, so common retries are safe by default. Document the auto-generation so integrators understand it.
Response indicates whether this was a replay:
HTTP/1.1 200 OK
Idempotent-Replayed: trueSo integrators can distinguish "this is the original response" from "this is a cached replay."
Scope, TTL, and conflict semantics are covered in `idem-scoped-per-account`, `idem-24h-ttl`, and `idem-fail-on-key-reuse`. For multi-step operations, see `idem-recovery-points`.
Reference: Stripe idempotent requests, Brandur — idempotency keys
Use Recovery Points for Multi-Step Idempotent Operations
A POST /v1/charges request isn't a single atomic operation — internally it might (a) authorise the card, (b) write a row to the charges table, (c) enqueue a webhook event, (d) update the customer's balance. If the server crashes between (b) and (c), the customer is charged but no webhook fires. Simply returning the cached response on retry isn't enough — the retry needs to resume from where the original failed.
The recovery-point pattern (Brandur Leach) handles this: persist a recovery_point field on the idempotency key record at each phase boundary (started → card_authorized → charge_persisted → webhook_enqueued → finished). On retry, the server reads the recovery point and resumes from the next phase, executing each remaining step idempotently. The cached response is returned only when recovery_point = 'finished'.
Incorrect (single-phase idempotency — partial failures lose work):
def post_charge(account_id, key, params):
cached = get_cached(account_id, key)
if cached:
return cached.response
# All-or-nothing — if we crash mid-way, the next retry executes everything again,
# but step (a) has side effects we can't undo (card already charged at the network).
auth = card_network.authorize(params)
charge = db.insert_charge(params, auth)
webhook_queue.enqueue('charge.created', charge)
balance_service.update(account_id, charge.amount)
save_cached(account_id, key, params, charge)
return charge// Server crashes after `db.insert_charge` but before `webhook_queue.enqueue`.
// Charge exists, webhook never fires; downstream systems don't know.
// Retry runs the WHOLE function — `card_network.authorize` runs again → second card auth.
// `db.insert_charge` violates unique constraint or creates a duplicate row.Correct (recovery-point pattern — resume from where you crashed):
def post_charge(account_id, key, params):
record = upsert_idempotency_record(account_id, key, params)
if record.recovery_point == 'finished':
return record.response # standard idempotent replay
if record.recovery_point == 'started':
auth = card_network.authorize_idempotent(params, key) # network-level dedup
record = update_recovery_point(record, 'card_authorized', {'auth': auth})
if record.recovery_point == 'card_authorized':
charge = db.insert_charge_idempotent(params, record.data.auth, key)
record = update_recovery_point(record, 'charge_persisted', {'charge': charge})
if record.recovery_point == 'charge_persisted':
webhook_queue.enqueue_idempotent('charge.created', record.data.charge, key)
record = update_recovery_point(record, 'webhook_enqueued')
if record.recovery_point == 'webhook_enqueued':
balance_service.update(account_id, record.data.charge.amount, key)
record = update_recovery_point(record, 'finished', response=record.data.charge)
return record.response// Crash between any two phases? Retry resumes from the recorded recovery point.
// Each step uses the idempotency key for downstream dedup (card network, DB, queue).
// Customer is never double-charged; webhooks always fire eventually.Each foreign mutation gets its own atomic phase boundary. The rule is: write the recovery point only after the foreign side effect is durable. Don't bundle multiple foreign mutations into one phase — if you crash mid-phase, you re-execute the foreign mutation.
Downstream services accept the same idempotency key for their own deduplication. This is the recursive part of the pattern: the charge service passes the key to the card network, the DB insert, the webhook queue. Each layer dedupes independently using the same key.
The recovery-point state machine is per-endpoint and lives alongside the request hash. Add columns to the idempotency record:
ALTER TABLE idempotent_responses
ADD COLUMN recovery_point VARCHAR(64) NOT NULL DEFAULT 'started',
ADD COLUMN recovery_data JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN locked_at TIMESTAMPTZ,
ADD COLUMN lock_holder VARCHAR(255);Hold a lock for the duration of a request to prevent concurrent execution of the same key — two parallel retries shouldn't both advance the state machine. Use SELECT ... FOR UPDATE or a separate lock column with a TTL.
When NOT to use recovery points: for genuinely atomic single-step operations (POST /v1/customers that just inserts a row). The pattern is for operations with multiple foreign side effects.
Reference: Brandur — implementing Stripe-like idempotency keys
Scope Idempotency Keys per Account, Not Globally
Idempotency keys are unique per (account, key) tuple, not globally. Two different accounts can use the same key string ("abc123") without conflict — each has its own keyspace. This matters for two reasons: (1) information disclosure — globally-unique keys would let one account discover another's keys by guessing; (2) collision avoidance — integrators don't have to coordinate key generation across tenants.
The database constraint is a composite unique index: UNIQUE (account_id, idempotency_key). Implementation cost is trivial; the alternative (single global keyspace) creates a class of bugs that surface only at scale when two unrelated integrators happen to choose the same key.
Incorrect (global keyspace — accounts can collide and leak):
CREATE TABLE idempotent_responses (
idempotency_key VARCHAR(255) PRIMARY KEY,
account_id VARCHAR(255) NOT NULL,
response_body JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);// Account A submits key "order-123" → cached.
// Account B submits key "order-123" → either gets A's response (data leak!) or 409 (UX bug).
// Integrators have to know to namespace their keys: "acct_X-order-123" — leaks their account ID.Correct (per-account keyspace with composite unique index):
CREATE TABLE idempotent_responses (
account_id VARCHAR(255) NOT NULL,
idempotency_key VARCHAR(255) NOT NULL,
request_hash VARCHAR(64) NOT NULL, -- for idem-fail-on-key-reuse
response_status SMALLINT NOT NULL,
response_body JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (account_id, idempotency_key)
);// Account A's key "order-123" lives in its own keyspace.
// Account B's key "order-123" is independent — no leak, no collision.
// Integrators use natural keys (order IDs, business event IDs) without namespacing.Lookup is also scoped:
def get_cached_response(account_id: str, key: str) -> Optional[Response]:
row = db.fetch_one(
"SELECT response_status, response_body, request_hash "
"FROM idempotent_responses "
"WHERE account_id = $1 AND idempotency_key = $2",
account_id, key
)
return rowFor Connect / platform APIs, the scope still includes the acting account (the one in Stripe-Account: acct_X), not the platform account. A platform that operates on behalf of 1000 connected accounts has 1000 separate keyspaces, one per acted-on account. This means the same platform code can submit the same key string for two different connected accounts without conflict.
Key uniqueness is enforced at insert time via the unique index, not in application code. The constraint violation surfaces as the conflict path in `idem-fail-on-key-reuse`.
Document the scoping rule prominently so integrators understand:
Idempotency keys are scoped per account. The same key string may be used safely by different accounts without conflict.
Reference: Brandur — designing idempotency keys
Use American English Spelling (canceled, Not cancelled)
API identifiers use American English consistently — canceled, cancel, canceling (single 'l'); authorize (not authorise); color (not colour); behavior (not behaviour); license (not licence); analyze (not analyse). The choice doesn't matter; the consistency does. Once an API ships with a British spelling on an enum value or event name, fixing it is a breaking change requiring the dated-version migration machinery.
Stripe picked American English and applies it uniformly. The same logic applies to any API team — pick one variant, document it, and audit every shipped identifier. The cost of catching this before launch is zero; the cost of catching it after is a full version-bump cycle for what is essentially a cosmetic issue.
Incorrect (British spelling on load-bearing identifiers):
{
"status": "cancelled_by_customer",
"object": "appointment"
}// Event: booking/appointment.cancelled
// Field: cancelledBy (also wrong casing — see naming-snake-case-wire-format)
// Once integrators write switch (event.type) { case 'booking/appointment.cancelled': ... },
// renaming to .canceled requires a version-change module.Incorrect (mixed British and American across the API):
{
"cancelled_at": 1672531200,
"authorized_at": 1672531100,
"behavior": "manual",
"colour": "blue"
}// Two consumers reading the docs guess different spellings for the next field.
// Schema validation in any one language flags one variant as a typo.Correct (American English uniformly):
{
"status": "canceled_by_customer",
"canceled_at": 1672531200,
"authorized_at": 1672531100,
"behavior": "manual",
"color": "blue"
}// Event: booking/appointment.canceled (single 'l')
// Field: canceled_by, canceled_at, canceled_reason
// Consistent across every endpoint, payload, and event in the API.The most common offenders to audit:
| British | American | Where to check |
|---|---|---|
cancelled / cancelling | canceled / canceling | event types, status enums, timestamps |
authorised / authorisation | authorized / authorization | auth-related fields, headers |
colour | color | UI/display config |
behaviour | behavior | config/settings fields |
licence (noun) | license | metadata, attribution |
analysed / analyser | analyzed / analyzer | reporting/analytics fields |
optimise / organisation | optimize / organization | feature names, business fields |
centre | center | layout/UI fields |
flavour | flavor | variant names |
pyjamas | pajamas | (kidding — but audit) |
Add a CI lint that flags British spellings in new schema files. The cost is one regex; the benefit is catching the problem at PR time, not at version-bump time.
Don't mix variants on the same word — if you ship canceled once, every other use must be canceled. Mixed spellings (canceled_at and cancelled_by) on the same resource are the worst signal of a missing review gate.
If you prefer British English, that's also fine — what's not fine is mixing or shipping without an explicit choice. Document the choice in your API design guide and audit at PR time.
Pair this with [`naming-snake-case-wire-format`](naming-snake-case-wire-format.md) — both rules govern the orthographic conventions of wire identifiers. A cancelledBy field violates both (British spelling and camelCase) and is the most common signal of a missing review gate on naming.
Reference: Stripe API conventions
Booleans — Past-Tense Verbs and Plain Adjectives, Not is_/has_ Prefixes
Stripe's boolean fields use plain adjectives and past-tense verbs without prefixes: livemode, paid, captured, refunded, disputed, delinquent, cancel_at_period_end, default_for_currency. The convention reads naturally in code (if (charge.captured)) and avoids the prefix sprawl that comes from is_paid, has_refund, was_captured, did_refund mixed across one API.
This is a minor stylistic rule by itself, but consistency compounds. With a uniform pattern, integrators stop reading docs to remember "is it is_paid or paid or has_paid or paid_flag?" and the API surface gets noticeably cleaner.
Incorrect (mixed prefix conventions — every field is a guess):
{
"is_paid": true,
"has_refund": false,
"was_captured": true,
"did_dispute": false,
"refund_issued": true,
"cancellation_flag": false
}// Six fields, four different prefix conventions.
// Integrator can never predict whether the next boolean field will be `is_*`, `has_*`, `was_*`, or unprefixed.
// Code reads awkwardly: `if (charge.was_captured && !charge.has_refund && !charge.is_disputed)`Incorrect (`_flag` suffix — never says anything):
{
"active_flag": true,
"verified_flag": false,
"premium_flag": true
}// `_flag` is pure noise — adds zero information.
// `active_flag: true` is exactly as informative as `active: true`.Correct (past-tense verbs and plain adjectives):
{
"livemode": false,
"paid": true,
"captured": true,
"refunded": false,
"disputed": false,
"delinquent": false,
"cancel_at_period_end": true,
"default_for_currency": false
}// Reads naturally: if (charge.captured && !charge.refunded && !charge.disputed)
// Single convention; no guessing about prefixes.
// Field names are shorter; less token cost in logs, payloads, and SQL columns.The patterns Stripe uses:
| Pattern | When | Example |
|---|---|---|
| Past-tense verb | The state was achieved at some point | captured, refunded, paid, attempted |
| Plain adjective | Persistent property of the resource | livemode, delinquent, default |
| Verb phrase | Future-conditional action | cancel_at_period_end, automatic_tax |
| Negative-prefixed only when natural | Avoid double-negatives | pause_collection (object, not boolean) |
Don't double-negate. not_paid: false is "you must not... not pay" — confusing. Negate the noun instead: paid: false.
Nullable booleans are tri-state. A delinquent: null is meaningfully different from delinquent: false ("we don't know yet" vs "we know they aren't"). Document the meaning of null explicitly when it's a valid value.
For new properties that might gain states later, prefer enums — see `naming-enums-over-booleans`. A verified: true/false ships today but cannot express "pending review" without a breaking change.
Avoid Hungarian notation (b_paid, bool_captured) — language types are not part of the field name.
Reference: Stripe Charge object
Prefer Enums over Booleans for New Status/Flag Fields
For genuinely-binary states (livemode, default_for_currency), a boolean is correct. For anything that might gain a third state in the future — a workflow status, a verification result, a content moderation outcome — ship an enum instead. A boolean verified: true/false looks adequate today but cannot express pending, requires_action, under_review, or rejected without a breaking change. An enum verification_status: "verified" | "pending" | "rejected" extends additively as the workflow grows.
The cost of the enum is one extra string per response and slightly more explicit handling in clients; the benefit is permanent forward-compatibility for any workflow that turns out to be richer than initially expected. This pattern matters more for new properties than legacy ones — when you're naming a new field, ask "could this realistically have a third state?" If yes, make it an enum.
Incorrect (boolean that traps you):
{
"refund_issued": true
}// Adding "refund pending" → breaking change. Options:
// (a) Add `refund_pending: true` alongside → two booleans where one enum belongs.
// (b) Add `refund_status: "issued" | "pending" | "denied"` → now both fields exist, integrators confused.
// (c) Change `refund_issued` to nullable → still breaking for clients that don't tolerate null booleans.Incorrect (boolean for an inherently multi-state workflow):
{
"kyc_passed": false
}// Real-world KYC has at least: not_started, in_progress, passed, failed, requires_additional_info.
// Encoding all of that as one boolean loses every distinction except "is the result a pass".
// Integrators write `if (!user.kyc_passed) blockSignup(user)` — but blocking is wrong for `in_progress`.Correct (enum from the start — additive extensibility):
{
"refund_status": "issued"
}// Later, additively:
{
"refund_status": "pending"
}
// And later:
{
"refund_status": "denied"
}// Adding values is backwards-compatible if clients tolerate unknown values (ver-tolerate-unknown).
// No breaking change required to evolve the workflow.
// Clients write switch (refund.status) { ... default: ... } — handles new values gracefully.Correct (verification example):
{
"verification_status": "verified"
}
// Initial enum values: "unverified" | "pending" | "verified"
// Later additive value: "rejected" | "requires_additional_info"
// Even later: "manual_review"
// All additive, all non-breaking.When a boolean is genuinely correct:
| Boolean is fine | Why |
|---|---|
livemode | Will always be exactly two values: test or live |
default_for_currency | One default per currency; binary by definition |
automatic_tax (enabled/disabled) | Discrete on/off feature toggle |
captured (in a card-payment context) | Captured-or-not is binary for the lifetime of the charge |
When an enum is the better choice:
| Domain | Boolean (bad) | Enum (good) |
|---|---|---|
| Workflow outcome | verified | verification_status |
| Refund state | refund_issued | refund_status |
| Subscription state | is_active | status (with active, trialing, past_due, canceled, unpaid, incomplete) |
| Moderation | approved | moderation_status |
| Dispute lifecycle | disputed | dispute_status (when nuance matters) |
The retrofit cost is asymmetric. Boolean → enum is breaking. Enum with new values → almost always non-breaking. So when in doubt, ship the enum.
Enum values follow the same casing as all wire identifiers: lowercase snake_case. See `naming-snake-case-wire-format`.
Document enum extensibility upfront:
The status field is an extensible enum. The current values are listed below, but new values may be added in future versions. Clients must tolerate unknown values gracefully — fall back to a default handler rather than crashing.Reference: Stripe Subscription.status
Provide a metadata Pass-Through with Strict Limits
Every mutable resource has a metadata field — a flat key-value map for customer-defined data the API itself doesn't read. Stripe specifies hard limits: 50 keys, keys up to 40 characters, values up to 500 characters, all stored as strings. The platform never interprets metadata for processing or authorisation; it's pure passthrough.
This pattern absorbs ~90% of "can you add a field for X?" requests without expanding the official schema. Integrators tag charges with their internal order IDs, link customers to their CRM, attach context for support — all without needing the API team to add a field. The strict limits prevent metadata from becoming a poor-man's database (large blobs, many keys, attempted schema enforcement).
Incorrect (no metadata field — every tagging need becomes a feature request):
{
"id": "ch_X",
"object": "charge",
"amount": 2000
}// Integrator wants to link this charge to their internal order #6735.
// Options: (a) store the link in their database, do a join later (slow, fragile)
// (b) abuse the `description` field (no structure)
// (c) file a feature request to add `customer_order_id` — API team adds fields for everyone's special case.Incorrect (metadata with no limits — becomes a database):
{
"metadata": {
"order_id": "6735",
"customer_history": "[ 10000-row JSON blob of order history ]",
"fraud_score_model_v3": "[ 200KB of ML feature vectors ]",
"...": "...",
"key_47291": "..."
}
}// Storage costs explode. Indexes can't handle arbitrary keys.
// Backups, exports, and migrations become unwieldy.
// Integrators stop using their own database; API team becomes accidental database vendor.Correct (metadata field with documented hard limits):
POST /v1/charges
Content-Type: application/x-www-form-urlencoded
amount=2000¤cy=usd&source=tok_visa&metadata[order_id]=6735&metadata[customer_internal_id]=CUST-91234// Response:
{
"id": "ch_X",
"object": "charge",
"amount": 2000,
"metadata": {
"order_id": "6735",
"customer_internal_id": "CUST-91234"
}
}// Integrator links the charge to their order without the API team adding a field.
// Listing charges by metadata works: GET /v1/charges?metadata[order_id]=6735 (via search).
// Webhooks include metadata, letting downstream systems reconstruct context.The limits — and the why behind them:
| Limit | Value | Why |
|---|---|---|
| Max keys per object | 50 | Storage planning, JSON parse cost, response size |
| Max key length | 40 chars | Index size, log readability |
| Max value length | 500 chars | Prevents metadata-as-blob; forces use of real storage for large data |
| Value type | string only | Predictable serialisation; no type churn |
| Reserved characters in keys | no [ or ] | Bracket notation in form-encoded bodies needs unambiguous keys |
The platform never interprets metadata. This is the load-bearing promise — the integrator can put whatever they want in there knowing the API won't accidentally take a code path based on metadata content. Authorisation, routing, validation: none of them read metadata.
Document the no-secrets rule:
Do not store sensitive data (card numbers, bank credentials, SSNs, passwords) inmetadataordescription. These fields are visible in logs, the dashboard, and webhook payloads to all team members with access.
Updating metadata:
- Set
metadata[key]=valueto add or update a single key - Set
metadata[key]=(empty value) to remove a single key - Set
metadata=(empty top-level) to clear all metadata at once - Omit
metadatafrom the update entirely → metadata unchanged
Don't ship a `tags` array, a `properties` map, AND a `metadata` map — pick one and apply it uniformly. Stripe picked metadata (lowercase, singular, kv-shaped). Multiple parallel customer-data fields create confusion about which to use.
For platform-defined custom fields with stricter schemas (e.g., Stripe Checkout's custom_fields for collecting structured input from the buyer), use a separate, dedicated field. metadata is for the integrator's pass-through data; structured custom fields are for end-user input.
Reference: Stripe metadata
Names — Simple, Unambiguous, No Leading Digits, No Jargon
Field and resource names are plain language, unambiguous, and lexically valid in every common programming language. Concretely: don't start with a digit (breaks identifier syntax in most languages); don't include industry jargon when a plain word exists; don't reuse the same name for two different things in the same response; and don't embed vendor or implementation details in identifiers that should outlive them.
These look like trivia individually, but each one is a name that, once shipped, can only be changed via a breaking-version cycle. Catching them at PR review is free; catching them in v2 is expensive.
Incorrect (leading digit, jargon, vendor leak):
{
"3ds_required": true, // identifier starts with digit
"kyc_eddc_status": "approved", // industry jargon (EDD-C)
"stripeCheckoutSessionId": "cs_test_X" // vendor name in field
}// `3ds_required` breaks JS/Python/Java identifier syntax — accessors need quotes/brackets.
// `kyc_eddc_status` — only KYC specialists know "EDD-C" (Enhanced Due Diligence Compliance).
// `stripeCheckoutSessionId` couples the API surface to a specific payment provider.
// Renaming any of these later requires a dated-version migration.Incorrect (same name for two different things):
{
"amount": 2000,
"items": [
{ "amount": 1500 }, // is this the item subtotal? quantity? unit price?
{ "amount": 500 }
]
}// `amount` at top level means "total"; `amount` per item means... what?
// Consumers must read docs to disambiguate. Some will guess wrong.Incorrect (overly clever or playful):
{
"moolah": 2000, // slang
"boop_at": 1672531200, // cute, opaque
"yeet_threshold": 500 // ¯\_(ツ)_/¯
}// Cute names rot. The team that thought "yeet" was funny in 2024 won't in 2028.
// Non-English speakers (most of the internet) have no chance.
// Imagine these in a production incident at 3am.Correct (simple, plain language, no jargon, no leading digit, no vendor leak):
{
"amount": 2000, // total
"items": [
{ "unit_price": 1500 }, // unambiguous: per-item price
{ "unit_price": 500 }
],
"three_d_secure_required": true, // spelled out, alphabetic-first
"verification_status": "approved", // plain language, not "kyc_eddc_status"
"checkout_session_id": "cs_test_X" // no vendor name — prefix is on the value
}// Every field name reads as English. No jargon decoder ring needed.
// Identifiers are valid in every language without quoting.
// Vendor-neutral — the cs_ prefix on the value signals provenance to those who care.Naming rules to apply at PR review:
| Rule | Example bad | Example good |
|---|---|---|
| Don't start with a digit | 3ds_required | three_d_secure_required |
| Don't include vendor names | stripe_session_id | checkout_session_id |
| Don't use abbreviations specific to one industry | kyc_eddc_status | verification_status |
| Don't reuse a name for different concepts in the same response | amount (total) + amount (per item) | amount + unit_price |
| Don't use slang, jokes, or culturally-specific terms | moolah, yeet_threshold | amount, refund_limit |
| Don't use language-reserved words | class, type (sometimes), default | kind, payment_type, default_method |
| Prefer the resource name as a context (don't repeat it) | customer.customer_email | customer.email |
| Don't pluralise singular concepts | informations, metadatas | information, metadata |
Don't suffix booleans with _flag | active_flag | active |
A name that needs a comment to explain it is the wrong name. Rename it before merge.
Reference: Stripe API field names
Use snake_case for All Wire Identifiers
Every identifier on the wire is lowercase_snake_case: field names (payment_method, billing_details, amount_captured), enum values (requires_action, card_declined), event types (payment_intent.succeeded), error codes (incorrect_cvc), action endpoint verbs (/capture, /cancel). The rule applies uniformly across JSON response payloads, form-encoded request fields, query string parameters, event names, and error codes.
SDK clients can map snake_case to their language's preferred convention (camelCase in TypeScript/JS/Java, PascalCase for types) at the SDK layer — that's a one-time mapping the SDK owns. But the wire format is the contract every consumer reads; mixing casings on the wire creates a permanent compatibility tax that ripples through every integration.
Incorrect (camelCase on the wire):
{
"slotHoldId": "sh_abc",
"clientSecret": "cs_...",
"stripeCheckoutSessionId": "cs_test_123",
"cancelledBy": "customer"
}// Inconsistent with the rest of the API surface (query params often end up snake_case anyway).
// Forces every non-JS client to camel-case manually or reach for a converter.
// Future rename to snake_case is a breaking change for every consumer.Incorrect (mixed casing — worst of both):
{
"session_id": "cs_test_123",
"paymentIntentId": "pi_X",
"amount_captured": 2000,
"refundIssued": true
}// Two conventions in one response — consumers can never predict the casing of a new field.
// Symptom of multiple authors not enforcing the convention; signals a missing review gate.Correct (snake_case uniformly):
{
"session_id": "cs_test_123",
"payment_intent_id": "pi_X",
"amount_captured": 2000,
"refund_issued": true,
"canceled_by": "customer"
}// Single convention everywhere. No mental switching for consumers.
// SDKs can mechanically map to language conventions:
// TS: { sessionId: response.session_id, paymentIntentId: response.payment_intent_id }
// One mapping layer in the SDK; rest of the wire stays clean.The convention extends to:
| Surface | Example |
|---|---|
| JSON response fields | "amount_captured": 2000 |
| Form-encoded request fields | metadata[order_id]=6735 |
| Query string params | ?starting_after=cus_X&created[gte]=1672531200 |
| Enum values | "status": "requires_action" |
| Event types | "type": "payment_intent.succeeded" |
| Error codes | "code": "card_declined" |
| Action endpoint verbs | POST /v1/invoices/{id}/finalize |
Multi-word values stay snake_case (requires_payment_method, not requiresPaymentMethod or requires-payment-method).
Acronyms are lowercase — id not ID, url not URL, api_version not API_version. The lowercase rule beats acronym capitalisation.
SDKs translate, the wire doesn't:
// TypeScript SDK exposes camelCase for ergonomics:
const charge = await stripe.charges.retrieve('ch_X');
charge.amountCaptured; // 2000
// But the underlying request/response is snake_case:
// GET /v1/charges/ch_X → { "amount_captured": 2000, ... }Reference: Stripe API reference
Discriminate Polymorphic Types with a type Field and Sibling Objects
When a field can hold one of several variants — a PaymentMethod can be a card, a SEPA debit, a US bank account, etc. — use a type discriminator field with type-specific data under sibling objects named identically to the type value. Only the sibling matching the current type is populated; the others are null or absent. This pattern produces a single, stable schema that every variant fits, and lets SDK code generators emit one polymorphic deserialiser instead of N runtime sniffers.
The alternative — untagged unions (payment_method: card | sepa | bank) or per-variant endpoints — forces consumers to write if (payment_method.last4) ... else if (payment_method.iban) .... That kind of structural type-sniffing is fragile (a new variant with a last4 field could be mistaken for a card) and impossible to validate statically.
Incorrect (untagged union — type inferred from field presence):
{
"id": "pm_X",
"object": "payment_method",
"last4": "4242",
"exp_month": 12,
"exp_year": 2030,
"brand": "visa"
}// What kind of payment method is this? Looks like a card — `last4`, `exp_month`, `brand` are card-shaped fields.
// A future US bank account variant could have its own `last4` (last 4 of account number).
// Type sniffing breaks: `if (pm.last4 && pm.exp_month) treatAsCard(pm)` — until SEPA also has last4.Incorrect (per-variant endpoint with no shared schema):
GET /v1/cards/card_X → { "id": ..., "last4": ... }
GET /v1/sepa_debits/sepa_X → { "id": ..., "iban": ... }
GET /v1/us_bank_accounts/ba_X → { "id": ..., "routing": ... }// No unified resource — can't list "all payment methods on customer X".
// SDKs need per-variant types; no shared interface.
// Adding a new variant means a new endpoint, new resource, new types.Correct (`type` discriminator + sibling objects):
{
"id": "pm_X",
"object": "payment_method",
"type": "card",
"card": {
"brand": "visa",
"last4": "4242",
"exp_month": 12,
"exp_year": 2030,
"fingerprint": "..."
},
"sepa_debit": null,
"us_bank_account": null,
"billing_details": { "name": "Jenny Rosen", "email": "..." },
"metadata": {}
}// Same schema, different variant:
{
"id": "pm_Y",
"object": "payment_method",
"type": "us_bank_account",
"card": null,
"sepa_debit": null,
"us_bank_account": {
"account_type": "checking",
"bank_name": "Stripe Test Bank",
"last4": "6789",
"routing_number": "110000000"
},
"billing_details": { "name": "Jenny Rosen", "email": "..." },
"metadata": {}
}// Single schema fits every variant. Generic list endpoint works:
// GET /v1/customers/cus_X/payment_methods?type=card (also filter by type)
// SDK polymorphic deserialiser:
// switch (pm.type) { case 'card': return parseCard(pm.card); case 'us_bank_account': ... }
// Adding a new variant: add a new `type` value + a new sibling object. Backwards-compatible.Shared fields stay at the top level — id, object, type, billing_details, metadata, created apply to every variant regardless of type. Variant-specific data lives in the named sibling.
Sibling object names match the `type` value exactly: type: "card" → field card; type: "us_bank_account" → field us_bank_account. The mechanical correspondence lets clients write pm[pm.type] to extract the variant-specific data in any dynamic language.
Empty siblings are `null`, not absent. Stripe explicitly returns card: null on a SEPA-typed payment method — this keeps the response shape deterministic and lets schema validators check that every sibling field exists. The explicit null is part of the polymorphic schema contract; the absence-vs-null choice for non-polymorphic optional fields is separate.
The discriminator is a string enum, not a boolean (is_card: true) or a numeric code (type: 1). Strings are self-describing in logs and tooling; integers are opaque without a translation table.
Adding a new variant is backwards-compatible if clients tolerate unknown type values (see `ver-tolerate-unknown`). Old SDKs receive type: "new_variant" and fall through to a generic handler without crashing.
Reference: Stripe PaymentMethod object
Represent Birth Dates as {day, month, year} Hashes
Birth dates and other dates collected via three separate form inputs should be represented as a hash with integer day, month, and year fields — not as a string. UIs collect them as three discrete inputs; a structured object maps naturally to those inputs, removes locale-parsing ambiguity ("02/03/1990" is March 2 in the US and February 3 almost everywhere else), and avoids the silent failure of a consumer treating DD/MM/YYYY as MM/DD/YYYY.
Stripe applies this pattern to dob fields on Persons (Connect) and Identity Verification. The integer triple is unambiguous, sortable component-by-component, and trivially convertible to any locale's display format.
Incorrect (locale-string date):
{
"dob": "14/02/1990"
}// UK locale: 14 February 1990. US locale: invalid (no month 14).
// Round-tripping through Date constructors guesses wrong half the time.
// Form inputs in three fields had to be concatenated and re-parsed.Incorrect (ISO 8601 string for a structured form input):
{
"dob": "1990-02-14"
}// Better than locale strings, but still wrong shape for a 3-field UI.
// Forces client to concatenate three inputs into a string and back.
// Partial dates (year known, month unknown) cannot be represented.Correct (`{day, month, year}` integer hash):
{
"dob": {
"day": 14,
"month": 2,
"year": 1990
}
}// Maps one-to-one with the three form inputs.
// Each field is a small integer — no parsing, no locale guesswork.
// Partial dates degrade gracefully (omit the unknown component).Common use cases:
- Birth dates on KYC, Connect Persons, Identity Verification
- Expiration dates for cards (
exp_month,exp_yearinteger pair — same principle) - Any "date assembled from discrete user inputs" field
For date-only values that aren't assembled from user inputs (billing dates, period boundaries), use ISO 8601 date strings — see `resource-iso-date-only`. For datetimes with a time component, see `resource-unix-seconds-timestamps`.
Reference: Stripe Person.dob (Connect)
Related skills
FAQ
What does stripe-inspired-api-design-rules do?
stripe-inspired-api-design-rules is a Claude Code skill in the AI & Agent Building category.
When should I use stripe-inspired-api-design-rules?
When you need to helps with ai & agent building tasks during ai-assisted development, or when stripe-inspired-api-design-rules is a claude code skill in the ai & agent building category.
What are the main capabilities?
stripe-inspired-api-design-rules; AI & Agent Building; AI-coding skill.