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

  1. Filosofía central
  2. Los API Design Documents internos
  3. Patrón: IDs prefijados y únicos
  4. Patrón: Polymorphic Lookups
  5. Patrón: Cursor-Based Pagination
  6. Patrón: Response Object consistente
  7. Patrón: Expandable Objects
  8. Patrón: Idempotency Keys
  9. Patrón: Date-Based Versioning
  10. Patrón: Error Responses accionables
  11. Patrón: Metadata para extensibilidad
  12. Consistencia entre capas: REST → SDK → React SDK
  13. Request Logs e Inspectabilidad
  14. Test Mode como ciudadano de primera clase
  15. Proceso organizacional: API Review y Abstraction Ladders
  16. Checklist de auditoría para tus APIs
  17. 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:

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:

  1. Timestamp embebido en los primeros bytes (similar a ULIDs o UUIDs v7).
  2. Componente aleatorio para evitar colisiones dentro del mismo millisegundo.
  3. El resultado se codifica en Base62 para hacerlo más corto y legible.

Esto les da dos propiedades simultáneamente:

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:

Ventajas de seguridad

IDs secuenciales (user_1, user_2) exponen el volumen de negocio. Con IDs base62 prefijados:

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 DESCORDER 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?

CampoPropósito
idIdentificador único prefijado. Siempre presente, nunca nulo.
objectEl tipo del recurso como string. Hace la respuesta auto-documentada. Si recibes un JSON sin saber qué endpoint lo generó, puedes saber qué es.
createdUnix timestamp (segundos desde epoch). Consistente en todos los recursos.
livemodetrue = producción real, false = test mode. Previene confusión entre entornos.
metadataKey-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?

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:

CampoUso
typeCategoría grande: card_error, validation_error, api_error, authentication_error
codeError específico dentro del tipo: card_declined, expired_card, incorrect_cvc
decline_codeSolo para card_error: el código del banco emisor
messageTexto legible — para card_error es seguro mostrarlo al usuario final
paramEl campo del request que causó el problema
doc_urlLink directo a la documentación del error
request_log_urlLink 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:

  1. La primera vez que haces un request, tu cuenta queda pinned a la versión del API de ese día.
  2. Stripe puede hacer cambios breaking — pero solo afectan a cuentas nuevas.
  3. Tú eliges cuándo migrar, testeando con Stripe-Version en el header.
  4. 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:

CampoEjemplo
request_idreq_abc123xyz — presente en el header Request-Id de la response
TimestampCon timezone
Método HTTPPOST, GET, etc.
Path/v1/charges
Response status200, 400, 500
LatenciaEn milisegundos
API Version2024-10-28
IP del clientePara auditoría
User AgentSDK + versión, o el cliente HTTP del developer
Request bodyParcialmente — con redacción de datos sensibles
Response bodyParcialmente — 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?

  1. 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.

  2. Dashboard de logs: Muestra exactamente qué recibió el servidor — incluyendo headers, body parseado, y la response.

  3. 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:

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:

  1. Documentar su propuesta (el design doc).
  2. Defender las decisiones.
  3. Considerar consistencia con el resto de la API.
  4. 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

Response Shape

Paginación

Errores

Idempotencia

Versioning

Observabilidad

Consistencia de capas

Test Mode


17. Referencias para profundizar

Fuentes primarias de Stripe

RecursoURL
Stripe API Referencehttps://docs.stripe.com/api
Stripe API Tourhttps://docs.stripe.com/payments-api/tour
Stripe Pagination Docshttps://docs.stripe.com/api/pagination
Stripe API v2 Overviewhttps://docs.stripe.com/api-v2-overview

Artículos de ex-empleados de Stripe

RecursoURL
Kenneth Auchenberg - API DX Insightshttps://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 Laddershttps://blog.sbensu.com/posts/apis-as-ladders/

Artículos de análisis

RecursoURL
Why Stripe’s API is the Gold Standardhttps://apidog.com/blog/why-stripes-api-is-the-gold-standard-design-patterns-that-every-api-builder-should-steal/

Recursos de API Design general

RecursoURL
API Pagination Patternshttps://codelit.io/blog/api-pagination-patterns
Use The Index, Luke — No Offsethttps://use-the-index-luke.com/no-offset
Paginating Requests in APIshttps://ignaciochiazzo.medium.com/paginating-requests-in-apis-d4883d4c1c4c
Google API Design Guidehttps://google.aip.dev/general
Microsoft REST API Guidelineshttps://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md

Libros relevantes

LibroPor qué leerlo
Designing Web APIs — Brenda Jin et al.Cubre patrones de diseño, versioning, y SDK design
The Design of Web APIs — Arnaud LauretMá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.