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.

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 or required form field failed validationRead error.message for the specific field. 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.staticadslab.com/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.
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 (entity relationship issues)

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.
DESIGN_TEMPLATE_NOT_READYDesign template is not in status: completedPoll the template until complete; only then use it for image ad generation.
DESIGN_TEMPLATE_MISSING_REFERENCE_IMAGEThe template has no reference_image_urlRe-create the template from a reference 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_EDITABLETried to PATCH a flat ad (editable: false)Re-generate with "editable": true. There is no path to convert a flat ad.
INVALID_NODE_OVERRIDESOne or more node_overrides failed validationThe error.details array, when present, lists per-field issues.

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.

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: 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.

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