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.
Validation errors also list every field that failed in error.details, so you can show each problem next to its input:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "limit: Too small: expected number to be >=1; order: Invalid option: expected one of \"asc\"|\"desc\"",
"details": [
{ "field": "limit", "message": "Too small: expected number to be >=1" },
{ "field": "order", "message": "Invalid option: expected one of \"asc\"|\"desc\"" }
]
},
"meta": {
"request_id": "req_abc123",
"timestamp": "2026-10-15T14:30:00.000Z"
}
}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, query parameter, path parameter, or required form field failed validation | Read error.details for each field that failed (field, message). 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.staticads.ai/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. |
REVISION_CONFLICT | 409 | PATCH /v1/image-ads/:id, POST /v1/image-ads/:id/edits or a Figma sync item: base_revision is not the ad's current tree_revision (someone else saved, edited, or Figma synced). Nothing was saved. | Fetch the ad, reapply your change to its tree, and send error.details.current_revision as base_revision. |
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
| 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. |
REFERENCE_AD_URL_INVALID | reference_ad_url is unreachable or does not point to an image | Provide a publicly accessible 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 | The ad has no editable tree (some ads from before every ad was editable) | Generate a new ad. Every new ad is editable. |
IMAGE_UNAVAILABLE | Figma sync: an image in the tree could not be downloaded. Nothing was saved. For PATCH, the same code appears in the ad's render_error instead (the tree is saved, the previous image kept). | details list each URL and its paint path (...fills[0].sal.url). Use a publicly reachable image URL. |
RENDER_FAILED | Figma sync: the tree could not be rendered for any other reason. Nothing was saved. For PATCH, it appears in the ad's render_error. | Retry (or save again). If it keeps failing, contact support with the request_id. |
DESIGN_TEMPLATE_NOT_READY | Design template is still processing or failed | Poll GET /v1/design-templates/:id until status: completed. Applies to design-template workflows only — not POST /v1/image-ads. |
Design edits
POST /v1/image-ads/:id/edits returns 400 VALIDATION_ERROR unless the body has exactly one of instruction or script. An edit that runs but cannot be applied is not an HTTP error: the response is 200 with data.status: "failed" and data.failure: { code, message } (for example script_error, invalid_design, no_change, out_of_scope). Nothing was applied and nothing was charged; fix the script or instruction and retry. See Edit ads with instructions or scripts.
POST /v1/image-ads no longer accepts editable (every ad is editable). Sending it returns 400 VALIDATION_ERROR.
JSON tree errors
PATCH /v1/image-ads/:id, PATCH /v1/design-templates/:id and Figma sync validate current_json_tree before any other work. Every failure is a 400 VALIDATION_ERROR with one details entry per path, for example current_json_tree.root.children[2].fills[0].color.r:
- The tree is not a valid schema-v2 document: an unknown key, a missing required field, an invalid enum, a non-finite number, duplicate node ids, or text segments that don't cover the characters.
- The tree uses the retired format (no
schemaVersion): "This JSON tree uses the retired schema (version 1). Regenerate it with the current pipeline."
A font we don't ship is not an error: the text renders in Inter at the closest style and the tree keeps its fontName (see Fonts we do not ship).
Every IMAGE paint's sal.url must also be an absolute http(s) URL, or the request fails with 400 INVALID_IMAGE_URL (one details entry per paint path). Nothing is saved on any of these errors.
PATCH /v1/image-ads/:id then saves the tree and renders it in the background. A failed render does not return an error: the ad shows render_status: "failed" and render_error (IMAGE_UNAVAILABLE or RENDER_FAILED) and keeps its previous image.
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. |
IMAGE_TOO_LARGE | 413 | POST /v1/image-ads/:id/assets: the file is over 4.5 MB. Keep uploads to about 4 MB; larger request bodies are rejected by the hosting platform. |
IMAGE_TOO_MANY_PIXELS | 400 | POST /v1/image-ads/:id/assets: the image is over 50 megapixels. Resize it first. |
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: REFERENCE_AD_URL_INVALID, AUDIENCE_PRODUCT_MISMATCH, PRODUCT_VARIANT_NO_IMAGES.
"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;
}