Shared rules

Pagination, idempotency, errors, pace and versioning. The same across all four apps.

Wrap, Swarm, Nest and Flow share these rules. Learning them once covers all four.

Versioning

The base is always /api/public/v1. A breaking change bumps the version in the path; it never changes the meaning of what is already published.

That base is the only contract. Any other route you come across changes without notice, so an integration that depends on one will break.

Pagination

Every list uses a cursor:

GET /api/public/v1/contacts?limit=50&cursor=<opaque>
{ "data": [ ... ], "next_cursor": "b2ZmOjUw" }
  • limit defaults to 50, maximum 100. An odd value is not an error, it is clamped. Zero, negative or text fall back to 50; 500 becomes 100.
  • next_cursor is null on the last page, so there is no extra round trip to discover there is nothing left.
  • The cursor is opaque. Echo it back, never parse or build one. Each app encodes it differently and that encoding is free to change.
  • An unreadable cursor is not an error: the list restarts from the beginning. Worth knowing, because a loop retrying with a corrupted cursor spins on the first page instead of failing.

There is no total and no has_more. Page until next_cursor is null.

Idempotency

Almost every POST requires an Idempotency-Key header. Every one that creates or records something asks for it; Flow's two webhook-management POSTs are the exception. Without it you get a 400 and nothing is created:

{
  "error": {
    "code": "invalid_request",
    "message": "The Idempotency-Key header is required on this endpoint."
  }
}

With it:

Case What happens
Same key, same body You get the stored response back verbatim, same status. Nothing runs twice
Same key, different body conflict, 409

Two things that surprise people:

  • A key is remembered forever. It does not expire. Reusing one from six months ago returns that response.
  • A key belongs to the workspace, not the endpoint. Uniqueness is the pair of workspace and key, so using one key on two different endpoints gives you a 409, not two creations. Generate one key per operation: a UUID is fine.

Even error responses are stored, so a retry with the same key hands you the same error rather than trying again.

POST only. PATCH and DELETE neither require it nor look at it: a partial update sending the same body twice already leaves the record identical, so there is nothing to protect.

Errors

Always the same envelope:

{ "error": { "code": "not_found", "message": "..." } }

Eight codes, and their status never varies:

Code Status
unauthorized 401
forbidden 403
invalid_request 400
not_found 404
conflict 409
unprocessable 422
rate_limited 429
internal 500

Branch on error.code, never on the status and never on the message: messages are English and can change.

404, not 403

A record that exists but that your credential cannot see answers 404, exactly like an invented id.

The two cases are indistinguishable on purpose. A 403 would confirm that the private project exists, and from the id you can usually work out whose it is: that would leak precisely what "private" exists to protect.

Pace

Every scope has a class, and every class a per-minute budget:

Class Per minute
Read 120
Write 30
Manage 30

Buckets are per credential and per scope, so reading and writing at the same time do not compete with each other.

Go over and you get a 429 with Retry-After. There are no X-RateLimit-* headers on this surface, so you cannot know what is left without asking and seeing it fail. The one suite API that does send them is Zap's delivery API.

Treat these numbers as a floor, not a ceiling. The effective limit can be higher and is not predictable: design to respect the published number and to tolerate a 429 anyway.

Money values

Money comes back as strings, not floats, so a decimal value does not lose precision passing through JSON. Convert with your language's decimal library, not with parseFloat.