Errors

Every error response from the Curviate API uses the same flat JSON envelope. Machine-readable codes let your agent handle failures programmatically.

Error envelope

{
  "code": "UNAUTHORIZED",
  "message": "Valid API key required. Use Authorization: Bearer cvt_live_<key>.",
  "retry_hint": null,
  "user_fixable": true,
  "retry_likely_to_succeed": false
}

The error body is a flat JSON object; there is no wrapper "error" key. The top-level properties are code, message, retry_hint, user_fixable, and retry_likely_to_succeed.

FieldTypeAlways presentDescription
codestringYesMachine-readable error code from the taxonomy below
messagestringYesHuman-readable description
retry_hintobject or nullYesnull when no retry guidance; otherwise {"kind":"delay","delay_ms":N}, {"kind":"backoff"}, or {"kind":"never"}
user_fixablebooleanYestrue when the caller can resolve the error (for example fix a bad parameter, add a seat)
retry_likely_to_succeedbooleanYestrue when an unchanged retry may succeed and is safe to try (for example a rate-limit window that will expire, or a read that failed for a reason the API cannot see). false means do not retry the request unchanged: either it fails the same way again, or a retry could duplicate a write. An unexpected failure on a write, including an upstream failure whose outcome is unknown, says false, because the write may already have landed; read the resource back before re-sending. Two exceptions, both billing writes where a retry cannot charge twice: a failed checkout says true when the failure may clear (a network or payment-provider outage, or an unexpected server error), because a checkout session charges nothing until it is completed; and a failed switch to annual billing says true for a network or payment-provider outage, but false for an unexpected server error, because a partial success leaves the switch in place

Some errors add optional keys. errors (on SEQUENCE_INVALID and MOTION_NOT_STARTABLE) is an array of {path, rule} entries, one per failure. merged_into (on a 404 RESOURCE_NOT_FOUND for a Contact id that was merged into another Contact) is the id that Contact now lives under. reason (on CONTACT_NOT_ENROLLABLE) says why a Contact was not enrolled. feature, required_tier, current_tier and upgrade_url (on TIER_REQUIRED) name the feature, the Tier that includes it, the team's Tier, and for a team Owner where to upgrade. All of these are absent on every other error.

There is no retry_after_ms field

Wait time arrives in two places and neither is called retry_after_ms: the Retry-After response header (IETF, seconds), and the optional retry_hint.delay_ms in the body (milliseconds, snake_case). Reading a field that does not exist yields undefined, and arithmetic on it yields NaN, which typically collapses your backoff to zero and retries straight back into an active limit. The TypeScript SDK exposes the same value camelCased, as err.retryHint?.delayMs.

HTTP status codes

StatusMeaning
400Bad request. Invalid parameters or body.
401Unauthorized. Missing or invalid API key.
402Payment required. No active subscription or seat, a past-due or disputed subscription, a failed payment, or an expired trial.
403Forbidden. Valid key, but no active seat covers the account, the LinkedIn subscription the operation needs is missing, the workspace has not opted into beta operations, the team's Tier does not include the feature, or the account lacks permission on the target.
404Not found. The resource does not exist, or is not yours.
409Conflict. The request collides with existing state (already linked, already in progress, duplicate invitation).
413Payload too large. The request body exceeds a size limit. Three things trigger it, smallest first: the platform's own ceiling on total request size, which rejects bodies from roughly 1 MB and is the common case with images; then the caps this API enforces before the call, 5 MiB per attachment and 9 MiB for the whole request body.
415Unsupported media type. Wrong Content-Type header.
422Unprocessable. The request is well-formed, but LinkedIn will not perform it in the account's current state, or this API cannot answer it under the retrieval mode you asked for.
429Rate limited. Quota exceeded (see Rate limits).
500Internal error. Unexpected server-side failure. On a read, safe to retry with backoff. On a write it carries retry_likely_to_succeed: false, because the write may already have landed: read the resource back before re-sending.
501Not implemented. LinkedIn does not offer this operation for this account type.
502Upstream failure. LinkedIn returned something unusable. Read retry_likely_to_succeed before retrying: some 502s never succeed on retry, and on a write an upstream failure whose outcome is unknown says false, because the write may already have landed.
503Service unavailable. Temporary issue, safe to retry.
504Upstream timeout. Safe to retry with backoff.

422 and 502 are the two statuses integrators most often forget to handle. 422 carries most of the "LinkedIn said no" outcomes, and 502 is where an upstream failure surfaces, not 500.

