ByDomuDevelopers

Pagination, idempotency and limits

Walk lists by page or cursor, retry without duplicating and handle 429.

GuideREST

Walk lists by page or cursor

Every list accepts page (from 1) and pageSize, and returns meta.total and meta.totalPages. Contacts, deals and tasks also accept cursor: pass the previous response’s meta.nextCursor until meta.hasMore is false. With includeTotal=false the total isn’t counted (it comes back null) and it’s faster. Max pageSize is 500 for contacts, deals, tasks and appointments, and 48 for properties.

// 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" })) { /* … */ }

For properties the cursor is the id of the last one received (there’s no nextCursor), and don’t send includeTotal=false: there “false” counts as true.

Retry without duplicates

Send Idempotency-Key on POST, PATCH, PUT and DELETE: 8 to 128 characters (letters, digits and . _ : -), unique per operation within your agency. For 24 hours, the same key with the same method, path and body returns the stored response without repeating the change. 5xx responses aren’t stored: you can retry with the same key.

cURL
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

Derive the key from your own id (crm-sync:contact:u_123), not from the time: that way a retry, even from another process, lands on the same operation.

Per-minute limits and 429

Each key has two buckets per minute: all requests and, separately, writes. Support can approve more. There’s also a per-organization write cap (TENANT_RATE_LIMITED).

KeyLimit
Publishable key300/min · 60 writes
Secret key60/min · 30 writes
Pre-levels key1000/min
What it saysHeader
Key’s per-minute capX-RateLimit-Limit
What’s left this minuteX-RateLimit-Remaining
Seconds until it resetsX-RateLimit-Reset
Write cap (on a write 429)X-RateLimit-Write-Limit
Whole-organization writesX-Tenant-RateLimit-Remaining
Seconds to wait (on 429)Retry-After

A client that retries well

On 429 wait the seconds in Retry-After; on 5xx, back off exponentially with some jitter. Never retry a 4xx other than 429: it won’t change.

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));
  }
}