Stripe API Design Principles
Stripe API Design Principles
Guía de referencia para comparar y mejorar APIs propias
Fuente principal: Stripe (considerado el gold standard de diseño de APIs por la industria)
Fecha de investigación: Mayo 2026
Uso: Auditoría y mejora de APIs internas
Tabla de Contenido
- Filosofía central
- Los API Design Documents internos
- Patrón: IDs prefijados y únicos
- Patrón: Polymorphic Lookups
- Patrón: Cursor-Based Pagination
- Patrón: Response Object consistente
- Patrón: Expandable Objects
- Patrón: Idempotency Keys
- Patrón: Date-Based Versioning
- Patrón: Error Responses accionables
- Patrón: Metadata para extensibilidad
- Consistencia entre capas: REST → SDK → React SDK
- Request Logs e Inspectabilidad
- Test Mode como ciudadano de primera clase
- Proceso organizacional: API Review y Abstraction Ladders
- Checklist de auditoría para tus APIs
- Referencias para profundizar
1. Filosofía central
“APIs son productos, y los desarrolladores son clientes.”
Stripe no trata su API como un detalle de implementación expuesto. La tratan como un producto de software en sí mismo, con product management, UX research, y quality gates. Algunas consecuencias concretas de esto:
- Existe un documento interno de 20 páginas que todo nuevo endpoint debe seguir.
- La calidad de la documentación afecta las evaluaciones de desempeño de los ingenieros.
- Hacen UX Research con desarrolladores para identificar fricción en la integración.
- Todo cambio a la API pasa por un API Review Board cross-funcional.
- Practican friction logging: el equipo intenta usar su propio producto y documenta cada punto de fricción.
Implicación para auditar tus APIs: La pregunta de fondo no es “¿funciona esta API?” sino “¿qué tan fácil es para un desarrollador nuevo entender, integrar y debuggear esta API?“
2. Los API Design Documents internos
Stripe tiene una cultura fuerte de escritura. No es raro que circulen documentos de diseño de 20 páginas antes de aprobar cambios a la API. Estos documentos no son specs técnicas secas — son propuestas argumentadas.
Estructura típica de un API Design Document de Stripe
# [Nombre del Feature / Endpoint]
## Contexto y motivación
- ¿Por qué se necesita este cambio?
- ¿Qué problema del desarrollador resuelve?
- Links a tickets de soporte / feedback recurrente
## Requisitos
- Qué DEBE hacer (funcional)
- Qué NO DEBE hacer (restricciones)
- Consideraciones de backward compatibility
## Opciones consideradas
### Opción A: [nombre]
- Descripción
- Ventajas
- Desventajas
- Por qué se descartó
### Opción B: [nombre]
- ...
## Decisión propuesta
- Opción elegida con justificación
## Diseño de la API (la parte técnica)
- Request shape (verbo HTTP, path, headers, body params)
- Response shape (campos, tipos, ejemplo JSON)
- Error cases y sus códigos
- Impacto en SDKs
- Impacto en documentación
## Preguntas abiertas
- Decisiones pendientes con owners asignados
## Plan de lanzamiento
- Fases, rollout, deprecations si aplica
Por qué funciona este formato
El punto crítico está en la sección “Opciones consideradas”: documentar explícitamente qué alternativas se evaluaron y por qué se descartaron. Esto preserva el razonamiento que de otro modo se perdería, y acelera futuras revisiones cuando alguien pregunta “¿por qué lo hicieron así?“.
3. Patrón: IDs prefijados y únicos
El problema con UUIDs puros
550e8400-e29b-41d4-a716-446655440000 ← ¿Qué tipo de recurso es esto?
No hay forma de saberlo sin contexto adicional. En logs, en errores, en queries — siempre necesitas metadata extra.
La solución de Stripe
ch_3MqZlPLkdIwHu7ix0slN3S9y # Charge
cus_NffrFeUfNV2Hib # Customer
pi_3MtwBwLkdIwHu7ix28aiHDKq # PaymentIntent
sub_1MowQVLkdIwHu7ixeRlqHVzs # Subscription
inv_1MowQVLkdIwHu7ixeRlqHVzs # Invoice
re_3MqZlPLkdIwHu7ix0slN3S9y # Refund
Estructura: {prefix}_{random_base62_string}
¿Cómo se generan y se garantiza unicidad?
Stripe usa Base62 (caracteres [a-zA-Z0-9]) para la parte aleatoria. No usan UUIDs v4 directamente — usan una combinación de:
- Timestamp embebido en los primeros bytes (similar a ULIDs o UUIDs v7).
- Componente aleatorio para evitar colisiones dentro del mismo millisegundo.
- El resultado se codifica en Base62 para hacerlo más corto y legible.
Esto les da dos propiedades simultáneamente:
- Unicidad global por el componente aleatorio.
- Ordenación aproximada por tiempo porque el timestamp está al inicio.
ch_3MqZl...
↑↑↑↑↑
Estos caracteres iniciales codifican el timestamp
(más recientes = lexicográficamente mayor)
¿Cómo funciona esto en la base de datos?
-- Tabla de charges con ID prefijado
CREATE TABLE charges (
id VARCHAR(64) PRIMARY KEY, -- ej: "ch_3MqZlPLkdIwHu7ix0..."
customer_id VARCHAR(64) NOT NULL, -- ej: "cus_NffrFeUfNV2Hib"
amount INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
...
);
-- Índice en created_at para cursor pagination eficiente
CREATE INDEX idx_charges_customer_created
ON charges(customer_id, created_at DESC, id DESC);
Ventajas para la DB:
- El ID es opaco para el cliente pero internamente Stripe puede inferir el tipo de objeto, la shard de la DB donde vive, y el timestamp — todo desde el ID.
- Al ser lexicográficamente ordenable por tiempo, un
ORDER BY id DESCes aproximadamente equivalente aORDER BY created_at DESCsin necesitar un índice separado para paginación simple.
Ventajas de seguridad
IDs secuenciales (user_1, user_2) exponen el volumen de negocio. Con IDs base62 prefijados:
- No hay forma de adivinar cuántos customers tiene Stripe.
- No hay enumeración por fuerza bruta (el espacio de IDs válidos es enorme).
- El prefijo no ayuda a adivinar el ID completo.
Cómo adoptarlo en tus APIs
Para tener IDs con timestamp embebido (ordenables cronológicamente) necesitas ULID o UUID v7 — no nanoid, que es puramente aleatorio y no tiene timestamp.
// ❌ nanoid: aleatorio puro, no ordenable por tiempo
import { nanoid } from 'nanoid'
nanoid(24) // "V1StGXR8_Z5jdHi6B-myT3kQ" ← sin timestamp, sin orden
// ✅ ULID: timestamp (48 bits) + aleatorio (80 bits), lexicográficamente ordenable
import { ulid } from 'ulid'
ulid() // "01ARZ3NDEKTSV4RRFFQ69G5FAV"
// ^^^^^^^^^^ ^^^^^^^^^^^^^^^^
// timestamp aleatorio
// ✅ UUID v7: mismo concepto, en formato UUID estándar
import { uuidv7 } from 'uuidv7'
uuidv7() // "018f6f3a-b7c4-7000-8000-3f4a2b1c0d5e"
Con ULID, ORDER BY id DESC es equivalente a ORDER BY created_at DESC porque el timestamp está embebido al inicio del string. Eso es lo que permite a Stripe usar el ID directamente como cursor de paginación.
// Generador de IDs prefijados con timestamp embebido
import { ulid } from 'ulid'
function generateId(prefix: string): string {
return `${prefix}_${ulid()}`
}
const findingId = generateId('finding') // "finding_01ARZ3NDEKTSV4RRFFQ69G5FAV"
// ↑ timestamp al inicio
4. Patrón: Polymorphic Lookups
¿Qué son?
Un polymorphic lookup es cuando un endpoint puede recibir un ID de diferentes tipos de recursos y la API puede inferir de qué tipo se trata — sin que el cliente tenga que especificarlo explícitamente.
Ejemplo concreto: En algunas partes de la API de Stripe, puedes hacer:
GET /v1/events?related_object=ch_3MqZlPLkdIwHu7ix0slN3S9y
Stripe sabe que ch_ es un Charge y busca eventos relacionados a ese charge específico — sin necesidad de un parámetro ?type=charge.
¿Cómo lo implementan?
// Registro de prefijos → tipos de recursos
const PREFIX_MAP: Record<string, string> = {
'ch': 'charge',
'cus': 'customer',
'pi': 'payment_intent',
'sub': 'subscription',
're': 'refund',
}
function resolveObjectType(id: string): string | null {
const prefix = id.split('_')[0]
return PREFIX_MAP[prefix] ?? null
}
// Uso en un endpoint polimórfico
function lookupObject(id: string) {
const type = resolveObjectType(id)
if (!type) throw new Error(`Unknown object type for ID: ${id}`)
switch (type) {
case 'charge': return ChargeService.findById(id)
case 'customer': return CustomerService.findById(id)
// ...
}
}
Cuándo usarlo
Es útil para endpoints de “búsqueda general” o feeds de eventos donde el mismo endpoint puede estar relacionado a múltiples tipos de recursos. No es para usar en todos lados — solo donde realmente simplifique la experiencia del cliente.
5. Patrón: Cursor-Based Pagination
El problema del offset pagination
-- Offset: tiene que contar y descartar N filas
SELECT * FROM findings
ORDER BY created_at DESC
LIMIT 20 OFFSET 10000; -- ← La DB lee 10,020 filas para devolverte 20
Además, si alguien inserta un registro nuevo mientras paginas, la página 2 puede devolver un duplicado o saltarse un elemento.
La solución de Stripe: Cursor como ID del último elemento
Request
# Primera página (sin cursor)
GET /v1/charges?limit=10
# Siguiente página (usando el ID del último elemento recibido)
GET /v1/charges?limit=10&starting_after=ch_3MqZlPLkdIwHu7ix0slN3S9y
# Página anterior
GET /v1/charges?limit=10&ending_before=ch_3MqZlPLkdIwHu7ix0slN3S9y
Response
{
"object": "list",
"url": "/v1/charges",
"has_more": true,
"data": [
{
"id": "ch_3MqZlPLkdIwHu7ix0slN3S9y",
"object": "charge",
"amount": 2000,
"created": 1677123456
},
{
"id": "ch_2AaBbCcDdEeFfGg",
"object": "charge",
"amount": 5000,
"created": 1677123400
}
]
}
Nota: Stripe usa el ID del último objeto directamente como cursor. No hay un campo next_cursor separado — el cliente toma data[data.length - 1].id y lo pasa como starting_after. Esto funciona porque los IDs tienen el timestamp embebido (via ULID) y son ordenables por tiempo. ORDER BY id DESC ≈ ORDER BY created_at DESC.
¿Qué regresa Stripe — hay next_cursor, total o página actual?
No. La respuesta es minimalista a propósito:
{
"object": "list",
"url": "/v1/charges",
"has_more": true,
"data": [ ...20 objetos... ]
}
El cliente deriva todo lo demás:
const response = await stripe.charges.list({ limit: 20 })
if (response.has_more) {
const lastId = response.data[response.data.length - 1].id
const nextPage = await stripe.charges.list({
limit: 20,
starting_after: lastId, // ← el ID es el cursor
})
}
// No existe: total de registros, página actual, ni número de páginas totales
total_count está disponible solo con expand[]=total_count, y tiene un cap de 10,000. Es caro de computar con COUNT(*) en tablas grandes, por eso no va en el response por default.
Para búsquedas con filtros: ¿GET con query params o POST?
Stripe usa GET con query params para la gran mayoría de listados y búsquedas:
# Listado filtrado por cliente
GET /v1/charges?customer=cus_NffrFeUfNV2Hib&limit=10&starting_after=ch_xxx
# Búsqueda full-text (endpoint especial /search)
GET /v1/charges/search?query=amount%3A2000&limit=10&page=eyJsaW1pdCI6M...
Para el endpoint /search, Stripe usa un page token (base64) en lugar del ID directo, porque los resultados de búsqueda pueden ordenarse por relevancia y no por tiempo.
Regla general: Filtros simples → GET con query params. Búsqueda compleja con múltiples criterios → considera POST con body JSON para evitar URLs kilométricas y para poder enviar estructuras anidadas.
Implementación en la base de datos (Keyset Pagination)
El cursor-based pagination a nivel de DB se implementa con keyset pagination usando una cláusula WHERE en lugar de OFFSET:
-- Primera página
SELECT * FROM findings
WHERE team_id = 'team_abc'
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Página 2 (cursor = último elemento de la página 1)
-- El cursor encapsula (created_at, id) del último elemento
SELECT * FROM findings
WHERE team_id = 'team_abc'
AND (created_at, id) < ('2024-03-15T10:00:00Z', 'finding_xyz789')
ORDER BY created_at DESC, id DESC
LIMIT 20;
Índice requerido:
-- El índice compuesto DEBE coincidir con el ORDER BY
CREATE INDEX idx_findings_cursor
ON findings(team_id, created_at DESC, id DESC);
¿Por qué el compuesto (created_at, id)? Cuando dos registros tienen exactamente el mismo created_at, necesitas un desempate determinístico. El id es único, así que garantiza que la paginación nunca se repita ni salte registros.
¿Qué significa (created_at, id) < (valor1, valor2)?
Es row value comparison, SQL estándar soportado por PostgreSQL, MySQL 8+ y SQLite (D1). Funciona como comparación lexicográfica de tuplas:
(a, b) < (x, y)
-- es equivalente a:
-- a < x OR (a = x AND b < y)
Aplicado al ejemplo concreto:
(created_at, id) < ('2024-03-15T10:00:00Z', 'finding_xyz789')
-- Significa: registros donde created_at es anterior al cursor,
-- O donde created_at es igual pero id es menor (desempate)
Es syntactic sugar que evita escribir el OR explícito. En D1 funciona correctamente.
¿Qué pasa si el usuario ordena por otro campo?
Si el sorting es dinámico (por severity, score, title, etc.), el ID solo ya no es suficiente como cursor. El cursor necesita encodar los valores de todos los campos de ordenamiento:
// Cursor para ordenamiento por severity ASC + created_at DESC + id DESC
function encodeCursor(finding: Finding): string {
const payload = {
severity: finding.severity,
created_at: finding.created_at,
id: finding.id,
}
return Buffer.from(JSON.stringify(payload)).toString('base64url')
}
Y la query cambia — ya no puedes usar row-value comparison simple porque las direcciones de orden son mixtas (ASC/DESC), tienes que expandir el OR manualmente:
-- Página 2: severity ASC, created_at DESC, id DESC
SELECT * FROM findings
WHERE team_id = 'team_abc'
AND (
severity > 'high'
OR (severity = 'high' AND created_at < '2024-03-15T10:00:00Z')
OR (severity = 'high' AND created_at = '2024-03-15T10:00:00Z' AND id < 'finding_xyz789')
)
ORDER BY severity ASC, created_at DESC, id DESC
LIMIT 20;
Por eso Stripe lo simplifica: al usar IDs con timestamp embebido (ULID), ORDER BY id DESC siempre equivale a ORDER BY created_at DESC, y el cursor puede ser solo el ID. Si tu plataforma necesita sortings dinámicos, tendrás que implementar cursors opacos que encoden los campos de ordenamiento activos.
Encapsular el cursor de forma opaca
Es buena práctica que el cliente no sepa qué hay adentro del cursor:
// Servidor: genera cursor opaco
function encodeCursor(createdAt: Date, id: string): string {
const payload = JSON.stringify({ created_at: createdAt.toISOString(), id })
return Buffer.from(payload).toString('base64url')
}
// Servidor: decodifica cursor
function decodeCursor(cursor: string): { created_at: string; id: string } {
return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
}
Esto permite cambiar la implementación interna sin romper a los clientes.
Datos privados en cursors
Dado que el cursor puede contener valores de columnas, nunca embeds datos sensibles (como montos, emails, etc.). Embeds solo los campos necesarios para la posición en el índice (timestamp + id).
6. Patrón: Response Object consistente
El envelope estándar de Stripe
Cada objeto de Stripe tiene estos campos garantizados:
{
"id": "ch_3MqZlPLkdIwHu7ix0slN3S9y",
"object": "charge",
"created": 1677123456,
"livemode": false,
"metadata": {},
"amount": 2000,
"currency": "usd",
"status": "succeeded"
}
¿Para qué sirve cada campo?
| Campo | Propósito |
|---|---|
id | Identificador único prefijado. Siempre presente, nunca nulo. |
object | El tipo del recurso como string. Hace la respuesta auto-documentada. Si recibes un JSON sin saber qué endpoint lo generó, puedes saber qué es. |
created | Unix timestamp (segundos desde epoch). Consistente en todos los recursos. |
livemode | true = producción real, false = test mode. Previene confusión entre entornos. |
metadata | Key-value libre. Espacio de escape para datos del cliente sin modificar el schema. |
¿Cuándo anidar objetos vs IDs?
Default: Devuelves solo el ID de los objetos relacionados.
{
"id": "ch_3MqZlP...",
"object": "charge",
"customer": "cus_NffrFe...",
"payment_intent": "pi_3MtwBw...",
"amount": 2000
}
Con expand: El cliente pide explícitamente el objeto embebido.
GET /v1/charges/ch_3MqZlP?expand[]=customer&expand[]=payment_intent
{
"id": "ch_3MqZlP...",
"object": "charge",
"customer": {
"id": "cus_NffrFe...",
"object": "customer",
"email": "user@example.com"
},
"payment_intent": {
"id": "pi_3MtwBw...",
"object": "payment_intent"
},
"amount": 2000
}
Criterio de diseño: ¿Con qué frecuencia el cliente va a necesitar este sub-objeto?
- “Casi siempre” → considera embebelo por default.
- “A veces” → usa expand.
- “Rara vez” → solo el ID.
Respuesta de lista con paginación
Las listas siempre siguen este envelope:
{
"object": "list",
"url": "/v1/charges",
"has_more": true,
"data": [
{ "id": "ch_001", "object": "charge", "amount": 2000 },
{ "id": "ch_002", "object": "charge", "amount": 5000 }
]
}
Nota sobre total_count: Stripe no incluye el total por default porque es caro de computar. Se puede pedir con expand[]=total_count, y aun así está limitado a 10,000 como máximo. Es una decisión de performance deliberada.
Respuesta de error
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Tu tarjeta no tiene fondos suficientes.",
"param": "source",
"doc_url": "https://stripe.com/docs/error-codes/card-declined",
"request_log_url": "https://dashboard.stripe.com/logs/req_abc123"
}
}
Niveles de error en Stripe:
| Campo | Uso |
|---|---|
type | Categoría grande: card_error, validation_error, api_error, authentication_error |
code | Error específico dentro del tipo: card_declined, expired_card, incorrect_cvc |
decline_code | Solo para card_error: el código del banco emisor |
message | Texto legible — para card_error es seguro mostrarlo al usuario final |
param | El campo del request que causó el problema |
doc_url | Link directo a la documentación del error |
request_log_url | Link directo al log de esa request en el dashboard |
7. Patrón: Expandable Objects
Reglas de profundidad para expansión:
# Expansión simple
GET /v1/charges/ch_123?expand[]=customer
# Expansión profunda (hasta 4 niveles)
GET /v1/payment_intents/pi_123?expand[]=payment_method.billing_details
# Múltiples expansiones
GET /v1/charges/ch_123?expand[]=customer&expand[]=payment_intent
# Expansión en listas
GET /v1/charges?expand[]=data.customer
Regla de diseño: Documenta qué campos son expandibles. Stripe los marca con una etiqueta “expandable” en su API reference.
8. Patrón: Idempotency Keys
El problema
En sistemas distribuidos, un cliente hace una request, no recibe respuesta (timeout de red), y no sabe si la operación se ejecutó o no. Si reintenta, puede crear el recurso dos veces.
La solución
POST /v1/charges
Idempotency-Key: order_123_charge_attempt_1
Content-Type: application/json
{
"amount": 2000,
"currency": "usd",
"customer": "cus_NffrFe"
}
Garantía: Si envías el mismo Idempotency-Key dos veces, Stripe devuelve el resultado del primer request — sin ejecutar la operación de nuevo.
Implementación interna
-- Tabla de idempotency keys
CREATE TABLE idempotency_keys (
key VARCHAR(255) PRIMARY KEY,
account_id VARCHAR(64) NOT NULL,
response JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '24 hours'
);
-- Al recibir un request con Idempotency-Key:
-- 1. Buscar en esta tabla
-- 2. Si existe y no ha expirado → devolver response cacheada
-- 3. Si no existe → ejecutar operación, guardar resultado, devolver
Scope: La key es única por cuenta + endpoint. La misma key enviada a dos endpoints diferentes no colisiona.
Mejores prácticas para el cliente
// Bueno: key significativa y reproducible para el mismo intento
const idempotencyKey = `order_${orderId}_charge_${attemptNumber}`
// Bueno: UUID v4 cuando no hay un identificador natural
import { v4 as uuidv4 } from 'uuid'
const idempotencyKey = uuidv4() // Guardar en DB antes de hacer el request
9. Patrón: Date-Based Versioning
En lugar de /v1, /v2 que rompen a todos los clientes al mismo tiempo:
Stripe-Version: 2024-10-28
Cómo funciona:
- La primera vez que haces un request, tu cuenta queda pinned a la versión del API de ese día.
- Stripe puede hacer cambios breaking — pero solo afectan a cuentas nuevas.
- Tú eliges cuándo migrar, testeando con
Stripe-Versionen el header. - Internamente, Stripe tiene capas de transformación que convierten entre versiones.
Trade-off: Requiere mantener código de transformación para cada versión vieja activa. Es costoso de implementar, pero genera confianza masiva en los integradores.
10. Patrón: Error Responses accionables
El principio: un error debe decirle al desarrollador exactamente qué hacer a continuación.
// ❌ Error genérico (no accionable)
{ "error": "Bad request" }
// ✅ Error accionable de Stripe
{
"error": {
"type": "invalid_request_error",
"code": "parameter_missing",
"message": "Missing required param: amount.",
"param": "amount",
"doc_url": "https://stripe.com/docs/api/charges/create#create_charge-amount"
}
}
Spell-checking en parámetros
Si envías emaill en lugar de email:
{
"error": {
"type": "invalid_request_error",
"code": "parameter_unknown",
"message": "Received unknown parameter: emaill. Did you mean email?",
"param": "emaill"
}
}
11. Patrón: Metadata para extensibilidad
{
"id": "cus_NffrFe",
"object": "customer",
"metadata": {
"internal_user_id": "usr_abc123",
"plan_tier": "enterprise",
"sales_rep_email": "jane@company.com",
"team_id": "team_xyz"
}
}
Límites de Stripe: 50 keys máximo, keys de máximo 40 chars, valores de máximo 500 chars.
Cuándo usarlo: Para datos que son relevantes para tu negocio pero que Stripe no necesita saber. Permite extender el modelo sin cambiar el schema.
12. Consistencia entre capas: REST → SDK → React SDK
La consistencia no es sobre usar el mismo verbo HTTP en todas partes, sino sobre mantener nombres y contratos coherentes a través de todas las capas de abstracción.
Ejemplo concreto con Customers
Capa 1: REST API
GET /v1/customers/cus_NffrFe
Authorization: Bearer sk_test_...
# Response:
{
"id": "cus_NffrFe",
"object": "customer",
"email": "user@example.com",
"name": "Jane Doe",
"created": 1677123456
}
Capa 2: Backend SDK (Node.js)
// El método se llama "retrieve" — no "get", no "find", no "fetch"
const customer = await stripe.customers.retrieve('cus_NffrFe')
// Mismos nombres de campos que la API REST
console.log(customer.id) // "cus_NffrFe"
console.log(customer.email) // "user@example.com"
console.log(customer.object) // "customer"
Capa 3: React SDK / Stripe.js
import { useStripe } from '@stripe/react-stripe-js'
function CheckoutComponent() {
const stripe = useStripe()
async function handleSubmit() {
// El método también se llama "retrieve" — consistencia total
const { paymentIntent } = await stripe.retrievePaymentIntent(clientSecret)
// El objeto retornado tiene la misma forma que en REST y Backend SDK
console.log(paymentIntent.id) // "pi_3MtwBw..."
console.log(paymentIntent.status) // "requires_confirmation"
console.log(paymentIntent.object) // "payment_intent"
}
}
¿Dónde falla la consistencia? (Anti-patrón)
// REST API usa: GET /findings/:id
// Backend SDK usa: findingService.getFinding(id) ← "get" no "retrieve"
// React hook usa: useFinding(id) ← ok, es un hook
// Pero el response: { findingId: "..." } ← "findingId" no "id"
// { createdAt: "..." } ← camelCase, no snake_case
// { type: "vuln" } ← no hay campo "object"
La regla de Stripe
No importa si usas retrieve o get — lo que importa es que sea lo mismo en todos lados. Decide una convención y la enforces en REST, SDKs, y en los tipos de respuesta.
// Define una interfaz base que todas las capas respetan
interface BaseResource {
id: string // Siempre "id", nunca "findingId", "assetId"
object: string // Tipo del recurso
created_at: string // ISO 8601 o Unix timestamp — elige uno y sé consistente
metadata: Record<string, string>
}
interface Finding extends BaseResource {
object: 'finding' // Valor literal
severity: 'critical' | 'high' | 'medium' | 'low'
title: string
}
13. Request Logs e Inspectabilidad
¿Qué se loggea?
Stripe captura por cada request:
| Campo | Ejemplo |
|---|---|
request_id | req_abc123xyz — presente en el header Request-Id de la response |
| Timestamp | Con timezone |
| Método HTTP | POST, GET, etc. |
| Path | /v1/charges |
| Response status | 200, 400, 500 |
| Latencia | En milisegundos |
| API Version | 2024-10-28 |
| IP del cliente | Para auditoría |
| User Agent | SDK + versión, o el cliente HTTP del developer |
| Request body | Parcialmente — con redacción de datos sensibles |
| Response body | Parcialmente — con redacción de datos sensibles |
¿Cómo manejan datos privados?
Redacción (masking) automática antes de persistir el log:
Datos que se redactan SIEMPRE:
- Números de tarjeta → "****1234"
- CVV → "[redacted]"
- Passwords
- API keys en el body
- SSN, IBAN
Datos que se guardan normalmente:
- IDs de recursos (no son secretos)
- Amounts, currencies
- Status codes
- Error messages (que no contengan datos sensibles)
En la práctica, Stripe define una lista blanca de campos seguros para loggear, en lugar de una lista negra de campos a redactar. Todo lo que no está en la lista blanca se redacta por default.
¿Cómo confirman lo que recibió la plataforma?
-
Request ID: Cada response incluye un header
Request-Id: req_abc123xyz. Si hay un problema, el developer puede dar ese ID a soporte y Stripe puede buscar el log exacto. -
Dashboard de logs: Muestra exactamente qué recibió el servidor — incluyendo headers, body parseado, y la response.
-
El Dashboard mismo es observable: Cuando haces una acción en el Dashboard (crear un producto, por ejemplo), la request que genera el Dashboard aparece en los logs. Esto permite a developers aprender la API viendo qué hace el Dashboard.
[Log en el Dashboard]
2024-03-15 10:23:45 UTC
POST /v1/charges 200 OK 45ms
Request ID: req_abc123xyz
API Version: 2024-10-28
Request body:
{
"amount": 2000,
"currency": "usd",
"customer": "cus_NffrFe"
}
Response body:
{
"id": "ch_3MqZlP...",
"object": "charge",
"status": "succeeded"
}
Para tus APIs: cómo implementarlo
// Middleware de logging en Cloudflare Workers / Hono
app.use('*', async (c, next) => {
const requestId = crypto.randomUUID()
c.header('Request-Id', requestId)
const startTime = Date.now()
const requestLog = {
request_id: requestId,
method: c.req.method,
path: c.req.path,
timestamp: new Date().toISOString(),
body: maskSensitiveFields(await c.req.json().catch(() => null)),
}
await next()
await persistRequestLog({
...requestLog,
status: c.res.status,
latency_ms: Date.now() - startTime,
})
})
function maskSensitiveFields(body: unknown): unknown {
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'api_key'])
// Recorre el objeto y redacta los campos sensibles
// Whitelist approach: solo loggear campos conocidos como seguros
}
14. Test Mode como ciudadano de primera clase
sk_test_4eC39HqLyjWDarjtT1zdp7dc # Test mode
sk_live_4eC39HqLyjWDarjtT1zdp7dc # Live mode
Stripe tiene una instancia paralela del sistema para test mode:
- Las requests en test mode nunca mueven dinero real.
- Tienes tarjetas de prueba con comportamientos predefinidos (
4242 4242 4242 4242siempre aprueba). - Los webhooks en test mode son eventos reales generados por tu integración de prueba.
- Los logs de test mode están separados de los de producción.
Para tus APIs: El equivalente es tener entornos bien separados con keys distintas, y hacer que el comportamiento sea idéntico al de producción (mismo schema, mismas validaciones, mismo formato de respuesta).
15. Proceso organizacional: API Review y Abstraction Ladders
API Review
Stripe creó un API Review Board: un grupo cross-funcional que debe aprobar cualquier cambio a la API pública. El proceso fuerza a los equipos a:
- Documentar su propuesta (el design doc).
- Defender las decisiones.
- Considerar consistencia con el resto de la API.
- Pensar en backward compatibility.
Lección aprendida: En retrospectiva, debería ser más un “servicio de educación y consultoría” que un “gate de aprobación”. Los ingenieros no son siempre buenos diseñadores de abstracciones, pero esa habilidad se puede enseñar.
Abstraction Ladders
Nivel 1: REST API → Control total, máxima flexibilidad
Nivel 2: Backend SDK → Elimina boilerplate de HTTP, tipos fuertes
Nivel 3: React SDK → Componentes pre-construidos (CardElement, etc.)
Nivel 4: Stripe Checkout → Sin código de tu parte, máxima velocidad
Cada nivel oculta complejidad del anterior. El principio es que puedes subir y bajar la escalera según cuánto control necesitas. Una API bien diseñada tiene estas capas y la progresión es natural.
16. Checklist de auditoría para tus APIs
IDs y Recursos
- ¿Los IDs tienen un prefijo que indica el tipo de recurso?
- ¿Los IDs son seguros (no exponen volumen de negocio)?
- ¿Los IDs son ordenables por tiempo de creación?
Response Shape
- ¿Todos los recursos tienen
id,object,created_at? - ¿El campo
objectes un string literal que describe el tipo? - ¿Los nombres de campos son consistentes (
snake_caseocamelCase— elige uno)? - ¿Las listas siguen un envelope estándar con
data,has_more,url? - ¿Los objetos anidados pueden ser expandidos on-demand en lugar de siempre embebidos?
Paginación
- ¿Usas cursor-based pagination en lugar de offset?
- ¿El cursor es opaco para el cliente?
- ¿El índice de la DB soporta keyset pagination eficientemente?
- ¿Los SDKs ofrecen auto-pagination?
Errores
- ¿Los errores tienen
type,code, ymessageseparados? - ¿El
messagees accionable (le dice al dev qué hacer)? - ¿Se indica el
paramque causó el error en validaciones? - ¿Hay un link a documentación relevante en el error?
- ¿Se detectan y sugieren correcciones para typos en parámetros?
Idempotencia
- ¿Los endpoints POST que crean recursos soportan
Idempotency-Key? - ¿La respuesta es idéntica si se envía la misma key dos veces?
Versioning
- ¿Hay una estrategia de versioning que no rompa a clientes existentes?
- ¿Los clientes pueden testear nuevas versiones sin migrar completamente?
Observabilidad
- ¿Cada response incluye un
Request-Idheader? - ¿Se loggean requests con ese ID para poder buscarlos después?
- ¿Los datos sensibles están redactados en los logs?
- ¿Los developers pueden ver exactamente qué recibió el servidor?
Consistencia de capas
- ¿Los nombres de métodos del SDK coinciden con la semántica de la API REST?
- ¿Los tipos retornados por el SDK tienen la misma forma que la respuesta JSON?
- ¿Un developer que conoce la API REST puede usar el SDK sin sorpresas?
Test Mode
- ¿Existe un entorno de pruebas equivalente a producción?
- ¿Las keys de test y producción son distinguibles?
- ¿El comportamiento del test mode es idéntico al de producción?
17. Referencias para profundizar
Fuentes primarias de Stripe
| Recurso | URL |
|---|---|
| Stripe API Reference | https://docs.stripe.com/api |
| Stripe API Tour | https://docs.stripe.com/payments-api/tour |
| Stripe Pagination Docs | https://docs.stripe.com/api/pagination |
| Stripe API v2 Overview | https://docs.stripe.com/api-v2-overview |
Artículos de ex-empleados de Stripe
| Recurso | URL |
|---|---|
| Kenneth Auchenberg - API DX Insights | https://kenneth.io/post/insights-from-building-stripes-developer-platform-and-api-developer-experience-part-1 |
| How Stripe Builds APIs (Postman Blog) | https://blog.postman.com/how-stripe-builds-apis/ |
| Sebas Bensu - APIs as Ladders | https://blog.sbensu.com/posts/apis-as-ladders/ |
Artículos de análisis
| Recurso | URL |
|---|---|
| Why Stripe’s API is the Gold Standard | https://apidog.com/blog/why-stripes-api-is-the-gold-standard-design-patterns-that-every-api-builder-should-steal/ |
Recursos de API Design general
| Recurso | URL |
|---|---|
| API Pagination Patterns | https://codelit.io/blog/api-pagination-patterns |
| Use The Index, Luke — No Offset | https://use-the-index-luke.com/no-offset |
| Paginating Requests in APIs | https://ignaciochiazzo.medium.com/paginating-requests-in-apis-d4883d4c1c4c |
| Google API Design Guide | https://google.aip.dev/general |
| Microsoft REST API Guidelines | https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md |
Libros relevantes
| Libro | Por qué leerlo |
|---|---|
| Designing Web APIs — Brenda Jin et al. | Cubre patrones de diseño, versioning, y SDK design |
| The Design of Web APIs — Arnaud Lauret | Más centrado en UX del API y el proceso de diseño |
| API Design Patterns — JJ Geewax (Google) | Patrones concretos con implementación, muy técnico |
Documento generado en base a investigación de mayo 2026.