Error codes

Always write a default branch

The tables below cover the codes you are most likely to meet, not all of them, and the taxonomy is explicitly additive: new codes are appended over time and existing ones are never removed. A switch over error.code with no default will silently fall through on a code added after you shipped. Branch on the codes you handle, and treat everything else as "unknown failure, surface it".

Authentication and request validation

CodeHTTPDescription
UNAUTHORIZED401API key missing, malformed, or revoked
INVALID_REQUEST400Request body or parameters failed schema validation. The message names the offending field. A body field the operation does not declare is rejected here too, never accepted and quietly discarded, so a filter you send is either applied or reported; operations that reject unknown keys carry additionalProperties: false in the OpenAPI spec. A common cause is a query parameter such as limit or cursor placed in the JSON body. On a structured search a filter value that matches no filter option is NOT an error: it is sent on as an id and reported in notices[] on the 200. The Draft attachment upload answers this code with HTTP 411 when Content-Length is missing (a chunked upload is not accepted), and with 400 when the body did not carry exactly its Content-Length; nothing is kept then.
FILTER_CANDIDATES_REQUIRED422A structured search filter value matched several options, so it cannot be resolved to one id without your choice. unresolved[] lists each offending field with its candidates, and next_action says what to do. Re-send with a chosen id. See Search.
UNSUPPORTED_MEDIA_TYPE415Content-Type header is missing or not application/json. On the Draft attachment upload (POST /v1/drafts/{id}/attachments), which takes the raw file instead, it must be the file's own type (video/mp4, application/pdf, image/jpeg, image/png, image/gif or image/webp).
PAYLOAD_TOO_LARGE413Three limits can trigger this. The platform's own ceiling on total request size is the lowest and the one you are most likely to hit: it starts rejecting at roughly 1 MB, so a handful of photo-sized images is refused even though it clears everything below. Above that, this API caps one attachment at 5 MiB of file content and the whole request body at 9 MiB. Attachments travel base64-encoded, which inflates them by about a third, so a 5 MiB file costs roughly 6.7 MiB of the body budget. Send fewer or smaller attachments; retrying unchanged will not help. A larger video or PDF goes to a Draft with the attachment upload instead (50 MiB per file), which answers this code for a file over its type's limit.

Request validation runs before every entitlement check, so an INVALID_REQUEST says nothing about entitlement. A malformed body is rejected while the seat, the LinkedIn subscription and beta consent are all still unexamined, so a 400 is never evidence that your entitlement is the problem: fix the request and send it again. The reverse is the more useful half, and it is the one worth relying on: a 403 carrying one of the three entitlement codes below proves the request itself parsed and validated cleanly, so there is nothing to fix in the body.

Account state

CodeHTTPDescription
ACCOUNT_NOT_FOUND404The account_id does not exist or does not belong to this tenant
RESOURCE_NOT_FOUND404A non-account resource (chat, message, invitation, webhook) was not found. Also what a mistyped path returns, so check the URL shape before you check your ids.
NOT_FOUND404This API serves no route at that path at all, which is a different fact from a resource being absent: nothing was looked up. Check the path shape and the version prefix before you check your ids.
ACCOUNT_RESTRICTED422The account exists but LinkedIn is restricting it from performing this operation
RESOURCE_ACCESS_RESTRICTED403The account lacks admin or equivalent permission on the target (for example a company page it does not administer)
REAUTH_REQUIRED409The stored session cannot be replayed for this change of scope, or a disconnected account's location cannot change in place, or a Draft cannot be put on a disconnected account. Re-authenticate (with country to move it), or choose another account for the Draft.
CONNECTION_LOCATION_REQUIRED400A new connect names no location. Send exactly one of country, ip or proxy. Also returned by a PATCH that sends proxy: null without country.
CONNECTION_LOCATION_UNAVAILABLE422No connection is free in that country right now (the location is strict), or a location PATCH did not land there; the PATCH body then carries connection_location. Try a nearby country, or send allow_country_fallback: true.

Reads and retrieval

CodeHTTPDescription
NOT_STORED422The read asked for a stored copy and nothing else (mode=cache_only), and nothing is stored for that resource. This is not the same fact as RESOURCE_NOT_FOUND: the resource may exist perfectly well on LinkedIn, and this API simply holds no copy of it, so re-checking the id is the wrong move. Re-read with mode=refill to fetch it once, or mode=auto to fetch it now. You will not see this code when the store itself could not be read: that is a retryable 502, because "we hold nothing" and "we could not look" are different answers and only one of them is worth retrying.

