Static Ads Lab
Reference

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

StatusMeaning
200 OKSuccessful read or non-async update
201 CreatedSynchronous resource creation succeeded
202 AcceptedAsync creation accepted; resource is in processing
400 Bad RequestValidation error in body, query, or required form field
401 UnauthorizedMissing or invalid API key
402 Payment RequiredWallet balance too low
403 ForbiddenValid key, missing scope for this action
404 Not FoundResource doesn't exist or isn't in this org
409 ConflictIdempotency conflict, or duplicate resource
413 Payload Too LargeBody > 15 MB
422 Unprocessable EntityEntity relationship violated (e.g. product not in brand)
429 Too Many RequestsRate limit hit; check Retry-After
500 Internal Server ErrorServer-side failure
503 Service UnavailableDependent service unhealthy

General error codes

CodeStatusWhen it firesWhat to do
VALIDATION_ERROR400Request body, query parameter, path parameter, or required form field failed validationRead error.details for each field that failed (field, message). Fix and retry.
UNAUTHORIZED401Missing X-API-Key header, or key is malformed/revokedVerify key starts with sal_live_. Rotate from Settings → API Keys if necessary.
INSUFFICIENT_BALANCE402Wallet can't cover the requestTop up at https://www.staticads.ai/settings?tab=billing. The error.message includes the link and exact amounts.
FORBIDDEN403Key is valid but lacks the required scopeIssue a new key with the needed scopes.
NOT_FOUND404Endpoint path doesn't existRe-check the URL and method.
CONFLICT409Resource already exists or duplicate operationRe-check inputs; may be a unique constraint violation.
IDEMPOTENCY_CONFLICT409Another request with the same Idempotency-Key is in flightRetry shortly. See Idempotency.
REVISION_CONFLICT409PATCH /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_LARGE413Body exceeded 15 MBCompress images before upload. Always upload binaries via multipart, not base64.
RATE_LIMIT_EXCEEDED429Per-key rate limit exceededHonor Retry-After. Add backoff. See Rate limits.
INTERNAL_ERROR500Unhandled server-side errorRetry with backoff. If persistent, contact support with request_id.

Resource-specific 404s

CodeResource
BRAND_NOT_FOUNDBrand
PRODUCT_NOT_FOUNDProduct
PRODUCT_VARIANT_NOT_FOUNDProduct variant
AUDIENCE_NOT_FOUNDAudience
IMAGE_NOT_FOUNDImage
DESIGN_TEMPLATE_NOT_FOUNDDesign template
IMAGE_AD_NOT_FOUNDImage ad
IMAGE_AD_VERSION_NOT_FOUNDImage ad version

Resource-specific 422s

CodeWhen it firesWhat to do
PRODUCT_BRAND_MISMATCHThe product_id does not belong to the given brand_id in POST /v1/image-adsList /v1/products?brand_id=… to verify which products belong to the brand.
AUDIENCE_PRODUCT_MISMATCHThe audience_id does not belong to the given product_idList /v1/audiences?product_id=… to verify.
REFERENCE_AD_URL_INVALIDreference_ad_url is unreachable or does not point to an imageProvide a publicly accessible image URL.
PRODUCT_VARIANT_NO_IMAGESThe product has no variant with attached imagesCreate a variant and call PATCH /v1/product-variants/:id with image_ids.
IMAGE_AD_NOT_COMPLETEDTried to PATCH an ad that is still processing or failedWait for status: completed before patching.
IMAGE_AD_NOT_EDITABLEThe ad has no editable tree (some ads from before every ad was editable)Generate a new ad. Every new ad is editable.
IMAGE_UNAVAILABLEFigma 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_FAILEDFigma 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_READYDesign template is still processing or failedPoll 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

CodeStatusWhen
IMAGE_INVALID_FORMAT400Unsupported MIME type. Supported: JPEG, PNG, WebP.
IMAGE_UNREADABLE400File could not be decoded as an image.
IMAGE_UPLOAD_FAILED500Storage write failed. Retry.
IMAGE_TOO_LARGE413POST /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_PIXELS400POST /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.

CodeOriginWhat to do
PIPELINE_ERRORImage ad worker pipeline crashed after retriesSubmit 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, not Authorization: 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.

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;
}