Errors & rate limits
The error envelope
Every error returns the same JSON shape, with a stable machine-readable code and a human-readable message:
{
"error": {
"code": "not_found",
"message": "The requested resource could not be found."
}
}Branch on code, not on message. Messages are written for humans and may be reworded; codes are part of the contract.
Validation failures add an errors object keyed by field name, each holding an array of messages:
{
"error": {
"code": "validation_error",
"message": "The given data was invalid.",
"errors": {
"destination_url": ["The destination url field must be a valid URL."],
"expires_at": ["The expires at field must be a date after now."]
}
}
}Always send Accept: application/json. Without it, some framework-level
failures can be rendered as HTML rather than the envelope above.
Status codes
Success
| Status | When |
|---|---|
200 OK | A successful read or update |
201 Created | A resource was created |
202 Accepted | Work was accepted but completes asynchronously — domain verification |
204 No Content | A successful delete; the body is empty |
Errors
| Status | code | Meaning |
|---|---|---|
401 Unauthorized | unauthenticated | No API key, or a key that has been revoked or expired |
402 Payment Required | payment_required | The account has no active plan |
403 Forbidden | forbidden | The key is valid but lacks the scope this endpoint requires |
404 Not Found | not_found | No such resource — or it belongs to another account |
405 Method Not Allowed | method_not_allowed | Wrong HTTP verb for that path |
422 Unprocessable Entity | validation_error | The request body failed validation; see errors |
429 Too Many Requests | rate_limited | The rate limit was exceeded |
500 Internal Server Error | server_error | Something went wrong on our side |
Other HTTP failures fall back to the generic code http_error.
Codes in practice
401 vs 403
These two are easy to confuse and mean quite different things:
401 unauthenticated— we could not identify you at all. TheAuthorizationheader is missing, malformed, or the key has been revoked or has expired. Check the credential.403 forbidden— we know exactly who you are, and this key is not allowed to do that. The key was issued without the required scope. Generate a new key with the scope included; scopes cannot be added to an existing key.
402 payment_required
The API requires an active plan. This check runs after authentication, so a bad key returns 401 even on an unsubscribed account.
Access is granted for a lifetime purchase, a running trial, or a live subscription. A subscription that has lapsed into past_due — after a card failure, for example — returns 402 until billing is resolved.
404 not_found
Returned both for a resource that does not exist and for one that exists on another account. The API deliberately does not distinguish between the two, so it cannot be used to probe for valid ids belonging to other customers.
If a 404 is unexpected, check that you are using the resource’s UUID and not a numeric id or a slug.
422 validation_error
The most common cause of a 422 on writes:
| Field | Common cause |
|---|---|
slug | Already taken. Slugs are unique across all of Link Squeeze, not just your account. |
hostname (links) | Not a domain registered to your account. |
hostname (domains) | Not a valid hostname, or already registered on Link Squeeze. |
destination_url | Missing on a PATCH that also sends utm_params — the two must be sent together. |
expires_at | Not in the future. |
pixel_ids.* | A pixel UUID not registered to your account. |
platform | Not one of facebook, tiktok, pinterest, google_ads. |
url (webhooks) | Not HTTPS, or resolves to a private or loopback address. |
event_types.* | Not one of link.created, link.clicked, pixel.fired. |
One endpoint does not use the envelope. When POST /v1/domains rejects a
hostname at the domain-parsing stage, it answers 406 Not Acceptable with a
bare message key rather than the error object:
{ "message": "not-a-domain is not a valid domain name. Ensure your domain name is valid" }Handle a 406 from that endpoint as a validation failure. Everything else in
the API uses the standard envelope.
Rate limits
Requests are limited to 60 per minute, counted per API key.
Two keys on the same account get independent budgets, and keys are never throttled by unrelated traffic sharing an egress IP address — which matters if you call the API from a NAT gateway, a CI runner, or a serverless platform.
Reading your budget
Successful responses carry the current state of your bucket:
| Header | Description |
|---|---|
X-RateLimit-Limit | Requests allowed per minute — 60 |
X-RateLimit-Remaining | Requests left in the current window |
When you exceed it
{
"error": {
"code": "rate_limited",
"message": "Too many requests."
}
}The window is a rolling minute. Back off and retry — an exponential backoff starting around one second, with jitter, is a reasonable default.
Staying under the limit
- Page in bulk. Use
per_page=100on list endpoints rather than making seven times as many calls at the default of 15. - Prefer
/statsover/clickswhen you only need counts. One aggregated call replaces paging through thousands of individual clicks. - Use webhooks instead of polling. Subscribing to
link.clickedremoves the need to poll for new clicks at all, which is what most rate-limit pressure turns out to be. - Serialise your workers. If several processes share one key, they share one 60/minute budget. Give each its own key, or put them behind a single rate-limited queue.