Entitlement

Three separate refusals, all 403, all user_fixable, and fixed in three different places. Branch on the code, never on the message.

A seat names no LinkedIn product. Any paid seat covers the whole LinkedIn interface, Sales Navigator and Recruiter included. Nothing on a seat names a product, and none of these refusals asks you to buy a different kind of seat. What a Team's subscription buys is its Tier, which covers every one of its seats: read it with GET /v1/billing/tier.

CodeHTTPDescription
NO_ACTIVE_SEAT403The targeted account is not on an active seat. Buy or attach a seat, then retry.
LINKEDIN_FEATURE_NOT_SUBSCRIBED403The LinkedIn account itself does not have the premium feature the endpoint requires (InMail, Sales Navigator, Recruiter). Distinct from NO_ACTIVE_SEAT, which is the Curviate-side seat gate.
TIER_REQUIRED403The team's Tier does not include the feature the operation belongs to (for example Motions, which need the Automate Tier, called by a team on Build). The error adds feature, required_tier and current_tier (null when the team has no Tier), plus upgrade_url when the caller is a team Owner. A team Owner upgrades under Settings, Billing; anyone else asks an Owner. Decided before the request body is validated, and an unchanged retry is refused identically. Reads, export, delete and stop in the same group still work. In the API reference these operations carry a Tier badge.
BETA_NOT_ENABLED403The operation is beta-gated and this team has not opted into beta operations. Turn on "Allow beta operations" under Settings › API & Webhooks, or send X-Curviate-Beta: true on the request. Distinct from both codes above: nothing is missing from the seat or from LinkedIn, and the fix is entirely yours. An unchanged retry is refused identically.
TEAM_ACCESS_DENIED403The request names a team (X-Curviate-Team) this credential cannot act in. An API key or an authorized application works in exactly one team, and a dashboard session only in teams you are a member of. Drop the header, or use a credential issued for that team.
INSUFFICIENT_ROLE403Your role in the team does not allow this action. Ask a team Owner to do it or to change your role.
ACCOUNT_NOT_SHARED403The person who connected this account turned team access off for it, so only they can use it. Ask them to turn team access on, or use another account.
ACCOUNT_OWNER_ONLY403Only the person who connected the account can do this (reconnect it, solve its checkpoints). Ask them to do it with their own credentials.

Rate limits

CodeHTTPDescription
RATE_LIMIT_TENANT429The per-tenant rate limit was exceeded. Wait for the Retry-After header. A Contacts or Motions write also answers it, with retry_hint: backoff and no Retry-After, when other writes of your team (an import, a bulk update, a wide burst) kept it waiting for five seconds: nothing was written, retry shortly.
RATE_LIMIT_ACCOUNT429The per-LinkedIn-account rate limit was exceeded. Wait for the Retry-After header.
PLATFORM_RATE_LIMIT429A platform-level limit was reached. Back off for Retry-After, or a minimum of 60 seconds.
LINKEDIN_RATE_LIMITED429LinkedIn is rate-limiting this account directly. Back off substantially before retrying.
BUDGET_EXHAUSTED429Not a rate limit. An account-safety ceiling you configured is spent, or the account is outside the hours it works in. Nothing reached LinkedIn and nothing was spent, so backing off is the wrong recovery: the body carries row, reason (ceiling or activity_window), reset_at and hint.parameter, the exact setting that lifts it. When reset_at is null and what to do instead are documented in one place, Account safety. On the default posture this is not an error at all: the action goes through and the same payload rides the 2xx body under safety_warning with blocked: false. See Account safety.

Platform and upstream errors

