Development Guide

Development Guide

The Quick Guide gets one request working. This page covers what changes when the code has to keep working in production.

Two shapes of call

BeatAPI has exactly two request lifecycles, and almost every integration mistake comes from treating one as the other.

TextWorkflow, Image, Video, Effect
ReturnsThe answer, immediatelyA task ID, immediately
LatencySecondsSeconds to minutes
StreamingServer-sent eventsNot applicable
CompletionThe HTTP responsePoll, or receive a webhook
BilledOn the responseOn settlement, refundable on failure

Text is synchronous: POST /v1/chat/completions, /v1/responses, /v1/messages, or the Gemini-compatible endpoint returns the result. Everything else accepts the work and hands back an ID.

The asynchronous lifecycle

  1. SubmitPOST /v1/images/tasks (or videos, effects, video-analysis, music-video, ecommerce-video). A 201 means accepted, not finished.
  2. Store the ID — persist data.id and data.request_id before starting any background work. A crash between the response and the write leaves a task you have paid for and cannot find.
  3. Wait — poll GET /v1/tasks/{task_id}, or register a webhook.
  4. Read the result — on succeeded, use data.output.media. On failed, read data.error_code and the refund fields.

Polling

1import time
2import random
3import requests
4
5TERMINAL = {"succeeded", "failed"}
6
7def wait_for_task(api_key, task_id, timeout=900):
8 url = f"https://api.beatapi.io/v1/tasks/{task_id}"
9 headers = {"Authorization": f"Bearer {api_key}"}
10 deadline = time.time() + timeout
11 delay = 5.0
12
13 while time.time() < deadline:
14 response = requests.get(url, headers=headers, timeout=30)
15
16 if response.status_code == 429:
17 time.sleep(float(response.headers.get("Retry-After", 10)))
18 continue
19 response.raise_for_status()
20
21 task = response.json()["data"]
22 if task["status"] in TERMINAL:
23 return task
24
25 # Jitter keeps a fleet of workers from polling in lockstep.
26 time.sleep(delay + random.uniform(0, delay * 0.3))
27 delay = min(delay * 1.5, 30.0)
28
29 raise TimeoutError(f"task {task_id} did not finish within {timeout}s")

Three properties of that loop matter:

  • It stops on terminal states, not on a guess about elapsed time. A finished task never changes again.
  • It backs off with jitter. The task endpoint allows up to 120 requests per minute per key; a fixed one-second poll across a handful of concurrent tasks will exhaust that on its own.
  • It treats 429 as “wait”, not “fail”. Honour Retry-After.

Music Video tasks have non-terminal states that need a decision rather than more polling — storyboard_ready and requires_action. Treating them as “still working” polls forever. The Quick Guide lists every status and the action each one calls for.

Webhooks instead of polling

Register an endpoint with POST /v1/webhooks and BeatAPI posts the terminal state to it. This removes the polling loop entirely, at the cost of needing a reachable HTTPS endpoint.

Verify the signature against the raw request bytes before parsing JSON — re-serialising the body changes it and the signature will not match. See Webhooks.

Keep a slow reconciliation poll even with webhooks configured. A delivery can fail; a task that has been in processing for far longer than its model’s normal runtime is worth checking directly.

Errors

Every failure carries a stable error.code and a request_id. Log the request_id — it identifies the exact call in support.

1{
2 "error": {
3 "code": "rate_limit_exceeded",
4 "message": "Too many requests.",
5 "request_id": "req_abc123",
6 "retry_after_seconds": 12
7 }
8}
HTTPRetry?What it means
400NoThe request is wrong. Retrying sends the same wrong request
401NoThe key is missing, invalid, revoked, or inactive
402NoThe balance is exhausted. Add credits
403NoThe account cannot perform this operation
404NoWrong ID, or the resource belongs to another key
409NoAn idempotency key was reused with a different body
429Yes, after Retry-AfterRate or concurrency limit
500503Yes, with backoffTransient. Poll known tasks rather than resubmitting

The full list of error.code values is in the Quick Guide.

1import requests
2
3response = requests.post(
4 "https://api.beatapi.io/v1/images/tasks",
5 headers={"Authorization": f"Bearer {api_key}"},
6 json={"model": "nano-banana", "prompt": "a cute panda"},
7 timeout=30,
8)
9
10if not response.ok:
11 error = response.json().get("error", {})
12 # request_id is the only thing that identifies this call later.
13 log.error("beatapi failed", code=error.get("code"), request_id=error.get("request_id"))
14 if response.status_code == 402:
15 alert_billing()
16 elif response.status_code == 429:
17 schedule_retry(after=error.get("retry_after_seconds", 10))

A 500 on a submit is ambiguous: the task may or may not have been created. Resubmitting blindly can pay for the same work twice. Send an Idempotency-Key on task creation and reuse it on the retry — BeatAPI returns the original task instead of creating a second one.

Rate limits

Two separate limits apply, and they fail differently.

  • Request rate — requests per minute per key, returned as 429 with Retry-After. The allowance rises with lifetime top-ups; the current value and the next tier are on the dashboard and in GET /api/user/self.
  • Concurrency — how many tasks may be processing at once, returned as user_concurrency_exceeded. Waiting for a running task to finish is the only remedy; retrying immediately just consumes rate.

GET /v1/usage reports both: concurrency.limit and concurrency.active.

Retries that do not cost money

  • Retry 429 and 5xx. Never retry 400, 401, 402, 403, 404, or 409.
  • Use exponential backoff with jitter, and a ceiling.
  • Put an Idempotency-Key on every task creation so a retried submit cannot double-charge.
  • After a failed submit, poll before resubmitting if you have an ID.
  • Cap total attempts. A permanently failing request retried forever is an outage that bills.

Cost control

  • Set an output cap on text calls. Output is the expensive half; max_tokens is the simplest lever there is.
  • Match the model to the step. Classification and routing do not need the top tier.
  • Watch the tiered models. The GPT-5.6 and Grok families bill a higher rate past a context threshold; the DeepSeek family and Hunyuan hy3 bill a higher rate during published peak windows. A long-running agent or a scheduled batch can cross either line invisibly. Each response’s usage record shows which rate was applied.
  • One key per component. by_api_key in GET /v1/usage then attributes spend without any work on your side.
  • Reconcile refunds. A failed task is refunded; if your ledger only records the reservation, your numbers will drift from the invoice.

Before going to production

  • Use the exact https://api.beatapi.io origin.
  • Keep permanent keys server-side. Browsers get a short-lived Realtime client_secret, never an API key.
  • Store data.id and request_id before starting background work.
  • Poll with jitter; stop at terminal or action states.
  • Verify webhook signatures against raw bytes.
  • Record reservations, settlements, refunds, errors, and final output URLs.
  • Alert on 402 — it stops every paid operation on the account at once.