Errors
Every error code, when it fires, and how to handle it.
All error responses have the same shape:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body"
},
"meta": {
"request_id": "req_abc123",
"timestamp": "2026-04-30T14:30:00.000Z"
}
}Always log meta.request_id — it's how support traces requests on our side.
HTTP status codes
| Status | Meaning |
|---|---|
200 OK | Successful read or non-async update |
201 Created | Synchronous resource creation succeeded |
202 Accepted | Async creation accepted; resource is in processing |
400 Bad Request | Validation error in body, query, or required form field |
401 Unauthorized | Missing or invalid API key |
402 Payment Required | Wallet balance too low |
403 Forbidden | Valid key, missing scope for this action |
404 Not Found | Resource doesn't exist or isn't in this org |
409 Conflict | Idempotency conflict, or duplicate resource |
413 Payload Too Large | Body > 15 MB |
422 Unprocessable Entity | Entity relationship violated (e.g. product not in brand) |
429 Too Many Requests | Rate limit hit; check Retry-After |
500 Internal Server Error | Server-side failure |
503 Service Unavailable | Dependent service unhealthy |
General error codes
| Code | Status | When it fires | What to do |
|---|---|---|---|
VALIDATION_ERROR | 400 | Request body or required form field failed validation | Read error.message for the specific field. Fix and retry. |
UNAUTHORIZED | 401 | Missing X-API-Key header, or key is malformed/revoked | Verify key starts with sal_live_. Rotate from Settings → API Keys if necessary. |
INSUFFICIENT_BALANCE | 402 | Wallet can't cover the request | Top up at https://www.staticadslab.com/settings?tab=billing. The error.message includes the link and exact amounts. |
FORBIDDEN | 403 | Key is valid but lacks the required scope | Issue a new key with the needed scopes. |
NOT_FOUND | 404 | Endpoint path doesn't exist | Re-check the URL and method. |
CONFLICT | 409 | Resource already exists or duplicate operation | Re-check inputs; may be a unique constraint violation. |
IDEMPOTENCY_CONFLICT | 409 | Another request with the same Idempotency-Key is in flight | Retry shortly. See Idempotency. |
PAYLOAD_TOO_LARGE | 413 | Body exceeded 15 MB | Compress images before upload. Always upload binaries via multipart, not base64. |
RATE_LIMIT_EXCEEDED | 429 | Per-key rate limit exceeded | Honor Retry-After. Add backoff. See Rate limits. |
INTERNAL_ERROR | 500 | Unhandled server-side error | Retry with backoff. If persistent, contact support with request_id. |
Resource-specific 404s
| Code | Resource |
|---|---|
BRAND_NOT_FOUND | Brand |
PRODUCT_NOT_FOUND | Product |
PRODUCT_VARIANT_NOT_FOUND | Product variant |
AUDIENCE_NOT_FOUND | Audience |
IMAGE_NOT_FOUND | Image |
DESIGN_TEMPLATE_NOT_FOUND | Design template |
IMAGE_AD_NOT_FOUND | Image ad |
IMAGE_AD_VERSION_NOT_FOUND | Image ad version |
Resource-specific 422s (entity relationship issues)
| Code | When it fires | What to do |
|---|---|---|
PRODUCT_BRAND_MISMATCH | The product_id does not belong to the given brand_id in POST /v1/image-ads | List /v1/products?brand_id=… to verify which products belong to the brand. |
AUDIENCE_PRODUCT_MISMATCH | The audience_id does not belong to the given product_id | List /v1/audiences?product_id=… to verify. |
DESIGN_TEMPLATE_NOT_READY | Design template is not in status: completed | Poll the template until complete; only then use it for image ad generation. |
DESIGN_TEMPLATE_MISSING_REFERENCE_IMAGE | The template has no reference_image_url | Re-create the template from a reference image URL. |
PRODUCT_VARIANT_NO_IMAGES | The product has no variant with attached images | Create a variant and call PATCH /v1/product-variants/:id with image_ids. |
IMAGE_AD_NOT_COMPLETED | Tried to PATCH an ad that is still processing or failed | Wait for status: completed before patching. |
IMAGE_AD_NOT_EDITABLE | Tried to PATCH a flat ad (editable: false) | Re-generate with "editable": true. There is no path to convert a flat ad. |
INVALID_NODE_OVERRIDES | One or more node_overrides failed validation | The error.details array, when present, lists per-field issues. |
Image upload errors
| Code | Status | When |
|---|---|---|
IMAGE_INVALID_FORMAT | 400 | Unsupported MIME type. Supported: JPEG, PNG, WebP. |
IMAGE_UNREADABLE | 400 | File could not be decoded as an image. |
IMAGE_UPLOAD_FAILED | 500 | Storage write failed. Retry. |
Async job failure codes
These appear in the resource's data.error.code field when an async resource lands in status: "failed". They are not HTTP status codes.
| Code | Origin | What to do |
|---|---|---|
PIPELINE_ERROR | Image ad worker pipeline crashed after retries | Submit a new POST /v1/image-ads. The wallet was not charged. |
Common error scenarios
"I get 401 even though I just created the key"
- Key must be sent as
X-API-Key, notAuthorization: Bearer. - The full key including the
sal_live_prefix. - Check for trailing whitespace or newline (a frequent culprit for keys pasted from clipboards).
"I get 422 on POST /v1/image-ads"
The most common cause is a product/brand mismatch (PRODUCT_BRAND_MISMATCH). The product you passed doesn't belong to the brand you passed. Verify:
const r = await fetch(
`https://api.staticadslab.com/v1/products/${product_id}`,
{ headers: { "X-API-Key": process.env.SAL_API_KEY } },
);
const { data } = await r.json();
console.log(data.brand_id === expectedBrandId);Other 422 culprits: DESIGN_TEMPLATE_NOT_READY (template still processing), AUDIENCE_PRODUCT_MISMATCH, INVALID_NODE_OVERRIDES.
"I keep getting 402 INSUFFICIENT_BALANCE in production"
You're likely hitting it after a series of fast successful generations. Top-ups are manual today. Until programmatic top-up exists, monitor error.code === "INSUFFICIENT_BALANCE" and surface a "Add funds" CTA to the operator. The error message includes the top-up URL.
Recommended client-side wrapper
class SalApiError extends Error {
constructor(status, code, requestId, message) {
super(message);
this.status = status;
this.code = code;
this.requestId = requestId;
}
}
async function salFetch(url, init = {}) {
const r = await fetch(url, {
...init,
headers: {
"X-API-Key": process.env.SAL_API_KEY,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
const body = await r.json();
if (!r.ok) {
throw new SalApiError(
r.status,
body?.error?.code ?? "UNKNOWN",
body?.meta?.request_id ?? "unknown",
body?.error?.message ?? r.statusText,
);
}
return body;
}