CodeHTTPDescription
PLATFORM_ERROR502An upstream failure. Retrying after a short backoff is usually likely to succeed. Note the 502, not 500. Branch on the response, not on this row: retry_hint and retry_likely_to_succeed are per-response and authoritative. Three outcomes carry this code and must never be retried. Two are writes that may already have landed, where re-sending would duplicate them: an invitation the platform acknowledged without confirming, and a message whose send returned no usable identifier. Read the sent invitations or the conversation instead of re-sending. The third is a read whose upstream answered in a shape this API could not interpret, which every retry answers exactly the same way until a fix ships; report it rather than backing off. All three arrive with retry_likely_to_succeed: false and retry_hint: { kind: "never" }. Separately, an upstream failure on a write whose outcome is unknown arrives with retry_likely_to_succeed: false (no never hint): read the resource back before re-sending. On POST /v1/billing/seats/add, a failure after a charge that was then refunded in full, with the seat count confirmed unchanged, arrives with retry_likely_to_succeed: true: no seats were added, and the message says so; retrying is safe. When either cannot be confirmed, the add arrives as INTERNAL with false instead.
PLATFORM_NOT_IMPLEMENTED501The operation is not offered for this account type or platform tier.
LINKEDIN_OPERATION_NOT_SUPPORTED422LinkedIn structurally disallows this operation or parameter combination, for everyone. Not retryable, and not fixable by subscribing.
LINKEDIN_SERVICE_UNAVAILABLE503LinkedIn is temporarily unavailable. Retry after a backoff.
SUBSTRATE_LINK_FAILED502Connecting the LinkedIn account failed upstream rather than being refused. Read retry_likely_to_succeed before re-sending: a connect that may have partly landed is not safe to repeat blindly, so read the account back first.
SUBSTRATE_CAP_REACHED503A capacity ceiling on the connection infrastructure, not on your plan. Nothing about the request is wrong; retry after a backoff, and if it persists it is ours to fix.
BILLING_CHECKOUT_FAILED502The payment provider failed while opening a checkout or schedule. No charge was made. Retry after a short backoff.
BILLING_PORTAL_UNAVAILABLE503The billing portal could not be reached. Nothing changed; retry after a short backoff.
INTERNAL500An unexpected internal failure. On a read, safe to retry with exponential backoff. On a write it arrives with retry_likely_to_succeed: false, because the write may already have landed: read the resource back before re-sending. Also served for any failure whose own code is outside this taxonomy.

Account connection (checkpoint)

These codes appear during the account-connect flow (POST /v1/auth/intent, POST /v1/auth/checkpoint/solve).

CodeHTTPDescription
CHECKPOINT_NOT_FOUND404No active checkpoint exists for this account.
CHECKPOINT_EXPIRED422The checkpoint has expired. Restart the connection flow.
CHECKPOINT_INVALID_CODE422The submitted verification code was incorrect.
CHECKPOINT_MAX_ATTEMPTS429Too many incorrect code attempts. Restart the connection flow.
CHECKPOINT_ALREADY_RESOLVED409The checkpoint has already been resolved.
CHECKPOINT_UNSUPPORTED400This challenge type cannot be resolved through the API (for example a CAPTCHA).
CONNECTION_IN_PROGRESS409A connection attempt for this LinkedIn account is already open. Wait for it to finish or expire before starting another.
ACCOUNT_ALREADY_LINKED409This LinkedIn identity is already linked. When your tenant already owns it, the error names your own account_id; re-authenticate that account in place rather than linking again. Otherwise no id is named, because the identity is not yours to act on. A connect that resolves by reactivating an account you had previously disconnected returns that account with recovered: true instead of this error.
ACCOUNT_LINKING_DISABLED403Account linking is disabled on this environment.

LinkedIn session errors

CodeHTTPDescription
LINKEDIN_AUTH_FAILED401LinkedIn rejected the credentials. Verify email and password, then retry.
LINKEDIN_COOKIE_INVALID401The li_at cookie is expired or invalid. Re-export it from your browser.
LINKEDIN_SESSION_EVICTED401Someone signed into this LinkedIn account somewhere else, and LinkedIn allows only one session at a time for it. A person has to close the other session; reconnecting the account will not help while it is open, and retrying will not either. While it lasts, account_states on GET /v1/accounts/{account_id} carries recruiter_session_evicted, and it stays there until the account is reconnected.

Messaging and engagement

CodeHTTPDescription
MESSAGE_WINDOW_EXPIRED422The edit or delete window has closed. The message is final; do not retry.
RECIPIENT_UNREACHABLE422The recipient cannot receive a message from this account (no shared connection, privacy settings).
CONNECTION_REQUEST_CONFLICT409A request to this member is already pending, or you are already connected. Never retry this: a send-withdraw-resend loop is exactly the pattern that gets an account flagged.
REACTION_NOT_FOUND422No reaction of that value exists to remove. The post exists; your reaction on it does not.

Drafts

