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" }
limitdefaults 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_cursorisnullon 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.