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:
{
"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:
{
"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:
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
| Code | HTTP | Meaning |
|---|---|---|
OAUTH_INVALID_REQUEST | 400 | A required parameter is missing or malformed. |
OAUTH_INVALID_CLIENT | 401 | Unknown client id, or client authentication failed. |
OAUTH_INVALID_GRANT | 400 | The code or refresh token is expired, already used, issued to another client, or the PKCE verifier does not match. |
OAUTH_INVALID_SCOPE | 400 | You asked for a scope the app is not allowed to request, or tried to widen a grant on refresh. |
OAUTH_UNAUTHORIZED_CLIENT | 400 | The app may not use this grant type. |
OAUTH_UNSUPPORTED_GRANT_TYPE | 400 | Only authorization_code and refresh_token exist. |
OAUTH_ACCESS_DENIED | 403 | The user declined on the consent screen. |
OAUTH_INVALID_TOKEN | 401 | The access token is missing, expired, or revoked. |
OAUTH_INSUFFICIENT_SCOPE | 403 | The 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
| Code | HTTP | Meaning |
|---|---|---|
API_KEY_INVALID | 401 | The API key is missing, expired, or revoked. |
API_KEY_INSUFFICIENT_SCOPE | 403 | The key is valid but does not carry the scope this endpoint needs. |
COMPANY_NOT_MANAGED_BY_APP | 403 | The company, job or application belongs to a company your app did not create. |
PUBLIC_API_VALIDATION_ERROR | 400 | The body failed validation, or a job did not satisfy the publish contract. The message names the field. |
JOB_NOT_FOUND | 404 | No job with this id. |
APPLICATION_NOT_FOUND | 404 | No application with this id. |
CV_NOT_FOUND | 404 | The account has no CV to share yet. |
CV_LINK_NOT_FOUND | 404 | The durable CV link is unknown, was revoked, or the user disconnected the app. |
CONNECTION_NOT_FOUND | 404 | The connection behind this token is gone. |
DEVELOPER_PLATFORM_DISABLED | 404 | The 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
| Field | Type | Description |
|---|---|---|
| 429 | Retry | Back off and try again, after |
| 500, 502, 503, 504 | Retry | Exponential backoff with jitter, a handful of attempts, then give up and alert. |
| 408, network timeout | Retry carefully | The request may have succeeded. Use an idempotency key on writes. |
| 400, 401, 403, 404, 422 | Do not retry | Nothing about the same request will succeed. Fix it or surface it. |
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.
| Plan | Requests per minute | Requests per day | Who gets it |
|---|---|---|---|
| Standard | 300 | 20,000 | Every developer account starts here. No request needed. |
| Elevated | 1,000 | 200,000 | Granted on request once your integration is live. |
| Custom | Set by Tabbio | Set by Tabbio | Agreed 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.
| Header | What it carries |
|---|---|
x-ratelimit-limit | Requests allowed in the current minute. |
x-ratelimit-remaining | Requests left in the current minute. |
x-ratelimit-reset | Epoch seconds at which the minute window resets. |
x-quota-limit | Requests allowed in the current UTC day. |
x-quota-remaining | Requests left today. |
x-quota-reset | Epoch seconds at the next UTC midnight. |
x-request-id | On every /v1 and /oauth response, failures included. The platform's request log is keyed on it, and support asks for it first. |
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.
| Code | HTTP | Meaning |
|---|---|---|
RATE_LIMIT_EXCEEDED | 429 | You are over the per-minute limit for your developer account. meta.retryAfter is the seconds to wait. |
QUOTA_EXCEEDED | 429 | You have spent the daily quota for your developer account. meta.retryAfter counts down to the next UTC midnight. |
DEVELOPER_ACCOUNT_SUSPENDED | 403 | The 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.
| Surface | Burst | Sustained | Counted against |
|---|---|---|---|
/oauth/token, /oauth/revoke, /oauth/introspect | 20 requests per 10 seconds | 1200 per hour | Your client id, falling back to the client IP |
/v1/* with an API key | 20 requests per second | 3000 per minute | Your app |
/v1/me* with an access token | 20 requests per second | 3000 per minute | The connection |
GET /v1/cv-links/{token} | 60 requests per second | 2000 per minute | Client 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 ametawithtotalPages. - 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
applicationCountbefore 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.