Menu

Pagination, errors, idempotency and versions

The rules shared by every endpoint: format and strict inputs, cursor lists, the single error shape, safe retries, the dry run and its confirmation, versions and limits.

Format

  • JSON in snake_case both ways (pipeline_id, has_more). Keys that are your own data —custom fields, headers— are kept as they are.
  • Path parameters go in the URL (/crm/deals/{deal_id}). In a GET, everything else goes in the query: limit=50, include_archived=true, lists as label_ids=a,b or label_ids[]=a, objects as JSON (filter={…}). In POST, PATCH and DELETE, in a JSON body of up to 1 MB with Content-Type: application/json.
  • Every response carries MB-Request-Id (req_…): include it if you write to us about a problem.
  • In responses, a new operation or a new field can appear without notice: ignore what you do not know.
  • In what you send it is the other way around: a field the operation does not have is 400 unknown_field instead of being ignored (a misspelled filter never returns the list unfiltered). param names it and, if it looks like it belongs at another level of the body, the message says where. Maps of your own data (custom fields, headers) accept any key.
  • A bulk operation states how many items it takes at most: “Bulk · up to N” in the reference, x-mb-bulk.max_items in the contract and bulk.max_items in mb_describe_operation (x-mb-bulk.maxItems still comes, deprecated).

Pagination

Lists return { data, has_more, next_cursor }. Ask for the next page with cursor=<next_cursor> until has_more is false. In CRM lists, limit goes from 1 to 200 (50 by default); the reference states each list's range. A cursor that cannot be read is 400 invalid_cursor: send next_cursor exactly as it came, to the same list.

Some filters apply to the page that was read: a page can bring fewer than limit —even none— with has_more set to true. Follow the cursor; do not count results.
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);
  // A page can bring fewer than limit (even none) with has_more true: keep following the cursor.
  cursor = page.has_more ? page.next_cursor : undefined;
} while (cursor);

Errors

