Errors and billing
How errors and wallet billing interact, and the patterns that keep production sane.
This guide covers the practical interaction between API errors and wallet billing — what charges, what doesn't, and how to handle the common failure modes without surprise bills.
Billing model
Static Ads Lab charges on success:
| Operation | Cost | When charged |
|---|---|---|
| Generate flat image ad | $1.00 | When the ad transitions to completed |
| Generate editable image ad | $4.00 | When the ad transitions to completed |
| Generate design template | $0.25 | When the template transitions to completed |
If a job ends in failed, your wallet is not debited. Same when a request errors before queueing (validation, auth, etc.).
What to do per error
| Error | What it means | What to do |
|---|---|---|
400 VALIDATION_ERROR | Body or query failed validation | Fix and retry. No charge. |
401 UNAUTHORIZED | Missing or bad API key | Verify the key. No charge. |
402 INSUFFICIENT_BALANCE | Wallet too low | Top up at https://www.staticadslab.com/settings?tab=billing, then retry. |
404 NOT_FOUND | Resource doesn't exist or isn't in this org | Re-check IDs. No charge. |
422 | Entity relationship issue (product not in brand, template not ready, etc.) | Fix and retry. No charge. |
429 RATE_LIMIT_EXCEEDED | Too many requests | Honor Retry-After and back off. No charge. |
500 / 503 | Server-side or dependency failure | Retry with backoff. If a job was queued and ended in failed, no charge. |
The failed status case
A job can return 202 Accepted (queued, no charge yet), then later transition to status: "failed". The error field describes what happened:
{
"data": {
"id": "ia_abc",
"status": "failed",
"error": {
"code": "PIPELINE_ERROR",
"message": "Image generation failed after 3 attempts"
}
}
}Retry by submitting a fresh POST /v1/image-ads. Wallet was not debited.
Surfacing 402 to operators
If you ship a customer-facing tool, the most operator-hostile failure is 402 INSUFFICIENT_BALANCE mid-batch. Recommended pattern:
- Check
data.error.code === "INSUFFICIENT_BALANCE"after everyPOST. - Surface a "Top up wallet" CTA with the URL from
data.error.message. - Pause the batch, top up, then resume from the pending IDs.
async function safeGenerate(input) {
const r = await fetch("https://api.staticadslab.com/v1/image-ads", {
method: "POST",
headers,
body: JSON.stringify(input),
});
if (r.status === 402) {
const body = await r.json();
throw new InsufficientBalanceError(body.error.message);
}
if (!r.ok) {
const body = await r.json().catch(() => ({}));
throw new SalApiError(r.status, body?.error?.code, body?.meta?.request_id, body?.error?.message);
}
return r.json();
}Logging request IDs
Every response (success or error) includes meta.request_id. Log it on every error path. Support tickets are dramatically faster to resolve when the request ID is included.
log.error("Static Ads Lab error", {
status: r.status,
request_id: body?.meta?.request_id,
code: body?.error?.code,
message: body?.error?.message,
});Idempotency keeps retries safe
Use Idempotency-Key on every POST /v1/image-ads (or any other billable POST). On retry, the cached response replays without a new charge. See Idempotency.