Async Jobs
Some operations take longer than a single HTTP request should wait — rendering a video, generating a full creative campaign, scanning a whole site, discovering thousands of contacts. These endpoints return immediately with a job handle, and you poll one endpoint for the result regardless of which service produced it.
The pattern
- Submit. Call the async endpoint. It reserves credits, kicks off the work, and returns a
job_idwithstatus: "queued". - Poll.
GET /v1/jobs/{job_id}untilstatusiscompletedorfailed. - Settle. On
completed, credits are consumed exactly once andresultis included. Onfailed, the reserved credits are refunded automatically anderroris included.
# 1. Submit — generate a newsletter banner (15 credits, async)
curl -X POST https://api.ai92.ai/v1/creative/newsletter \
-H "Authorization: Bearer ai92_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "X-AI92-Idempotency-Key: 9d2e...c7" \
-d '{"brand_id": "brand_koloxo", "topic": "Summer launch"}'{
"job_id": "f_2c1a9b44-7e51-4c0d-9b2f-1a6d3e8f0011",
"status": "queued",
"service": "creative",
"credits_reserved": 15,
"poll_url": "/v1/jobs/f_2c1a9b44-7e51-4c0d-9b2f-1a6d3e8f0011",
"created_at": "2026-06-21T10:30:00Z",
"updated_at": "2026-06-21T10:30:00Z"
}# 2. Poll
curl https://api.ai92.ai/v1/jobs/f_2c1a9b44-7e51-4c0d-9b2f-1a6d3e8f0011 \
-H "Authorization: Bearer ai92_live_YOUR_KEY"A finished job:
{
"job_id": "f_2c1a9b44-7e51-4c0d-9b2f-1a6d3e8f0011",
"status": "completed",
"service": "creative",
"credits_reserved": 15,
"result": { "asset_url": "https://cdn.ai92.ai/...", "format": "png" },
"created_at": "2026-06-21T10:30:00Z",
"updated_at": "2026-06-21T10:30:48Z"
}A failed job (credits already refunded):
{
"job_id": "f_2c1a9b44-...",
"status": "failed",
"service": "creative",
"credits_reserved": 15,
"error": { "code": "BACKEND_ERROR", "message": "render failed", "status": 503 }
}Job statuses
status | Meaning | Response includes |
|---|---|---|
queued | Accepted, not started yet. | poll_url |
processing | Running. | poll_url |
completed | Done successfully — credits consumed once. | result |
failed | Did not complete — credits refunded. | error |
completed and failed are terminal: stop polling once you see them.
Job ID prefixes
The job_id prefix tells you which backend produced the job. You never need to call those backends directly — GET /v1/jobs/{job_id} works for all of them — but the prefix is useful in logs:
| Prefix | Backend | Poll at |
|---|---|---|
f_ | Creative (image/video/newsletter renders) and site scans | GET /v1/jobs/{job_id} |
strat-int- | Full campaign / creative-campaign pipeline | GET /v1/jobs/{job_id} |
g_ | Analysis / intelligence | GET /v1/jobs/{job_id} |
c_, bdem_, j_, gxr_, a_ | Other internal producers | GET /v1/jobs/{job_id} |
Note: contacts bulk discovery and SEO audit are the exceptions — they return a UUID
scan_id(nof_/strat-int-prefix) and are polled at their own namespace (GET /v1/contacts/bulk/{scan_id}andGET /v1/seo/audit/{scan_id}respectively), not atGET /v1/jobs/{...}. See Poll namespaces below.
Which endpoints are async
| Endpoint | Handle returned | Poll at | Reserved cost |
|---|---|---|---|
POST /v1/creative/generate | job_id (f_*) | GET /v1/jobs/{job_id} | 50+ |
POST /v1/creative/newsletter | job_id (f_*) | GET /v1/jobs/{job_id} | 15 |
POST /v1/creative/campaigns | job_id (strat-int-*) | GET /v1/jobs/{job_id} | 250 / week |
POST /v1/campaigns/build | job_id (strat-int-*) | GET /v1/jobs/{job_id} | 25 |
POST /v1/ai-visibility/{brand_id}/scan | job_id (f_*) | GET /v1/jobs/{job_id} | 20 |
POST /v1/seo/audit | scan_id (UUID) | GET /v1/seo/audit/{scan_id} | — |
POST /v1/contacts/bulk (large limits) | scan_id (UUID) | GET /v1/contacts/bulk/{scan_id} — not /v1/jobs/ | 300 / 100 |
Synchronous endpoints return their result directly and never give you a job handle.
Poll namespaces
Most async jobs poll at the unified GET /v1/jobs/{job_id} endpoint, but two endpoints return a UUID scan_id that is polled at its own namespace — GET /v1/jobs/{scan_id} returns 404 for these. There are four distinct poll namespaces in total:
| Producer | Handle | Poll at |
|---|---|---|
f_* jobs (creative, scans) | job_id | GET /v1/jobs/{job_id} |
strat-int-* jobs (campaign build) | job_id | GET /v1/jobs/{job_id} |
| SEO audit | scan_id (UUID) | GET /v1/seo/audit/{scan_id} |
| Contacts bulk discovery | scan_id (UUID) | GET /v1/contacts/bulk/{scan_id} |
Always poll a scan_id at the namespace listed above — not at /v1/jobs/, which will 404 for an SEO-audit or contacts-bulk scan_id.
Reserve + refund, exactly once
This is the contract that protects your balance:
- On submit, the full cost is reserved (decremented with a TTL). If you can't afford it, you get
402 INSUFFICIENT_CREDITSand nothing is reserved. - On success, the reservation simply finalizes — you are charged once.
- On failure or timeout, the reservation is refunded automatically by AI92's reconciler — you are charged zero.
So a job that fails costs you nothing, and a job that succeeds costs you exactly its published price — never more, never twice.
Polling guidance
- Poll with backoff, not a tight loop: e.g. every 2 s for the first 30 s, then every 5–10 s. Most renders settle in well under a minute.
- Stop on terminal status (
completed/failed). - Prefer webhooks where available — subscribe to the relevant completion event and skip polling entirely. See Webhooks.
- Ownership is enforced.
GET /v1/jobs/{job_id}returns404for an unknown id or an id owned by another account — existence is never leaked across tenants.
import time, httpx
def wait_for_job(job_id, key, timeout=300):
headers = {"Authorization": f"Bearer {key}"}
delay = 2
deadline = time.time() + timeout
while time.time() < deadline:
job = httpx.get(f"https://api.ai92.ai/v1/jobs/{job_id}", headers=headers).json()
if job["status"] in ("completed", "failed"):
return job
time.sleep(delay)
delay = min(delay * 1.5, 10)
raise TimeoutError(job_id)