# Paginación, idempotencia y límites

Recorre listados por página o por cursor, reintenta sin duplicar y maneja el 429.

Guía REST

## Recorre listados por página o por cursor

Todos los listados aceptan page (desde 1) y pageSize, y traen meta.total y meta.totalPages. Contactos, negocios y tareas aceptan además cursor: pasa el meta.nextCursor de la respuesta anterior hasta que meta.hasMore sea false. Con includeTotal=false no se cuenta el total (llega en null) y va más rápido. El pageSize máximo es 500 en contactos, negocios, tareas y citas, y 48 en propiedades.

Cursor:

```
// Contactos, negocios y tareas: por cursor, sin contar el total (lo más rápido).
async function* all(path, params = {}) {
  let cursor;
  do {
    const qs = new URLSearchParams({ ...params, pageSize: "500", includeTotal: "false",
                                     ...(cursor ? { cursor } : {}) });
    const res = await api(`${path}?${qs}`);
    const { data, meta } = await res.json();
    yield* data;
    cursor = meta.hasMore ? meta.nextCursor : undefined;
  } while (cursor);
}

for await (const contact of all("/contacts", { status: "NEW" })) { /* … */ }
```

Propiedades:

```
// Propiedades: cursor = id de la última recibida; pageSize máximo 48.
let cursor;
while (true) {
  const qs = new URLSearchParams({ pageSize: "48", ...(cursor ? { cursor } : {}) });
  const { data } = await (await api(`/properties?${qs}`)).json();
  if (data.length === 0) break;
  save(data);
  cursor = data[data.length - 1].id;   // meta.total y meta.totalPages llegan en null
  if (data.length < 48) break;
}
```

Páginas:

```
curl -G https://api.bydomu.com/v1/appointments -H "X-API-Key: $BYDOMU_API_KEY" -d page=2 -d pageSize=100
# "meta": { "total": 342, "page": 2, "pageSize": 100, "totalPages": 4 }
```

En propiedades el cursor es el id de la última recibida (no hay nextCursor) y no mandes includeTotal=false: ahí «false» cuenta como verdadero.

## Reintenta sin duplicar

Manda Idempotency-Key en POST, PATCH, PUT y DELETE: de 8 a 128 caracteres (letras, números y . _ : -), única por operación dentro de tu inmobiliaria. Durante 24 horas, la misma clave con el mismo método, ruta y cuerpo devuelve la respuesta guardada sin repetir el cambio. Las respuestas 5xx no se guardan: puedes reintentar con la misma clave.

```bash
curl -X POST https://api.bydomu.com/v1/contacts \
  -H "X-API-Key: $BYDOMU_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: crm-sync:contact:u_123" \
  -d '{"firstName":"Laura","phone":"+573000000001"}'

# 1.ª vez   → 201 · Idempotency-Status: created
# repetida  → 201 · Idempotency-Status: replayed (la misma respuesta, sin crear otro)
# otro body → 409 IDEMPOTENCY_CONFLICT
# en curso  → 409 IDEMPOTENCY_IN_PROGRESS
```

Deriva la clave de tu propio id (crm-sync:contact:u_123) y no de la hora: así un reintento, aunque sea desde otro proceso, cae sobre la misma operación.

## Límites por minuto y el 429

Cada clave tiene dos baldes por minuto: todas las peticiones y, aparte, las escrituras. Soporte puede aprobar más. Además hay un tope de escrituras por organización (TENANT_RATE_LIMITED).

Clave Límite

Clave publicable `300/min · 60 escrituras`

Clave secreta `60/min · 30 escrituras`

Clave de antes de los niveles `1000/min`

Qué dice Encabezado

Tope por minuto de la clave `X-RateLimit-Limit`

Lo que queda en este minuto `X-RateLimit-Remaining`

Segundos para que se reinicie `X-RateLimit-Reset`

Tope de escrituras (en un 429 de escritura) `X-RateLimit-Write-Limit`

Escrituras de toda la organización `X-Tenant-RateLimit-Remaining`

Segundos a esperar (en el 429) `Retry-After`

## Un cliente que reintenta bien

Ante 429 espera los segundos de Retry-After; ante 5xx, espera creciente con algo de azar. Nunca reintentes un 4xx distinto de 429: no va a cambiar.

Node.js:

```js
import { randomUUID } from "node:crypto";

// Reintenta 429 y 5xx con la MISMA Idempotency-Key: nunca duplica.
export async function api(path, { method = "GET", body, idempotencyKey } = {}) {
  const key = idempotencyKey ?? (method === "GET" ? undefined : randomUUID());
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`https://api.bydomu.com/v1${path}`, {
      method,
      headers: {
        "X-API-Key": process.env.BYDOMU_API_KEY,
        ...(body ? { "Content-Type": "application/json" } : {}),
        ...(key ? { "Idempotency-Key": key } : {}),
      },
      ...(body ? { body: JSON.stringify(body) } : {}),
    });
    const retryable = res.status === 429 || res.status >= 500;
    if (!retryable || attempt === 5) return res;
    const retryAfter = Number(res.headers.get("Retry-After"));
    const wait = retryAfter > 0 ? retryAfter * 1000 : Math.min(30_000, 500 * 2 ** attempt);
    await new Promise((r) => setTimeout(r, wait + Math.random() * 250));
  }
}
```

Python:

```python
import os, random, time, uuid
import requests

API = "https://api.bydomu.com/v1"

def api(method, path, json=None, idempotency_key=None):
    key = idempotency_key or (None if method == "GET" else str(uuid.uuid4()))
    headers = {"X-API-Key": os.environ["BYDOMU_API_KEY"]}
    if key:
        headers["Idempotency-Key"] = key
    for attempt in range(1, 6):
        res = requests.request(method, API + path, json=json, headers=headers, timeout=30)
        if res.status_code != 429 and res.status_code < 500 or attempt == 5:
            return res
        retry_after = int(res.headers.get("Retry-After", "0") or 0)
        wait = retry_after if retry_after > 0 else min(30, 0.5 * 2 ** attempt)
        time.sleep(wait + random.random() / 4)
```
