Resources

Errors and rate limits

Every error code, what actually causes it, what to do about it, and the limits you are working inside.

Two error shapes

Almost everything uses the envelope:

JSON
{
  "data": null,
  "error": {
    "code": "OAUTH_INSUFFICIENT_SCOPE",
    "message": "This token does not carry the cv:read scope."
  },
  "meta": null
}

POST /oauth/token and POST /oauth/introspect are the exceptions. Their RFCs specify flat bodies and Tabbio follows them:

JSON
{
  "error": "invalid_grant",
  "error_description": "The authorization code has already been used."
}

So a failure from the token or introspection endpoint is payload.error as a string, and everything else is payload.error.code. One helper covers both:

tabbio-error.js
export function errorCodeOf(payload) {
  // The token and introspection endpoints answer the flat RFC shape.
  if (typeof payload?.error === "string") return payload.error;
  return payload?.error?.code ?? null;
}

POST /oauth/revoke is not one of the exceptions: RFC 7009 specifies no success body at all, so it keeps the envelope.

Branch on the code, never the message

error.code is part of the contract and will not change. error.message is written for a person reading a log and gets reworded.

Sign in with Tabbio

CodeHTTPMeaning
OAUTH_INVALID_REQUEST400A required parameter is missing or malformed.
OAUTH_INVALID_CLIENT401Unknown client id, or client authentication failed.
OAUTH_INVALID_GRANT400The code or refresh token is expired, already used, issued to another client, or the PKCE verifier does not match.
OAUTH_INVALID_SCOPE400You asked for a scope the app is not allowed to request, or tried to widen a grant on refresh.
OAUTH_UNAUTHORIZED_CLIENT400The app may not use this grant type.
OAUTH_UNSUPPORTED_GRANT_TYPE400Only authorization_code and refresh_token exist.
OAUTH_ACCESS_DENIED403The user declined on the consent screen.
OAUTH_INVALID_TOKEN401The access token is missing, expired, or revoked.
OAUTH_INSUFFICIENT_SCOPE403The token is valid but the user did not grant the scope this endpoint needs.

The ones that actually happen

OAUTH_INVALID_GRANT right after a successful sign in. Your callback handler ran twice, or a retry re-sent the code. Codes are single use, and a second exchange revokes the tokens the first one produced. Make the handler idempotent.

OAUTH_INVALID_GRANT on refresh. Either the token was already rotated (you lost a response and kept the old value) or reuse detection revoked the family. Neither is recoverable. Send the person through the authorization flow again.

OAUTH_INSUFFICIENT_SCOPE on an endpoint you thought you had. They granted less than you asked. Read scope from the token response and store it. See Scopes and consent.

OAUTH_INVALID_TOKEN on a connection that worked yesterday. Usually just an expired access token, so refresh. If the refresh also fails, the person disconnected you in Settings > Connected apps, and the answer is to ask rather than to retry.

invalid_grant with a redirect URI that looks right. Matching is exact. A trailing slash, http against a registered https, or an added query parameter are all different URIs.

Partner API

CodeHTTPMeaning
API_KEY_INVALID401The API key is missing, expired, or revoked.
API_KEY_INSUFFICIENT_SCOPE403The key is valid but does not carry the scope this endpoint needs.
COMPANY_NOT_MANAGED_BY_APP403The company, job or application belongs to a company your app did not create.
PUBLIC_API_VALIDATION_ERROR400The body failed validation, or a job did not satisfy the publish contract. The message names the field.
JOB_NOT_FOUND404No job with this id.
APPLICATION_NOT_FOUND404No application with this id.
CV_NOT_FOUND404The account has no CV to share yet.
CV_LINK_NOT_FOUND404The durable CV link is unknown, was revoked, or the user disconnected the app.
CONNECTION_NOT_FOUND404The connection behind this token is gone.
DEVELOPER_PLATFORM_DISABLED404The developer platform is switched off for this deployment.

The ones that actually happen

COMPANY_NOT_MANAGED_BY_APP. The company exists but another app created it, or a Tabbio user made it themselves. Your key only reaches companies your app created, and the same 403 covers a job or an application underneath one. Check you are not carrying a company id from a different environment.

API_KEY_INVALID in production only. Almost always the staging key in the production environment, or a key that was rotated out and deleted. The Last used column on the platform's API keys page tells you which key is really being sent.

PUBLIC_API_VALIDATION_ERROR. The message names the field. Three causes account for most of them: a company description under 50 characters, a website without a scheme, and a job created with saveAs: "live" that does not satisfy the publish contract. The same code covers a job action that its status forbids, such as publishing a job that is already live.

A 400 from publish, close or archive. These are not idempotent. Publishing a live job, closing a job that is not live, and archiving a live job are all refused. Read status first, or treat the 400 as the no-op it describes.

CV_NOT_FOUND with the cv:read scope granted. The account has never built a CV. Render an empty state; the connection is fine.

Retrying

FieldTypeDescription
429Retry

Back off and try again, after meta.retryAfter seconds. A burst or per-minute limit clears within seconds; QUOTA_EXCEEDED does not clear until the next UTC midnight.

500, 502, 503, 504Retry

Exponential backoff with jitter, a handful of attempts, then give up and alert.

408, network timeoutRetry carefully

The request may have succeeded. Use an idempotency key on writes.

400, 401, 403, 404, 422Do not retry

