Conventions
What holds across every route: how the API is versioned, how it fails, how lists are paged, how to retry a write safely, and how to behave when you are asked to slow down. Write one error handler and one retry policy against this page and they apply everywhere.
Versioning
The version is part of the address (/api/public/v1) and is sent back on every
response, errors included, as a header:
X-Tstr-Api-Version: v1
The one exception is a response stopped before it reaches the API (see Authentication): the API did not send it, so it cannot carry the header.
Within v1, changes are additive only. We may add:
- routes;
- optional request fields and parameters;
- fields in a response;
- values in a documented set, such as a new integration
status; - error
types; - scopes.
We do not remove or rename a route, a field, a scope or an error type, change a field's type or meaning, make an optional request field required, or start refusing a request that used to be accepted. A change that cannot be made that way would be a new version, served under its own prefix.
What that asks of your client:
- Ignore response fields you do not know. Do not reject a body for an extra key, and do not generate a strict schema that forbids one.
- Tolerate a value you do not know in a field that holds one of a set, and do not crash on it.
- Handle an error
typeyou do not know by its HTTP status. See Error types. - Do not depend on key order or on the spacing of a response.
Requests and responses
- Bodies are JSON. Timestamps are ISO 8601 in UTC with an explicit offset, such as
2026-10-16T09:30:00.123456+00:00. - Credentials go in the
Authorization: Bearerheader only. See Authentication. - Only the methods a route lists are served. A wrong method on a real path is a
404 unknown_route, not a405, andHEADandOPTIONSare not served: useGET. A trailing slash is a different path, and/meta/is not/meta. - To send PATCH, PUT or DELETE through a network that blocks them, see Authentication.
- The API is for servers and command-line tools. It does not send the cross-origin headers that would let browser JavaScript served from another site call it, and a preflight request from another origin is refused. Calling it from a page on another site will not work.
Errors
Every error from the API has one shape, whichever part of the API produced it. It is modelled on RFC 7807 problem details:
{
"type": "insufficient_scope",
"title": "Insufficient scope",
"status": 403,
"detail": "The access token does not carry every scope this operation needs. The WWW-Authenticate header lists all of them; re-authorize requesting that complete set."
}
typeis a stable machine-readable slug. This is the field to switch on. It is a slug, not a URL: there is nothing at the other end of it to fetch.statusrepeats the HTTP status code.titleanddetailare for a person reading a log, and are written to say who has to act. Their wording may change, so do not match on them.- A
422also haserrors, a list with one entry for each problem. Each hasloc(where in the request),msgandtype. There is no field that echoes back what you sent.
The body's Content-Type is application/json, not application/problem+json.
A 401, and the 403 for a missing scope, also carry a WWW-Authenticate: Bearer
challenge whose resource_metadata parameter is the address of the
protected-resource metadata.
An unexpected failure inside the API has this shape too: a 500 with type
internal_error, and the version header. It says nothing about the cause, on
purpose, so there is nothing in it to act on beyond retrying. A 5xx that does not
follow this format usually came from something in front of the API and not from the
API itself, so treat a 5xx you cannot parse as transient.
Error types
This is the complete list of types the API can return. A client should handle each
by what it asks of whoever has to act. The status and the "who" are what differ.
type | Status | Who acts | What it means, and what to do |
|---|---|---|---|
too_many_items | 400 | You | A bulk request carried more items than one request may: 1,000. Nothing was done. Split it into requests of 1,000 or fewer, each with an Idempotency-Key of its own. See POST /contacts:bulk. |
idempotency_key_required | 400 | You | The route needs an Idempotency-Key header and the request carried none. Nothing was done. Send a new value for each logical operation. See Idempotency. |
invalid_idempotency_key | 400 | You | The Idempotency-Key header is empty, only spaces, or longer than 255 characters. Nothing was done. Send between 1 and 255. |
authentication_required | 401 | You | The request carried no bearer token, or the header was not in the form Bearer <token>. Send one. |
invalid_token | 401 | You | The access token is expired, revoked or unknown, or the value sent is not an access token at all. Refresh it. If the refresh is refused, sign in again. |
token_audience_mismatch | 401 | You | The token was not issued for this API. Request one with resource set to the metadata document's resource value, then retry. |
insufficient_scope | 403 | You, and the person who consents | The credential lacks a scope this operation needs. The WWW-Authenticate header's scope lists every scope the operation needs. Ask for that whole set. (POST /contacts:bulk is the one route where it lists alternatives: any one of them is enough to call it.) An approval ceiling or the signer's role can still withhold some of them; see Scopes. |
tenant_mismatch | 403 | You | The token was authorized for another organization. Sign in again and choose the right one, or send the request to that organization's address. The response never names the other organization. |
grant_pending_approval | 403 | The customer's administrator | Waiting for an administrator. The application has connected and nobody has approved it yet, so no data is served. An administrator approves it in Settings, and then the same token works. Do not sign in again. |
grant_denied | 403 | The customer's administrator | An administrator said no. The application was declined, and stays declined until an administrator approves it. Do not retry in a loop; speak to the customer. |
third_party_access_admins_only | 403 | The customer's administrator | You may not grant access. The organization only lets administrators connect applications, and the person who authorized this one is not one. An administrator has to approve it in Settings; signing in again does not change the answer. |
third_party_access_disabled | 403 | The customer's administrator | The customer switched it off. Third-party access is turned off for the organization, so no application is served, approved ones included. Nothing needs fixing in your code; it resumes when an administrator turns access back on. |
unknown_route | 404 | You | No route matches this method and path. Check both against the route reference. |
contact_not_found | 404 | You | No contact in the account has the id in the path. Take ids from GET /contacts, which also finds a contact by email address or external id. On a bulk item, the same words are an outcome and mean something different: nobody matched yet. |
idempotency_key_reused | 409 | You | The Idempotency-Key was already used with a different request body. That is a mistake and not a retry, because a retry sends the request it sent before. Nothing was done, and the answer to the first request is unchanged. Change the request or send a new key. See Idempotency. |
idempotency_request_in_progress | 409 | You | Another request with this Idempotency-Key has not finished, and this one did nothing. That is not a mistake: wait the number of seconds in the Retry-After header and send the same request again. If the first finished you are given its answer, and if it failed this one runs. See Idempotency. |
email_address_in_use | 409 | You | An email address in the request already belongs to a contact: the one in the path or another. Addresses are unique across the account and the API never moves one between contacts, because an address carries its whole consent history with it. Nothing was done. Find the holder with GET /contacts?email=... and decide. |
subscription_list_name_in_use | 409 | You | A subscription list that has not been deleted already has the name in the request, and names are unique across an account. Nothing was created. If this is a re-run of a migration, send the list's source_ref: the second run is then answered with the list the first one made, whatever it has since been renamed to. See Re-running a migration. |
address_erased | 409 | You | An email address in the request belongs to somebody who exercised the right to erasure (GDPR Article 17), so it cannot be added: adding it would enrol them again. Nothing was done, and retrying will not change it. Skip the record. |
validation_error | 422 | You | The request does not match the operation's schema. errors says where. |
suppression_cannot_be_cleared | 422 | You | A write sent false for do_not_contact, do_not_contact_email or do_not_contact_sms. Those can be set here and never cleared: undoing a suppression makes somebody contactable again, and an application holding an exported copy cannot know whether they have asked us to stop since. Nothing was done. Send true to suppress, leave the field out to leave it alone, and let the person change their mind in the preference centre. See PATCH /contacts/{contact_id}. |
rate_limited | 429 | You | You are sending requests faster than you are allowed to. The Retry-After header is the number of seconds to wait; wait at least that long, and if you keep being limited, slow down and back off further. Your credential is fine: do not sign in again. See Rate limits. |
internal_error | 500 | Us | Something failed on our side that we did not expect, and nothing in your request or your credentials caused it. Retry shortly, and do not sign in again. If it keeps failing the fault is ours to fix and nothing you send will change it. The response says nothing about the cause, on purpose. If the request was a write and you cannot tell whether it took effect, check before you repeat it. |
authorization_unavailable | 503 | Us | We could not check this request's authorization just now. It is a fault on our side and not a problem with your credentials: retry shortly, and do not sign in again. |
public_api_not_configured | 503 | The deployment's operator | This deployment has not been set up to serve the API. Retrying does not clear it. |
The four governance refusals (grant_pending_approval, grant_denied,
third_party_access_admins_only and third_party_access_disabled) are four
different next actions, which is why they are four types. None is a 401: the
token is genuine, and signing in again cannot change what the customer has decided.
They are the customer's access policy at work, described from the administrator's
side in Connected applications.
Types that are not in the table. The table holds every type this API's own code
chooses, a route's own included, and the route reference says which
routes return which. Some errors come from the web framework and not from a route, and
carry a generic type of the form http_<status> (for example http_404), with the
standard phrase for the status as the title. Types may be added within v1, as the
versioning section says, so a client needs a default for any type it does not know:
act on the status.
Pagination
Routes that return many records page with a keyset cursor. There is no offset
and no page number: a long walk over records that are changing would skip some and
repeat others, and the cursor carries on from the last record you saw instead.
A page looks like this:
{
"items": [ "..." ],
"next_cursor": "an-opaque-string",
"count": 100
}
- Ask for the next page by sending
next_cursorback as thecursorquery parameter.next_cursorisnullon the last page. countis the number of items on this page, not a total.- A cursor is opaque. Do not build one, read one, or change one, and do not assume its format.
- A cursor this API did not send is refused with a
422. It is never read as "start from the beginning".
Each list route in the route reference says which parameters it
takes. A short, complete listing, such as GET /integrations, is not paged.
Idempotency
Routes that create or change data take an Idempotency-Key header, so that a
request repeated after a timeout or a dropped connection is not carried out twice.
- Send a fresh value (a UUID will do) for each logical operation, and send the
same value again if you retry that operation. A key is 1 to 255 characters and
not only spaces; anything else is
400 invalid_idempotency_key. - If the key and the body match a request that has already completed, you get the
original response back, with the original status, and nothing is done a second
time. A retried
POST /contactsis answered201with the contact it made, and not409for an address it has itself just taken. - The same key with a different body is a
409of typeidempotency_key_reused: it is a mistake, not a retry. Change the request or the key. - A request whose twin is still running is refused at once with a
409of typeidempotency_request_in_progress, and has done nothing. That one is not a mistake: it is the opposite instruction. Wait the number of seconds inRetry-Afterand send the same request again. If you are told again, wait again. A client that handles both409s the same way will either give up on a request that was fine or repeat one that was wrong, which is why they are two types. - A request that fails is not remembered, so repeating it runs it. A
5xxthat you cannot tell took effect or not is exactly what the key is for: repeat it with the same key. - The key belongs to the application, the person who authorized it, the method and the whole URL with its query string. The same value sent to two different contacts' URLs is two requests, and the same value from two applications is two requests. It does not belong to the access token, so a token refreshed between a request and its retry still finds the first answer.
- A key protects a retry, so the answer is remembered for seven days. It is not a way to guard against running the same job again later; that is what the re-run rules of the route are for.
Each route in the route reference says whether it accepts the header.
It is required on POST /contacts:bulk and optional on the other writes.
Rate limits
When you go over a limit, the answer is 429 Too Many Requests with type: rate_limited and a Retry-After header: the number of seconds to wait. Wait at
least that long before you send the request again, and if you keep being limited,
slow down and back off further. It is not a problem with your credential, so do not
sign in again, and a 429 carries no WWW-Authenticate challenge.
There are two limits, and both answer in the same way:
- Per application. Each application has an allowance of its own with each customer, shared by every process and every person using that application for that customer, wherever it runs. An application that works with several customers is limited at each one separately. This is the limit an integration is meant to meet first, if it meets one at all.
- Per network address. Each address that calls a customer's API also has an allowance there, counted before the access token is looked at. It is what keeps a flood of invalid credentials from costing the sign-in service a lookup apiece, and it is set well above the per-application limit, so an application that stays inside its own allowance does not meet it. Applications that call a customer from one outbound address share it.
Both are generous by default: they are meant to sit well above what a migration or
a sync needs. They are deliberately not published as a number, because a number
written into a client becomes a number we cannot move. Write your client to react to
429 and Retry-After, not to a figure.
A value that cannot be one of our access tokens is refused with invalid_token
without being checked, but it still counts toward the per-address allowance. Send
the token exactly as you received it, with nothing added around it.