Every error has the same shape and the HTTP status of its type. code is stable: branch on it, not on message, whose wording can change. param names the field when it applies and details carries stable extra data for some codes. When the input does not match the schema, details.issues lists each problem with its path and message (up to 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"
  }
}
TypeHTTPWhat to do
invalid_request400The request is malformed: invalid JSON, a field that does not match the schema or a header with a wrong value. param names the field when it applies. Do not retry without fixing it.
authentication401The credential is missing, invalid or no longer usable (revoked, expired, suspended). The response carries WWW-Authenticate. Check the API key or refresh the token.
payment_required402The organization's subscription is blocked for non-payment: the API and the MCP are paused until it is settled.
permission403The credential is valid but not enough: the person behind it lacks the permission or the role (role_required), the credential lacks the scope, the module is not active, the API and the MCP are not turned on (platform_not_enabled) or the subscription does not include them (platform_requires_paid_plan), or the organization is a demo (demo_read_only). Asking again changes nothing.
not_found404It does not exist in this organization, or your credential cannot see it; an external account that is not linked answers external_account_not_linked. We never say whether a record of another organization exists: for you, it does not.
conflict409The state does not allow the operation: the record's (already restored, already running), a limit (limit_reached), the integration (integration_not_connected, integration_reconnect_required) or the external platform, which rejected it (external_rejected) or did not confirm a send (external_outcome_unknown). The code says which; some carry details. It is stored for idempotency: the same key returns the same response.
confirmation_required409The operation needs confirmation (bulk, irreversible, one that sends or changes something outside MB Suite or has a separate cost, or one that always asks): first dry_run: true, then the same request with the confirm_token.
idempotency422The Idempotency-Key (on the MCP, idempotency_key) was already used with a different request. Use a new key for a new request.
rate_limit429A limit: your credential's burst (rate_limited), a manual action's daily limit or waiting time (daily_limit_reached, cooldown_active) or the external platform, which is limiting requests (external_rate_limited). Wait what Retry-After says (seconds) and retry.
quota429The organization reached its daily quota. It resets at 00:00 UTC; details.quota says which one and when.
maintenance503The platform or the module is under maintenance. Retry after Retry-After.
unavailable503A service the operation needs is not available right now: the external platform is not answering (external_unavailable), the PDF service is down (pdf_unavailable) or MB Suite's database did not answer while checking the credential (platform_unavailable). Nothing changed and it is not stored for idempotency: retry after Retry-After with the same key.
upstream502The AI did not return a usable result (ai_generation_failed): empty, malformed or cut off. Nothing was saved and it is not stored for idempotency. The SDKs do not retry it on their own: retrying calls the AI again (and spends credits if it answers).
internal500Something failed on our side (a third party's failure is never a 500: it has its own code); it is already logged. Retry with backoff and, if it repeats, write to us with the request_id.

The full list of codes, with what to do in each case, is in Errors. A 401 also carries WWW-Authenticate; a 429, a 503 and a 409 idempotency_in_progress, Retry-After.

What repeats across modules always has the same code, type and details: the integration not connected or to reconnect (integration_not_connected, integration_reconnect_required), an external account not linked (external_account_not_linked), the external platform rejecting, not answering or limiting (external_rejected, external_unavailable, external_rate_limited) or not confirming a send that may have gone out (external_outcome_unknown), a demo organization (demo_read_only), a role (role_required), a limit (limit_reached, daily_limit_reached, cooldown_active), the AI (ai_generation_failed) and the PDF (pdf_unavailable). A 500 is always something of MB Suite's.

Idempotency

POST, PATCH and DELETE accept Idempotency-Key (up to 255 characters; one UUID per logical operation). For 24 hours:

  • The same key with the same request returns the stored response, with Idempotent-Replayed: true, without running again.
  • The same key with a different request: 422 idempotency_key_reused.
  • If the first one is still running: 409 idempotency_in_progress, with Retry-After.
  • If the first one ran but its response was not stored (it was too large or could not be saved): 409 idempotency_response_unavailable. It does not run again: read the resource instead of repeating the request.
  • A 5xx (the AI's 502 and an external platform's 503 included) or a 429 is not stored: retry with the same key and this time it runs.
  • The rest of the 4xx are: a 409 external_rejected or limit_reached comes back the same with the same key. After fixing the cause, send the request with a new key.

The key belongs to each credential: two different API keys do not share keys. Reads do not need it. Through the MCP it is the same with the idempotency_key argument (see MCP).

Dry run and confirm_token

Outside the screen, some operations do not run in one shot: bulk ones, irreversible ones, the ones that send or change something outside MB Suite (an email, a WhatsApp message, an SMS, a post, a change on an ad platform), the ones with a separate cost (AI, DataForSEO, a crawl) and the ones that always ask (like editing an AI agent that works live). First send the same body with "dry_run": true: it writes nothing and returns what is going to happen and a confirm_token valid for 10 minutes and for one run, bound to that operation, that input, that person and that credential. Then repeat the request with confirm_token instead of dry_run; using the same token again is 409 confirm_token_used (to retry safely, the same idempotency key). Without a token, the answer is 409 confirmation_required.

In the contract it is stated by x-mb-kind (irreversible), x-mb-bulk and x-mb-confirm: always. A few ask for it only with certain data: x-mb-confirm: conditional, with the condition in x-mb-confirm-when (for example, inviting a member only when the email goes out); without that data they run directly. When it is because something goes outside or costs, the 409 says so: details.reason is token_required, details.external names the platform and details.effect says what it does (sends, sends_later or pays). A token that does not serve carries in details.reason whether it expired (expired), belongs to another operation, input or person (mismatch) or is malformed (malformed). Any other write also accepts "dry_run": true to see what it would do without writing anything (its needs_confirmation says false: it runs without the token); a read has no dry run (400 dry_run_not_supported).

The preview carries summary, counts, items and warnings, in English like the API's other messages; needs_confirmation, which says whether running it needs the token, and, depending on the operation, validated (whether it checked that what it touches exists) and skipped_ids (the ids of a batch it will not touch). What goes outside or costs is flagged in warnings.

# 1. Preview: deletes nothing and returns a confirm_token (10 minutes)
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. Execute: the same body, with the confirm_token instead of 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 response
{
  "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
}
The plain DELETE of a record that goes to the trash (a deal, a person) runs directly with an API key: it stays 30 days in the trash. With an OAuth token, through the MCP, and for anything bulk or that does not come back, the dry run is required. A deletion that keeps a copy returns trash_item_id (in a batch, trash_item_ids) and trash_expires_at: restore it with POST /v1/trash/{item_id}/restore (owner or admin).

Versions

The API is versioned by date; the current one is 2026-11-01. Each organization is pinned to the version it had when it created its first API key; a request can ask for another live version with MB-Version: YYYY-MM-DD, and the response says in MB-Version which one it used. An OAuth token uses the current one.

  • A breaking change (removing a field, changing a type, making a parameter required) always ships in a new version; the previous one stays live for 12 months.
  • A version being retired answers with Deprecation and Sunset (RFC 8594).
  • A version that does not exist or is no longer live: 400 api_version_invalid or api_version_unsupported.

Each version's changes are in the Changelog.

Limits

Each organization has burst limits and daily quotas according to its plan. When you go over: 429 rate_limited with Retry-After, or 429 quota_exceeded until 00:00 UTC. Each operation declares how many reads and writes it can do, and that is what it takes from the quota; the ones that touch an external platform (sending a WhatsApp or an email) also count as “external operations”.

PlanBurst per API key (10 s)Per minute (organization)Calls per dayReads per dayWrites per dayExternal operations per dayActive API keysActive webhooks
Standard5030020,000100,00020,00030055
Growth10060050,000250,00050,0001,0001010
Scale1501,200150,000750,000150,0002,5002520
Enterprise2002,400500,0002,500,000500,0005,0005020
Practice (test API keys)251505,00025,0005,0005055
The free trial does not include the API or the MCP: they work with an active paid subscription. The Practice row is the quota of the practice organization, where test API keys work, and it never uses your workspace's quota.

These are each plan's limits: GET /v1/me returns the ones in force today for your organization, which may have been adjusted. There is also a per-IP limit: on the API, 600 requests per minute, before reading the credential; on the MCP, 300 per minute that count only the requests without a valid credential (missing, not an MB Suite one, revoked, suspended or expired), so valid requests do not add to it. Both answer 429 rate_limited with Retry-After.