Nothing about the same request will succeed. Fix it or surface it.

retry.js
const RETRYABLE = new Set([429, 500, 502, 503, 504]);

export async function callTabbio(url, options = {}, attempt = 0) {
  const response = await fetch(url, options);
  if (response.ok || !RETRYABLE.has(response.status) || attempt >= 4) return response;

  // Full jitter: 0 to 2^attempt seconds, capped. Without the jitter every client
  // that hit the limit together comes back together and hits it again.
  const ceiling = Math.min(2 ** attempt * 1000, 16000);
  await new Promise((resolve) => setTimeout(resolve, Math.random() * ceiling));
  return callTabbio(url, options, attempt + 1);
}

Use an Idempotency-Key on job creation and an externalId on company creation, so a retry after a timeout cannot produce a duplicate. See Jobs and Companies.

Your account limits

Every /v1 resource route is metered against your developer account, across every app, key and access token it owns. One budget, one number to explain.

PlanRequests per minuteRequests per dayWho gets it
Standard30020,000Every developer account starts here. No request needed.
Elevated1,000200,000Granted on request once your integration is live.
CustomSet by TabbioSet by TabbioAgreed for a volume integration. The platform shows the numbers you were given.

The plan you are on, today's consumption and the exact numbers you were given are on the Limits page of the developer platform. To ask for more, mail developers@tabbio.com with your account, the endpoints you call and the volume you expect.

Reading the headers

Every /v1 resource response carries the state of both windows, so you never have to guess how close you are.

HeaderWhat it carries
x-ratelimit-limitRequests allowed in the current minute.
x-ratelimit-remainingRequests left in the current minute.
x-ratelimit-resetEpoch seconds at which the minute window resets.
x-quota-limitRequests allowed in the current UTC day.
x-quota-remainingRequests left today.
x-quota-resetEpoch seconds at the next UTC midnight.
x-request-idOn every /v1 and /oauth response, failures included. The platform's request log is keyed on it, and support asks for it first.
budget.js
export function budgetOf(response) {
  const number = (name) => {
    const value = response.headers.get(name);
    return value === null ? null : Number(value);
  };
  return {
    minuteRemaining: number("x-ratelimit-remaining"),
    minuteResetsAt: number("x-ratelimit-reset"),   // epoch seconds
    dayRemaining: number("x-quota-remaining"),
    dayResetsAt: number("x-quota-reset"),          // next UTC midnight
    requestId: response.headers.get("x-request-id"),
  };
}

Both windows answer 429 when they are spent, with different codes.

CodeHTTPMeaning
RATE_LIMIT_EXCEEDED429You are over the per-minute limit for your developer account. meta.retryAfter is the seconds to wait.
QUOTA_EXCEEDED429You have spent the daily quota for your developer account. meta.retryAfter counts down to the next UTC midnight.
DEVELOPER_ACCOUNT_SUSPENDED403The account is suspended, so every app it owns is refused. Mail developers@tabbio.com.

meta.retryAfter carries the seconds to wait on both. A minute window clears within seconds, so a backoff is the right answer. A day window does not: QUOTA_EXCEEDED means you are done until the next UTC midnight, so stop, alert, and either shed work or ask for a higher limit rather than retrying into a wall.

The daily quota is counted in UTC

Not in your timezone and not in the account's. A job that runs at 03:00 Gulf time is spending yesterday's quota.

Per-surface tiers

On top of the account budget, each surface has its own burst tier. They exist to stop one broken loop from taking a shared component down, and you will normally meet the account limit first.

SurfaceBurstSustainedCounted against
/oauth/token, /oauth/revoke, /oauth/introspect20 requests per 10 seconds1200 per hourYour client id, falling back to the client IP
/v1/* with an API key20 requests per second3000 per minuteYour app
/v1/me* with an access token20 requests per second3000 per minuteThe connection
GET /v1/cv-links/{token}60 requests per second2000 per minuteClient IP

Partner API limits are counted against your app, so every key you own shares one budget. Calls made with a user access token are counted against that connection instead, so one noisy user cannot exhaust everybody else's budget.

The three OAuth endpoints are counted against your client id, read from HTTP Basic or from the body, and fall back to the client IP when a request names no client at all. So a broken deployment of yours cannot spend another partner's budget, and an unauthenticated flood is still bounded.

Over the limit answers 429. The burst window clears in seconds; the sustained one takes a minute or an hour depending on the surface.

Staying inside them

  • Page with pageSize=100. Twenty pages of 5 is twenty requests for the same data. Every list here is page based: page, pageSize, and a meta with totalPages.
  • Poll on a schedule, not in a loop. Every five minutes across your live jobs is well inside the limits. Every second is not, and finds nothing new.
  • Use the ETag on /v1/me/cv. A 304 still counts as a request, but it is fast and it saves you the parse and the diff.
  • Cache what does not change. A company's name and slug are stable. Re-reading them before every job post is spend for nothing.
  • Check applicationCount before paging candidates. One job read tells you whether anything is new.

When something is wrong on our side

GET /v1/openapi.json returns the live specification, so you can confirm an endpoint exists in the shape you expect.

Every /v1 and /oauth response carries an x-request-id, failures included, and the platform's request log is keyed on it. Find the request there, then mail developers@tabbio.com with that id, the endpoint and the time in UTC. It is the first thing support asks for and it saves a round trip. Never send a token, a client secret or an API key.