Paginación, errores, idempotencia y versiones
Las reglas comunes a todos los endpoints: formato y entradas estrictas, listas con cursor, el error único, reintentos seguros, la vista previa y su confirmación, versiones y límites.
Formato
- JSON en snake_case en los dos sentidos (
pipeline_id,has_more). Las claves que son datos tuyos —los campos personalizados, los encabezados— se respetan tal cual. - Los parámetros de ruta van en la URL (
/crm/deals/{deal_id}). En unGET, el resto va en la query:limit=50,include_archived=true, listas comolabel_ids=a,bolabel_ids[]=a, objetos como JSON (filter={…}). EnPOST,PATCHyDELETE, en un cuerpo JSON de hasta 1 MB conContent-Type: application/json. - Cada respuesta trae
MB-Request-Id(req_…): inclúyelo si nos escribes por un problema. - En las respuestas, una operación nueva o un campo nuevo pueden aparecer sin aviso: ignora lo que no conozcas.
- En lo que mandas es al revés: un campo que la operación no tiene es 400
unknown_field, en vez de ignorarse (un filtro mal escrito nunca devuelve la lista sin filtrar).paramlo nombra y, si parece que va en otro nivel del cuerpo, el mensaje dice dónde. Los mapas de datos tuyos (campos personalizados, encabezados) aceptan cualquier clave. - Una operación masiva dice cuántos elementos acepta como máximo: «Masiva · hasta N» en la referencia,
x-mb-bulk.max_itemsen el contrato ybulk.max_itemsenmb_describe_operation(x-mb-bulk.maxItemssigue saliendo, obsoleto).
Paginación
Las listas devuelven { data, has_more, next_cursor }. Pide la página siguiente con cursor=<next_cursor> hasta que has_more sea false. En las listas del CRM, limit va de 1 a 200 (50 por defecto); la referencia dice el rango de cada lista. Un cursor que no se puede leer es 400 invalid_cursor: manda next_cursor tal como vino, a la misma lista.
limit —incluso ninguno— con has_more en true. Sigue el cursor; no cuentes resultados.let cursor;
do {
const url = new URL("https://api.mb-suite.com/v1/crm/deals");
url.searchParams.set("limit", "200");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.MB_API_KEY}` } });
const page = await res.json();
for (const deal of page.data) console.log(deal.id, deal.title);
// Una página puede traer menos de limit (incluso ninguno) con has_more en true: sigue el cursor.
cursor = page.has_more ? page.next_cursor : undefined;
} while (cursor);Errores
Todos los errores tienen la misma forma y el HTTP de su tipo. code es estable: decide con él y no con message, que puede cambiar de redacción. param nombra el campo cuando aplica y details trae datos extra estables de algunos códigos. Cuando el input no cumple el schema, details.issues trae cada problema con su path y su message (hasta 10).
{
"error": {
"type": "not_found",
"code": "deal_not_found",
"message": "No deal with that id in this organization.",
"doc_url": "https://developers.mb-suite.com/errors#deal_not_found",
"request_id": "req_6f1d2c0b9a8e4f7d3c2b1a09"
}
}| Tipo | HTTP | Qué hacer |
|---|---|---|
invalid_request | 400 | El pedido está mal armado: JSON inválido, un campo que no cumple el schema o un encabezado con un valor que no corresponde. param nombra el campo cuando aplica. No reintentes sin corregirlo. |
authentication | 401 | Falta la credencial, no es válida o ya no sirve (revocada, vencida, suspendida). La respuesta trae WWW-Authenticate. Revisa la API key o renueva el token. |
payment_required | 402 | La suscripción de la organización está bloqueada por falta de pago: la API y el MCP quedan en pausa hasta regularizarla. |
permission | 403 | La credencial es válida pero no alcanza: a la persona detrás le falta el permiso o el rol (role_required), a la credencial le falta el alcance, el módulo no está activo, la API y el MCP no están habilitados (platform_not_enabled) o la suscripción no los incluye (platform_requires_paid_plan), o la organización es de demostración (demo_read_only). Pedir de nuevo no cambia nada. |
not_found | 404 | No existe en esta organización, o tu credencial no lo puede ver; una cuenta externa que no está vinculada responde external_account_not_linked. Nunca decimos si un registro de otra organización existe: para ti, no existe. |
conflict | 409 | El estado no permite la operación: el del registro (ya se restauró, ya está en curso), un tope (limit_reached), la integración (integration_not_connected, integration_reconnect_required) o la plataforma externa, que lo rechazó (external_rejected) o no confirmó un envío (external_outcome_unknown). El code dice cuál; algunos traen details. Se guarda en la idempotencia: con la misma clave vuelve la misma respuesta. |
confirmation_required | 409 | La operación pide confirmación (es masiva, irreversible, manda o cambia algo fuera de MB Suite, tiene un costo aparte o es de las que la piden siempre): primero dry_run: true, después el mismo pedido con el confirm_token. |
idempotency | 422 | La Idempotency-Key (en el MCP, idempotency_key) ya se usó con otro pedido. Usa una clave nueva para un pedido nuevo. |
rate_limit | 429 | Un freno: la ráfaga de tu credencial (rate_limited), el tope diario o la espera de una acción manual (daily_limit_reached, cooldown_active) o la plataforma externa, que está limitando (external_rate_limited). Espera lo que dice Retry-After (segundos) y reintenta. |
quota | 429 | La organización llegó a su cupo diario. Se reinicia a las 00:00 UTC; details.quota dice cuál y cuándo. |
maintenance | 503 | La plataforma o el módulo están en mantenimiento. Reintenta después de Retry-After. |
unavailable | 503 | Un servicio que la operación necesita no está disponible ahora: la plataforma externa no contesta (external_unavailable), el servicio de PDF está caído (pdf_unavailable) o la base de MB Suite no contestó al comprobar la credencial (platform_unavailable). No cambió nada y no se guarda en la idempotencia: reintenta después de Retry-After con la misma clave. |
upstream | 502 | La IA no devolvió un resultado que sirva (ai_generation_failed): vacío, fuera de formato o cortado. No se guardó nada y no se guarda en la idempotencia. Los SDK no lo reintentan solos: reintentarlo vuelve a llamar a la IA (y gasta créditos si responde). |
internal | 500 | Algo falló de nuestro lado (lo de un tercero nunca es un 500: tiene su propio código); ya quedó registrado. Reintenta con backoff y, si se repite, escríbenos con el request_id. |
La lista completa de códigos, con qué hacer en cada caso, está en Errores. Un 401 trae además WWW-Authenticate; un 429, un 503 y un 409 idempotency_in_progress, Retry-After.
Lo que se repite entre módulos tiene siempre el mismo código, tipo y details: la integración sin conectar o para reconectar (integration_not_connected, integration_reconnect_required), una cuenta externa sin vincular (external_account_not_linked), la plataforma externa que rechaza, no contesta o limita (external_rejected, external_unavailable, external_rate_limited) o que no confirma un envío que puede haber salido (external_outcome_unknown), una organización de demostración (demo_read_only), un rol (role_required), un tope (limit_reached, daily_limit_reached, cooldown_active), la IA (ai_generation_failed) y el PDF (pdf_unavailable). Un 500 es siempre algo de MB Suite.
Idempotencia
Los POST, PATCH y DELETE aceptan Idempotency-Key (hasta 255 caracteres; un UUID por operación lógica). Durante 24 horas:
- La misma clave con el mismo pedido devuelve la respuesta guardada, con
Idempotent-Replayed: true, sin volver a ejecutar. - La misma clave con otro pedido: 422
idempotency_key_reused. - Si el primero todavía corre: 409
idempotency_in_progress, conRetry-After. - Si el primero corrió pero su respuesta no quedó guardada (era demasiado grande o no se pudo guardar): 409
idempotency_response_unavailable. No se vuelve a ejecutar: lee el recurso en vez de repetir el pedido. - Un 5xx (el 502 de la IA y el 503 de una plataforma externa incluidos) o un 429 no se guardan: reintenta con la misma clave y esta vez se ejecuta.
- El resto de los 4xx sí: un 409
external_rejectedolimit_reachedvuelve igual con la misma clave. Después de corregir la causa, manda el pedido con una clave nueva.
La clave es de cada credencial: dos API keys distintas no comparten claves. Las lecturas no la necesitan. Por el MCP es lo mismo con el argumento idempotency_key (ver MCP).
Vista previa y confirmación
Fuera de la pantalla, algunas operaciones no se ejecutan de un tirón: las masivas, las irreversibles, las que mandan o cambian algo fuera de MB Suite (un correo, un WhatsApp, un SMS, una publicación, un cambio en una plataforma de anuncios), las que tienen un costo aparte (la IA, DataForSEO, un rastreo) y las que la piden siempre (como editar un agente de IA que atiende en vivo). Primero manda el mismo cuerpo con "dry_run": true: no escribe nada y devuelve qué va a pasar y un confirm_token que vale 10 minutos y una sola vez, atado a esa operación, ese input, esa persona y esa credencial. Después repite el pedido con confirm_token en lugar de dry_run; un segundo uso del mismo token es 409 confirm_token_used (para reintentar sin riesgo, la misma clave de idempotencia). Sin token, la respuesta es 409 confirmation_required.
En el contrato lo dicen x-mb-kind (irreversible), x-mb-bulk y x-mb-confirm: always. Unas pocas la piden solo con ciertos datos: x-mb-confirm: conditional, con la condición en x-mb-confirm-when (por ejemplo, invitar a un miembro solo si sale el correo); sin esos datos se ejecutan directo. Cuando es porque algo sale afuera o paga, el 409 lo dice: details.reason es token_required, details.external nombra la plataforma y details.effect dice qué hace (sends, sends_later o pays). Un token que no sirve trae en details.reason si venció (expired), si es de otra operación, input o persona (mismatch) o si vino mal (malformed). Cualquier otra escritura acepta también "dry_run": true para ver qué haría sin escribir nada (su needs_confirmation dice false: se ejecuta sin el token); una lectura no tiene seco (400 dry_run_not_supported).
La vista previa trae summary, counts, items y warnings, en inglés como los demás mensajes de la API; needs_confirmation, que dice si ejecutarla pide el token, y, según la operación, validated (si comprobó que existe lo que toca) y skipped_ids (los ids de un lote que no va a tocar). Lo que sale afuera o paga lo avisa en warnings.
# 1. Vista previa: no borra nada y devuelve un confirm_token (10 minutos)
curl -X POST https://api.mb-suite.com/v1/crm/deals/bulk/delete \
-H "Authorization: Bearer $MB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "deal_ids": ["DEAL_A", "DEAL_B"], "dry_run": true }'
# 2. Ejecutar: el mismo cuerpo, con el confirm_token en vez de dry_run
curl -X POST https://api.mb-suite.com/v1/crm/deals/bulk/delete \
-H "Authorization: Bearer $MB_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "deal_ids": ["DEAL_A", "DEAL_B"], "confirm_token": "CONFIRM_TOKEN" }'{
"dry_run": true,
"summary": "About to run \"Delete many deals\" on 2 items: \"Plan anual — Acme\", \"Renovación — Beta\". Deleted data stays 30 days in the trash.",
"counts": { "items": 2 },
"items": [
{ "type": "deal", "id": "DEAL_A", "name": "Plan anual — Acme" },
{ "type": "deal", "id": "DEAL_B", "name": "Renovación — Beta" }
],
"warnings": [
"This preview checked that what the operation touches exists; your permission was checked for the operation as a whole, and its own rules are checked only when it runs, so it can still fail."
],
"generic": true,
"validated": true,
"needs_confirmation": true,
"confirm_token": "ZTNiMGM0…",
"expires_at": 1762000600000
}DELETE simple de un registro que va a la papelera (un trato, una persona) se ejecuta directo con una API key: queda 30 días en la papelera. Con un token OAuth, por el MCP, y para todo lo masivo o lo que no vuelve, la vista previa es obligatoria. Una baja que deja copia trae en la respuesta trash_item_id (en un lote, trash_item_ids) y trash_expires_at: se restaura con POST /v1/trash/{item_id}/restore (owner o admin).Versiones
La API se versiona por fecha; la vigente es 2026-11-01. Cada organización queda fijada en la versión con la que creó su primera API key; un pedido puede pedir otra versión viva con MB-Version: AAAA-MM-DD, y la respuesta dice en MB-Version cuál usó. Un token OAuth usa la vigente.
- Un cambio que rompe (quitar un campo, cambiar un tipo, volver requerido un parámetro) sale siempre en una versión nueva; la anterior sigue viva 12 meses.
- Una versión en retiro responde con
DeprecationySunset(RFC 8594). - Una versión que no existe o ya no está viva: 400
api_version_invalidoapi_version_unsupported.
Los cambios de cada versión están en el Registro de cambios.
Límites
Cada organización tiene frenos por ráfaga y cupos diarios según su plan. Al pasarse: 429 rate_limited con Retry-After, o 429 quota_exceeded hasta las 00:00 UTC. Cada operación declara cuántas lecturas y escrituras puede hacer y eso es lo que descuenta del cupo; las que tocan una plataforma externa (enviar un WhatsApp, un correo) cuentan además en «operaciones externas».
| Plan | Ráfaga por API key (10 s) | Por minuto (organización) | Llamadas por día | Lecturas por día | Escrituras por día | Operaciones externas por día | API keys activas | Webhooks activos |
|---|---|---|---|---|---|---|---|---|
| Standard | 50 | 300 | 20.000 | 100.000 | 20.000 | 300 | 5 | 5 |
| Growth | 100 | 600 | 50.000 | 250.000 | 50.000 | 1.000 | 10 | 10 |
| Scale | 150 | 1.200 | 150.000 | 750.000 | 150.000 | 2.500 | 25 | 20 |
| Enterprise | 200 | 2.400 | 500.000 | 2.500.000 | 500.000 | 5.000 | 50 | 20 |
| Práctica (API keys de prueba) | 25 | 150 | 5.000 | 25.000 | 5.000 | 50 | 5 | 5 |
Son los límites de cada plan: GET /v1/me devuelve los que rigen hoy para tu organización, que pueden estar ajustados. Hay además un freno por IP: en la API, 600 pedidos por minuto, antes de leer la credencial; en el MCP, 300 por minuto que cuentan solo los pedidos sin una credencial válida (falta, no es de MB Suite, está revocada, suspendida o vencida), así los pedidos válidos no suman. Los dos responden 429 rate_limited con Retry-After.