CodeHTTPDescription
DRAFT_LIMIT_REACHED422The account already holds 50 Drafts, or your Drafts with no account do. Each account, and "no account", is its own bucket. Delete or publish a Draft in that bucket first.
MEDIA_QUOTA_EXCEEDED422The media would take the bucket (one account, or no account) past its 2 GiB quota. Nothing was stored. Remove media from other Drafts in that bucket first.
ACCOUNT_REQUIRED422Publishing or scheduling needs an account and this Draft has none (or a scheduled Draft would lose its account). Set account_id with PATCH /v1/drafts/{id} first, or unschedule it before removing the account.
DRAFT_NOT_PUBLISHABLE422The Draft has a content gap, for example empty text. The message starts with the field to fix. The Draft is unchanged.
DRAFT_PUBLISHING409A publish of this Draft is in flight. Read it again in a few seconds: it is gone if the publish succeeded, unchanged if it failed.
SCHEDULE_CONFLICT422Another scheduled Draft on the same account is less than 5 minutes from this time. The message names that Draft and its time. Pick a time at least 5 minutes away, or move the other Draft.

Contacts

CodeHTTPDescription
CONTACT_LIMIT_REACHED422The request would take your team past a cap: Contacts, Contact lists, Tags, or the Tags on one Contact. Delete some first. An existing Contact is still updated at the cap; only a new one is refused.
NAME_TAKEN409Another Tag or Contact list already has this name. Names compare without regard to case. Pick another name, or use the existing one.
IMPORT_QUEUE_FULL429Five Contact imports are already queued or running. Wait for one to finish (a few minutes), then send the import again; retry_likely_to_succeed is true.
RESOURCE_IN_USE409A Motion that is not completed or archived still uses this Tag or Contact list, so it cannot be renamed (Tag) or deleted; or a Contact import that is still queued or running names it, so it cannot be deleted yet. The message names the Motion or import ids. Remove it from those Motions or archive them, or wait for the import to finish, then try again.

Motions

CodeHTTPDescription
SEQUENCE_INVALID422The Sequence document breaks one or more rules. errors[] lists every failure as {path, rule} in step order, and the message repeats the first. Fix them all and send the document again; nothing was saved.
MOTION_NOT_STARTABLE422The Motion cannot start or resume yet. errors[] lists every gap as {path, rule}, for example a missing Contact list, sending account, timezone or Sequence. Fix them all, then call start or resume again.
MOTION_STATUS_CONFLICT409The operation does not apply in the Motion's current status. The message names the status and the operations it allows. Only a draft can be deleted; archive any other Motion. An archived Motion is read-only.
ENROLLMENT_NOT_ACTIVE409The Enrollment's status does not take this operation. Stop needs pending, active or paused; signal needs active or paused; resume needs active, paused or a finished Enrollment that can continue. The message names the current status.
CONTACT_NOT_ENROLLABLE422Adding a Contact to a Motion from a chat was refused. reason says why: already_enrolled, in_exclusion_list, in_other_motion or filtered_out. Nothing was enrolled.

Billing and seats

CodeHTTPDescription
PAYMENT_REQUIRED402Two distinct causes share this code. No active subscription or available seat: buy or add one. A past-due subscription: update your payment method to resume.
PAYMENT_FAILED402A payment attempt failed (for example a declined card on an add-seats charge). No seats were added. Update your payment method and try again.
SUBSCRIPTION_BUSY503The subscription is being modified concurrently. Retry after a short delay; retry_likely_to_succeed is true.
SUBSCRIPTION_NOT_FOUND404No subscription record exists for this tenant.
SEAT_NOT_FOUND400The referenced seat does not exist or is not yours.
SEAT_NOT_EMPTY400The seat already holds a connected account. Disconnect it first, or pick a seat with none attached.
SEAT_PROVISIONAL400The seat is not fully provisioned yet. It is not an error you fix, only one you wait out; re-read the seat and retry.
SEAT_CANCELLED403The referenced seat has been cancelled.
SUBSCRIPTION_ALREADY_EXISTS409This tenant already has a subscription, so there is nothing to create. Modify the existing one instead.
ALREADY_CANCELLED400The seat or subscription is already cancelled, so the cancellation has nothing to do.
CANCELLATION_ALREADY_EFFECTIVE400The cancellation has already taken effect and cannot be changed. Distinct from ALREADY_CANCELLED, which is a pending cancellation.
INVALID_CANCELLATION_SOURCE400The source value on a cancellation is not one this API accepts. A rejected field value, so correct it and re-send.
PERIOD_LOCKED400The current billing period is locked against this change. Waiting for the period to roll is the only remedy; retrying sooner is refused identically.
ACCOUNT_DISPUTED402A payment on this workspace is disputed, so operations that spend or provision are refused until it is resolved. Same family as PAYMENT_REQUIRED. Contact support to resolve it.
ADMIN_BYPASS400This workspace does not have billing enabled, so billing operations do not apply to it. Not something to fix in the request: this workspace type simply has no billing surface.
STRIPE_DRIFT_DETECTED503A checkout, seat add, seat cancellation revert or annual switch is refused because the seat price configured on our side does not match the price we display, so completing it would bill an amount you were never shown. Nothing was charged. Not something you can fix, and retrying is refused identically until we correct the price, so stop and contact support rather than backing off. user_fixable and retry_likely_to_succeed are both false.

