Free live webinar

Same team, more pipeline: AI-powered marketing for Dynamics 365 · Wed 11 Nov · 1:00pm ET · with Microsoft MVP Mark SmithSame team, more pipeline · 11 Nov

Save my seat

Conventions

Developer

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 type you 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: Bearer header only. See Authentication.
  • Only the methods a route lists are served. A wrong method on a real path is a 404 unknown_route, not a 405, and HEAD and OPTIONS are not served: use GET. 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."
}
  • type is 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.
  • status repeats the HTTP status code.
  • title and detail are 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 422 also has errors, a list with one entry for each problem. Each has loc (where in the request), msg and type. 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.

typeStatusWho actsWhat it means, and what to do
too_many_items400YouA 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_required400YouThe 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_key400YouThe Idempotency-Key header is empty, only spaces, or longer than 255 characters. Nothing was done. Send between 1 and 255.
authentication_required401YouThe request carried no bearer token, or the header was not in the form Bearer <token>. Send one.
invalid_token401YouThe 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_mismatch401YouThe token was not issued for this API. Request one with resource set to the metadata document's resource value, then retry.
insufficient_scope403You, and the person who consentsThe 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_mismatch403YouThe 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_approval403The customer's administratorWaiting 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_denied403The customer's administratorAn 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_only403The customer's administratorYou 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_disabled403The customer's administratorThe 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_route404YouNo route matches this method and path. Check both against the route reference.
contact_not_found404YouNo 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_reused409YouThe 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_progress409YouAnother 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_use409YouAn 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_use409YouA 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_erased409YouAn 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_error422YouThe request does not match the operation's schema. errors says where.
suppression_cannot_be_cleared422YouA 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_limited429YouYou 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_error500UsSomething 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_unavailable503UsWe 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_configured503The deployment's operatorThis 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_cursor back as the cursor query parameter. next_cursor is null on the last page.
  • count is 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 /contacts is answered 201 with the contact it made, and not 409 for an address it has itself just taken.
  • The same key with a different body is a 409 of type idempotency_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 409 of type idempotency_request_in_progress, and has done nothing. That one is not a mistake: it is the opposite instruction. Wait the number of seconds in Retry-After and send the same request again. If you are told again, wait again. A client that handles both 409s 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 5xx that 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.