API Errors
Reference for every error code and HTTP status the Codelloy API returns.
Response Envelope
Every error response uses the standard envelope with the errors field set to a non-empty array. response is omitted on failure.
{
"errors": [
{
"code": "VE001",
"message": "url: must not be empty"
}
]
}Each error object carries:
| Field | Type | Description |
|---|---|---|
code | string | Machine-readable code (see catalogue below). |
message | string | Human-readable description. |
details | object | Optional structured payload (quota, rate-limit, deep-link validation). Field shape depends on code. |
Error Code Catalogue
| Code | HTTP Status | Meaning |
|---|---|---|
SML002 | 400 | Bean Validation failure. Fired when a @NotEmpty / @Size / @Pattern constraint on a DTO field fails. The message carries the violation text (e.g. "must not be empty"). The most common 400 you’ll see — pre-empts VE001 on the validation path. |
VE001 | 400 | Ad-hoc validation error. Two paths emit this code: (1) service-layer code that validates beyond the DTO annotations (invalid shortCode format, missing analytics mode params, date range too large, etc.), and (2) the request-body parser when the JSON body is missing or malformed (e.g. an empty PATCH body on a required-body endpoint). |
DL001 | 400 | Deep-link validation. A deepLinks[] entry’s mobileAppId is null, blank, or not registered for your organisation. See Mobile Apps. |
SML005 | 429 | Rate limit exceeded. Too many requests per minute for your plan tier. Includes a Retry-After header and a structured details payload. |
EN001 | 404 | Entity not found. Fired by direct lookup paths (e.g. GET /shortenedUrl/{id}) when the resource doesn’t exist. The list endpoint with a no-match filter returns 200 + empty data[], NOT EN001. |
EN002 | 410 | Link archived. The short URL exists but has been archived by its owner (isActive=false). Distinct from EN001 so clients can render a “this link is no longer active” surface. |
QE001 | 402 | Plan quota exceeded. FREE-tier only for the link path: you’re at the monthly LINKS_PER_MONTH allowance and the cycle hasn’t reset. Paid plans never hit this for links — they create the link and bill overage instead. Also returned for hard-cap quotas like SEATS and CUSTOM_DOMAINS on any plan. |
QE002 | 402 | Click quota exhausted. Returned by the redirect path (not save) when an organisation has hit its monthly clicks quota. FREE plans hard-pause; paid plans keep redirecting and the over-cap clicks are billed automatically on the next invoice (€2.50 per 10,000) — they are not free. |
FE001 | 402 | Feature unavailable on plan. A request used a feature the current plan doesn’t include (e.g. METADATA_PREVIEW, ANALYTICS_EXPORT). |
SML001 | 500 | Generic server error. Unhandled internal failure. Contact support if reproducible. |
SML003 | 401 / 403 | Authentication / authorisation failure. API key missing, invalid, expired, or lacking the right role. |
SML004 | 500 | Database write exception (duplicate key, constraint violation). |
HTTP Status Summary
| Status | Meaning | When |
|---|---|---|
200 | OK | Request succeeded. |
400 | Bad Request | VE001 / DL001 / SML002. |
401 | Unauthorized | Missing or invalid X-API-KEY. |
402 | Payment Required | QE001 / QE002 / FE001. |
403 | Forbidden | Valid key but insufficient role. |
404 | Not Found | EN001. |
410 | Gone | EN002 — archived link. |
429 | Too Many Requests | SML005. |
500 | Internal Server Error | SML001 / SML004 or other unexpected failure. |
Detailed Error Scenarios
Validation Errors (SML002 vs VE001)
Both codes return HTTP 400 with a descriptive message. The distinction is which validation layer rejected the request:
SML002— Bean Validation on a DTO field (@NotEmpty,@Size,@Pattern). Most “missing required field” errors land here.VE001— Service-layer ad-hoc validation that runs after deserialisation (shortCode format check, analytics mode-detection, date-range cap, etc.), AND the request-body parser when the JSON body is missing or malformed JSON. Both surface a stable user-facing message.
Integrators should branch on code, not the HTTP status — both are 400 but the operator response differs (config bug vs payload bug).
Missing required field — SML002:
{
"errors": [
{ "code": "SML002", "message": "must not be empty" }
]
}Short code invalid characters — VE001:
{
"errors": [
{ "code": "VE001", "message": "Invalid short code format. Must be 7-50 characters, alphanumeric, underscore or hyphen only." }
]
}Analytics missing mode params — VE001:
{
"errors": [
{ "code": "VE001", "message": "Must specify either browse mode (from/to) or sync mode (since) parameters." }
]
}Missing or malformed request body — VE001:
Returned by the request-body parser when an endpoint with a required body receives an empty payload or unparseable JSON. The message is stable and does not leak parser internals.
{
"errors": [
{ "code": "VE001", "message": "Request body is missing or malformed" }
]
}Deep-Link Validation (DL001)
Returned when a deepLinks[] entry references a mobileAppId that is null, blank, whitespace-only, or not registered for your organisation. The offending value is echoed in details.mobileAppId so you can diagnose without parsing the message.
HTTP/1.1 400 Bad Request
Content-Type: application/json{
"errors": [
{
"code": "DL001",
"message": "Deep link entry rejected: mobileAppId 'unknown123' is not recognised for this organisation",
"details": {
"kind": "deep_link_validation",
"mobileAppId": "unknown123"
}
}
]
}How to troubleshoot: open Manage Apps on the dashboard, copy the mobileAppId for the platform you’re targeting, and confirm it matches the value you’re sending. See Mobile Apps.
Rate Limit Exceeded (SML005)
Returned when your organisation has exhausted its per-minute request budget. The response includes:
- An RFC 6585
Retry-Afterheader (seconds until the bucket refills enough for one more request). - A
detailspayload with the scope and per-minute limit so you can pace requests programmatically.
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json{
"errors": [
{
"code": "SML005",
"message": "Rate limit exceeded",
"details": {
"kind": "rate_limit_exceeded",
"scope": "crud",
"limitPerMin": 30,
"retryAfterSeconds": 12
}
}
]
}How to back off correctly: read Retry-After from the response headers and wait at least that many seconds before the next request. If your client adds jitter, use the header value as the floor, not the ceiling.
Quota Errors (QE001 / QE002 / FE001)
All three quota-style errors return HTTP 402 with a structured details payload so the dashboard (or your integration) can render a specific upgrade CTA.
Plan quota exceeded — out of links this cycle (QE001, FREE-tier hard-stop):
The LINKS_PER_MONTH quota is a per-cycle metered allowance, not a lifetime cap — it resets at the start of every billing cycle. On FREE this is a hard-stop: when the counter hits the cap the create path returns 402 QE001 and the link is not persisted. On PAID plans (PRO, BUSINESS) the cap is NOT enforced — over-cap creates succeed and accrue overage that bills automatically at the end of the cycle (€30 per 10,000 extra links). So you’ll only ever see QE001 for the link quota on FREE orgs.
{
"errors": [
{
"code": "QE001",
"message": "Plan quota exceeded: LINKS_PER_MONTH",
"details": {
"kind": "quota_exceeded",
"quota": "LINKS_PER_MONTH",
"current": 20,
"limit": 20,
"currentPlan": "FREE",
"suggestedUpgradeTo": "PRO"
}
}
]
}Click quota exhausted — redirect path (QE002):
Returned on the public click path, not on the save path. The redirector renders a “redirection paused” page on FREE. Paid plans never see this on the redirect path — links keep resolving and the over-cap clicks bill automatically on the next invoice (€2.50 per 10,000); they are not free.
{
"errors": [
{
"code": "QE002",
"message": "Click quota exhausted for current period",
"details": {
"kind": "click_quota_exhausted",
"shortCode": "spring-sale",
"currentClicks": 5000,
"clicksLimit": 5000,
"periodEnd": "2026-06-01T00:00:00",
"currentPlan": "FREE",
"suggestedUpgradeTo": "PRO"
}
}
]
}Feature unavailable on plan (FE001):
{
"errors": [
{
"code": "FE001",
"message": "Feature not available on current plan: METADATA_PREVIEW",
"details": {
"kind": "feature_not_available",
"feature": "METADATA_PREVIEW",
"currentPlan": "FREE",
"suggestedUpgradeTo": "PRO"
}
}
]
}Entity Not Found (EN001) vs Archived Link (EN002)
EN001 (HTTP 404) means the resource doesn’t exist on a direct lookup. EN002 (HTTP 410) means it exists but the owner has archived it via isActive=false.
The list endpoint doesn’t return
EN001— a query that matches zero rows returns200 OKwith an emptydata: [].EN001only fires on lookup-by-id / lookup-by-shortCode paths where the caller is asserting the resource should exist.
{
"errors": [
{ "code": "EN001", "message": "Short URL with shortCode 'no-such-link' not found" }
]
}{
"errors": [
{ "code": "EN002", "message": "Link is no longer active" }
]
}Authentication Errors
Missing or invalid X-API-KEY returns HTTP 401 with code SML003:
{
"errors": [
{ "code": "SML003", "message": "Invalid API key" }
]
}A valid key with insufficient role returns HTTP 403, same code.
Handling Errors in Your Code
A recommended pattern:
const response = await fetch(
"https://api.codelloy.com/link/external/v1/shortenedUrl/save",
{
method: "POST",
headers: {
"X-API-KEY": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
},
);
if (response.status === 429) {
const retryAfter = Number(response.headers.get("Retry-After") ?? 60);
throw new RateLimitError(`Codelloy rate limited; retry in ${retryAfter}s`, {
retryAfter,
});
}
const data = await response.json();
if (data.errors?.length) {
const [first] = data.errors;
// Branch on the code rather than parsing the message string.
switch (first.code) {
case "DL001":
throw new ValidationError(`Invalid mobileAppId: ${first.details?.mobileAppId}`);
case "QE001":
case "QE002":
case "FE001":
throw new QuotaError(first.message, first.details);
case "SML003":
throw new AuthError(first.message);
default:
throw new Error(`${first.code}: ${first.message}`);
}
}
return data.response;The Codelloy API distinguishes errors via the
codefield, not HTTP status alone. Two different 400s (VE001vsDL001) need different operator responses — always branch oncodefirst, status second.