Free trial

CodeHTTPDescription
TRIAL_EXPIRED402The trial seat has expired. Buy a seat to continue.
TRIAL_SEAT_LIMIT409Every trial seat is already occupied.
TRIAL_ACTIVE_SEAT_LIMIT409Seats cannot be added, toggled, or cancelled while trialing. Buy a subscription first.
TRIAL_IDENTITY_ALREADY_USED409That LinkedIn identity has already been used for a trial.
TRIAL_IDENTITY_UNRESOLVED422The trial could not be completed because the member identity could not be resolved.

A brand-new trial customer meets the TRIAL_* codes before almost anything else, so handle TRIAL_EXPIRED and TRIAL_SEAT_LIMIT explicitly if you onboard trial users.

SDK: CurviateError

When using the TypeScript SDK, every API-layer failure throws a CurviateError. The properties are camelCased: code, message, retryHint (with retryHint.delayMs), userFixable, retryLikelyToSucceed, and httpStatus.

The SDK's code union is narrower than the API's

The SDK ships a fixed union of error codes and maps anything outside it to INTERNAL before your switch sees it. Codes added to the API after an SDK release therefore arrive as INTERNAL. Read err.httpStatus and err.message alongside err.code when a failure does not match what you expected, and keep the REST envelope above as the authoritative list.

import { Curviate, isCurviateError } from "@curviate/sdk";
 
const curviate = new Curviate({ apiKey: process.env.CURVIATE_API_KEY! });
 
try {
  await curviate.accounts.list();
} catch (err) {
  if (!isCurviateError(err)) throw err;
 
  switch (err.code) {
    case "RATE_LIMIT_TENANT":
    case "RATE_LIMIT_ACCOUNT":
      // delayMs is the camelCase view of retry_hint.delay_ms
      await new Promise((r) => setTimeout(r, err.retryHint?.delayMs ?? 60_000));
      break;
    case "UNAUTHORIZED":
      console.error("Check your API key.");
      break;
    case "ACCOUNT_NOT_FOUND":
      console.error("Run accounts.list() and use an id from that response.");
      break;
    default:
      // Required: the taxonomy is additive, so unknown codes will appear.
      console.error(`Unhandled ${err.code} (HTTP ${err.httpStatus}): ${err.message}`);
      throw err;
  }
}

Handling a 429

# A 429 response, rate limit exceeded
HTTP/2 429
Retry-After: 37
ratelimit: "tenant";r=0;t=37
ratelimit-policy: "tenant";q=2500;w=60
Content-Type: application/json
 
{
  "code": "RATE_LIMIT_TENANT",
  "message": "Tenant rate limit exceeded.",
  "retry_hint": { "kind": "delay", "delay_ms": 37000 },
  "user_fixable": false,
  "retry_likely_to_succeed": true
}

Read the wait from Retry-After (seconds) or from retry_hint.delay_ms (milliseconds); they carry the same value in different units. Retry-After is the canonical one, and it is present on every 429.

const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
 
if (res.status === 429) {
  const header = res.headers.get("retry-after");
  const body = await res.json();
  const waitMs =
    (header ? Number(header) * 1000 : undefined) ??
    body.retry_hint?.delay_ms ??
    60_000;
  await new Promise((r) => setTimeout(r, waitMs));
}
Rate-limit headers need an authenticated request

ratelimit and ratelimit-policy are returned on every authenticated response. A 401 carries none, because the limiter runs after authentication. Do not treat their absence as "no limit applies"; treat it as "this request never authenticated".

Next steps