Reference

Errors & Limits

The response envelope, every status code we return, and how throttling behaves.

The response envelope

Every response has the same shape, whether it succeeded or not. Branch on the HTTP status, read message for a human, read error for a machine.

  • success is always present and always a boolean.
  • data appears only on success. Every failure carries error instead. They never both appear.
  • error is an object with a code, except on a validation failure, where it is an array of { field, code, message } with exactly one entry per offending field. There code is required or invalid_format.
  • message is written for a human reading a log. Do not match on it. Match on error.code, which is stable.
{
  "success": true,
  "message": "Tracking link created",
  "data": { "id": 90124 }
}

Status codes

StatusMeaningWhen you see it
200OK

The request succeeded. Read data. Also returned when creating a tracking link that already exists.

201Created

A new object exists. data carries it, including its new id.

202Accepted

Queued for background work. Webhook test events and delivery replays return this.

400Bad Request

Malformed JSON or a request that failed validation.

401Unauthorized

Missing, malformed, paused, expired or revoked API key. Not a scope problem.

403Forbidden

The key is valid but lacks the scope or is outside its IP allowlist, or the channel or program is not in a state that allows the write.

404Not Found

No such object, or one your account cannot see. We do not distinguish the two, on purpose. Also an unknown route.

409Conflict

The write clashes with what already exists: a second application for the same program and channel, a second channel for one domain, a sixth webhook endpoint.

413Payload Too Large

The request body is over 1 MB.

422Unprocessable

Well-formed and authorised, but the domain rules say no: a deep link off-domain, a webhook URL that is not public https, a verification tag we could not find.

429Too Many Requests

Rate limited. Read Retry-After and back off.

500Server Error

Our fault. Safe to retry an idempotent request; open a ticket if it persists.

Error codes

error.code is the contract. It is stable across releases, and it is what your retry and alerting logic should branch on.

Code · status
malformed_json400

The request body is not valid JSON.

invalid_api_key401

The key does not exist, is paused, expired or was revoked.

insufficient_scope403

Valid key, missing scope. The body names the scope that was required.

ip_not_allowed403

The calling address is outside the key's allowlist. error.ip is the address we saw, often IPv6.

route_not_found404

No endpoint matches the method and path. Check for a missing /v1 or a typo.

program_not_found404

Unknown program ID, or one not available to your account.

channel_not_found404

The channelId does not exist or is not one of yours.

tracking_link_not_found404

The tracking link does not exist or is not one of yours.

withdrawal_not_found404

The withdrawal does not exist or is not one of yours.

webhook_not_found404

The webhook endpoint does not exist, was deleted, or is not one of yours.

delivery_not_found404

No delivery with that ID belongs to this webhook endpoint.

program_not_approved403

There is no approved application for this program on the channel you passed.

channel_not_verified403

The channel is not approved, or has a verification step it has not completed, so it cannot apply or carry links.

duplicate_application409

This program and channel pair already has an application, whatever its status, rejected included. error carries applicationId and status. Re-applying is not possible through the API; contact support.

duplicate_channel409

You already have a channel for this domain.

verification_not_required409

The channel's category has no verification step. Social, email and other channels are reviewed by hand.

webhook_limit_reached409

The account already has five webhook endpoints. Delete one first.

delivery_not_replayable409

The delivery is still queued or retrying. Only succeeded or failed deliveries can be replayed.

payload_too_large413

The request body is over 1 MB.

destination_not_allowed422

The program does not support deep links, or destinationUrl is not on the merchant's domain or a subdomain of it. error.merchantDomain names the domain.

verification_failed422

We fetched the channel URL but did not find the verification meta tag, or the page could not be fetched.

url_not_allowed422

The webhook URL is not https on port 443, carries credentials, or resolves to a private address.

rate_limited429

Too many requests in the window. Retry-After tells you when.

quota_exceeded429

The account has used its monthly quota. Retry-After counts down to the 1st (UTC).

internal_error500

Something failed on our side. Safe to retry an idempotent request with back-off.

{
  "success": false,
  "message": "This key is missing the links:write scope",
  "error": {
    "code": "insufficient_scope",
    "required": "links:write",
    "granted": ["programs:read", "reports:read"]
  }
}

Rate limits

Limits are per account, shared by all of its API keys: 120 requests per minute in a fixed one-minute window, against a monthly quota of 250,000 calls. Every request that gets past authentication carries the X-RateLimit-* headers, and the X-Quota-* headers unless it was rate limited. A 401 or an ip_not_allowed 403 carries neither.

  • X-RateLimit-Reset is a Unix timestamp for when the per-minute window clears.
  • X-Quota-Used is the month's count before the current request; X-Quota-Limit is the monthly quota.
  • Retry-After appears only on a 429 and is in seconds. Honour it rather than guessing.
  • Exceeding the monthly quota returns 429 until the first of the next month (UTC), with error.code set to quota_exceeded.
  • Calls refused with a 401, an ip_not_allowed 403 or any 429 do not count towards the quota. Every other authenticated call does, including ones that fail with a 4xx.
  • The click redirect at go.linkapprove.com allows up to 50 requests per second per visitor IP, a ceiling real visitors never reach.
HTTP/1.1 200 OK
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 103
X-RateLimit-Reset: 1755511260
X-Quota-Used: 128402
X-Quota-Limit: 250000

Retries and idempotency

What is safe to retry

  • Every GET is safe. Retry freely.
  • 429 and 5xx are safe to retry with back-off, because the request either did not run or did not complete.
  • 4xx other than 429 will fail identically on retry. Fix the request instead.

Is it us or you?

GET /v1/health needs no key, is not rate limited and does not count towards the quota. A 200 with "API is healthy" means the API is up, so a failure elsewhere is about the request or the key.

Writes are naturally idempotent

Applying twice for the same program and channel returns 409 rather than creating a duplicate, and minting a link with an identical program, channel, destination URL, subId and subId2 returns the existing link with 200 instead of 201. A retried write will not double up.
async function callWithRetry(request, attempts = 4) {
  for (let attempt = 0; attempt < attempts; attempt++) {
    const res = await request();
    if (res.status !== 429 && res.status < 500) return res;

    const retryAfter = Number(res.headers.get("Retry-After"));
    const backoff = retryAfter
      ? retryAfter * 1000
      : Math.min(2 ** attempt * 500, 8000);

    await new Promise((resolve) => setTimeout(resolve, backoff));
  }

  throw new Error("Gave up after retries");
}