Skip to main content

Errors

An error answers with a status code and, in most cases, a JSON body carrying a stable code and a readable message:

{ "code": "validation_error", "message": "occurredAt is required on every event." }

Branch on the status and the code. The message is for people and may change.

StatuscodeWhat happenedRetry?@throughlinehq/server throws
400validation_errorThe body was malformed or missed a required field.No. Fix the request.ThroughlineInvalidRequestError
401invalid_clientPOST /v1/token got a wrong ClientID or secret, or a revoked key.No.ThroughlineAuthenticationError
401unauthenticatedThe token is missing, malformed or expired.Once, after a new exchange.ThroughlineAuthenticationError
402see belowThe Tenant's subscription is suspended, so writes are refused.Not until billing is fixed.ThroughlineSubscriptionSuspendedError
403insufficient_scopeThe key lacks the Scope this route needs.No.ThroughlineScopeError
404noneNo Contact has that id.No.none: resolves null or false
422unresolved_identityAn Event matched no existing Contact or organization.After creating it.ThroughlineRejectedEventsError
429rate_limitedThe key has used up its request budget.Yes, after Retry-After.ThroughlineRateLimitError
5xxvariesSomething failed on our side.Yes, with backoff.ThroughlineServerError
nonenoneThe request timed out or never connected.Yes, with the same DedupKeys.ThroughlineNetworkError

The SDK retries the last three for you and never retries the rest.

Bad credentials: 401​

On POST /v1/token, invalid_client means the ClientID and secret do not match a live key. Check that the two values come from the same key and that the key has not been revoked. Retrying will not help.

On every other route, unauthenticated almost always means the token expired. Exchange the key again and repeat the request once. A second 401 in a row means the key itself no longer works.

Subscription suspended: 402​

After repeated failed payments, a Tenant's subscription is suspended and its data becomes read-only. Every route that changes something answers 402, except erasing a Contact, which keeps working so you can still answer a deletion request. Reads and the token exchange keep working too.

This body has a different shape from the other errors. It is a problem details document, and type carries the code:

{
"type": "subscription_suspended",
"title": "Subscription suspended",
"status": 402,
"detail": "This company's subscription is suspended after repeated failed payments. The data stays readable, but nothing can be changed or sent until the payment details in billing are updated."
}

Retrying will not help. Someone with access to billing in the app has to update the payment details, and writes work again once the subscription is active.

@throughlinehq/server throws ThroughlineSubscriptionSuspendedError, with type as its code and detail as its message, and does not retry it.

Missing Scope: 403​

The message names the Scope the key lacks, such as This API key is missing the contacts:read scope. See Scopes.

Rejected payload: 400​

The message says what is wrong, and with a batch it describes the first invalid Event. Nothing in the request was recorded, so fix it and send the whole request again.

Unknown Contact or organization: 422​

Event routes record a request all at once or not at all. When any Event in it matches neither an existing Contact nor an existing organization, nothing in the request is recorded, including the Events that did match. The body lists each Event that failed, by its position in the request:

{
"code": "unresolved_identity",
"message": "1 of 2 events matched no Contact or Organization. No events were recorded.",
"unresolved": [
{
"index": 1,
"eventName": "invoice_paid",
"contactId": null,
"contactExternalId": "user-43",
"contactEmail": null,
"organizationExternalId": null
}
]
}

Create the missing Contact, then send the request again with the same DedupKeys. Or drop the listed Events and send the rest. events.trackBatch in @throughlinehq/server does the second for you and reports the dropped Events in rejected.

Rate limited: 429​

Each key has two budgets, one for reads (GET) and one for writes (everything else). Each refills at 100 requests a second; the write budget absorbs bursts of up to 1,000 requests and the read budget up to 200. The token exchange does not count against either.

A refused request carries a Retry-After header with the number of seconds to wait:

HTTP/1.1 429 Too Many Requests
Retry-After: 1

{ "code": "rate_limited", "message": "This API key has exhausted its request budget. Retry after the indicated delay." }

Wait that long, then send the same request again. To send many Events, send them as one array instead of one request each: a batch costs one request from the budget.

Network failures and 5xx​

You cannot tell whether a request that timed out reached us. Send it again with the same dedupKey on every Event, and an Event that already landed is recorded once rather than twice. The DedupKey protects a retry for 60 seconds, so retry promptly. Contact writes need no key: an upsert lands the same Contact however often it arrives.