Batch API reference

Every endpoint of the batch API, with its request and response shape.

Base URL and authentication

Every endpoint below is relative to the batch API base URL https://api.anex.sh/batch/v1 and needs an API key, created on the console's Keys page:

Authorization: Bearer <your-api-key>

Keys belong to the organization, not to a person: any key of an organization can see and manage that organization's jobs, and each action records which key performed it. An id that belongs to another organization answers 404, the same as an id that does not exist.

The examples use $BASE for the base URL and $TOKEN for the key.

The API is these seven endpoints, in the order a job meets them:

POST /inputs
Ingest an input file from a URL, or get a presigned target to upload one directly.
GET /inputs/{id}
Check an input's status until it is ready.
POST /jobs
Submit a job: an input plus a model.
GET /jobs/{id}
Poll a job for its status, estimate, and row counts.
POST /jobs/{id}/approve
Approve the estimate - required before any paid work starts.
POST /jobs/{id}/cancel
Cancel a job.
GET /jobs/{id}/result
Download the result file.

There is no list-jobs and no list-models endpoint: keep the job ids you submit (the console's Jobs page shows every job of the organization), and see models below for the model ids.

Models

The model column below is the exact string to pass as the model field when submitting a job - copy it verbatim; there are no aliases or version suffixes. The console's model dropdown selects from the same set.

Every model takes text; the input column lists what a model accepts on top of that. A content part your chosen model has no encoder for is not rejected at submit - the job fails while its full input is being checked and priced (the estimating stage, when the job has one), with failure_code: invalid_input - so pick a model that actually covers what your rows carry.

modelinputoutputquantizationexact weights served
qwen3.8-27btext + image + videotext (reasoning)fp8Qwen/Qwen3.8-27B-FP8
glm-5.2texttext (reasoning)nvfp4nvidia/GLM-5.2-NVFP4
qwen3.6-27btext + imagetext (reasoning)fp8Qwen/Qwen3.6-27B-FP8
gemma4-31btext + imagetext (reasoning)fp8RedHatAI/gemma-4-31B-it-FP8-dynamic
qwen3-14btexttext (reasoning)fp8Qwen/Qwen3-14B-FP8
qwen3-omni-30btext + image + audio + videotextunquantizedQwen/Qwen3-Omni-30B-A3B-Instruct
qwen3-embedding-8btextembedding vectorunquantizedQwen/Qwen3-Embedding-8B
qwen3-vl-embedding-8btext + image + videoembedding vectorunquantizedQwen/Qwen3-VL-Embedding-8B
qwen3-vl-embedding-2btext + image + videoembedding vectorunquantizedQwen/Qwen3-VL-Embedding-2B

The weights column names the published build each model is served from, so the version and precision are never a guess: an fp8 model runs that fp8 checkpoint, and an unquantized model runs the original published weights. Which models are actually on offer can vary by deployment - treat this table as the shape, not a guaranteed allowlist.

The three embedding models return vectors instead of a text completion, and take a different job shape (see embedding jobs below):

qwen3-embedding-8b
4096-dimension output, native; request any narrower width from 32 up to 4096 with dimensions - 32,768-token context
qwen3-vl-embedding-8b
text, image, video - one vector space across all three; 4096-dimension output, native; request any narrower width from 64 up to 4096 with dimensions - 32,768-token context; per 1M input tokens: $0.02 text-only rows, $0.03 rows with images, $0.10 rows with video
qwen3-vl-embedding-2b
budget sibling of the 8B; text, image, video - one vector space across all three; 2048-dimension output, native; request any narrower width from 64 up to 2048 with dimensions - 32,768-token context; per 1M input tokens: $0.005 text-only rows, $0.01 rows with images, $0.05 rows with video

The two qwen3-vl-embedding-* models take image and video content parts in input alongside text, all mapped into the one vector space above - see embedding jobs below for the parts contract. Like qwen3-embedding-8b they bill input tokens only, with no separate per-image or per-video charge - a media part becomes input tokens through the model's own encoder, the same as text - and a row's rate follows what it carries: text-only, images, or video.

The input file

Two formats are accepted, chosen by the file extension: .jsonl and .parquet.

JSONL - one OpenAI-batch request object per line. Each line must be a JSON object with custom_id, method, url and body, and url must be /v1/chat/completions; a file that targets any other endpoint is rejected.

{"custom_id":"row-1","method":"POST","url":"/v1/chat/completions","body":{"messages":[{"role":"user","content":"Say hello in French."}],"chat_template_kwargs":{"enable_thinking":false}}}

From a line's body, three things are used: messages (multi-turn is preserved), chat_template_kwargs (the per-row thinking setting), and response_format. The model and the output-token cap are always the job-wide values - a model or max_completion_tokens on the line does not override them - and any other body field is not carried through.

A model whose modalities (see Models above) include image, audio, or video accepts those as extra content parts, the same shapes as the OpenAI API:

{"type":"image_url","image_url":{"url":"https://example.com/photo.jpg"}}
{"type":"input_audio","input_audio":{"data":"<base64>","format":"wav"}}
{"type":"audio_url","audio_url":{"url":"https://example.com/clip.mp3","duration_seconds":42}}
{"type":"video_url","video_url":{"url":"https://example.com/clip.mp4","duration_seconds":30}}

image_url, audio_url, and video_url each accept a public URL or a data: URI (use https:// for a remote host - it's what our SSRF protections and pricing probe are built to assume); input_audio carries its bytes as bare base64 (no data: prefix) plus a format. Inline media is size-capped per modality (see limits). A type we don't recognize at all is rejected at submit; a real type your model simply has no encoder for passes submit and fails the job later instead (see errors).

Supported formats - audio: wav, mp3, m4a, aac, ogg, opus, flac, aiff (wma is not supported). Video: mp4, webm, mov, mkv, avi. Images: png, jpeg, webp, gif, bmp, tiff. These are container formats: inside an allowlisted video container the codec still has to be one the fleet decodes - H.264, H.265, VP8 and VP9 are the safe choices, and an .mp4 carrying AV1 can still fail at serving time. A part naming an unsupported container is rejected at submit with a 400 quoting the offending line, and past the sampled rows the job fails while it is still free with failure_code: invalid_input.

We work out a part's format from, in order: the format field (input_audio only), the media type of a data: URI, the file extension on a URL, and the first bytes of anything sent inline. Inline bytes that contradict the declared format are rejected. For an audio or video URL with no file extension we read the Content-Type from a HEAD request made while the job is being estimated; if your host answers, and that answer is application/octet-stream or names something off the allowlist, the job is rejected. That probe is best effort and covers the first few dozen URLs in a job, so a clip we could not ask about is admitted and can still fail at serving time. Three ways to fix that: put a file extension on the URL, serve the object with its real content type, or send the bytes inline. Images are never probed this way, so an extensionless image URL with no data: media type is passed through to the model as-is.

duration_seconds - audio and video are priced and bounded by duration, which we otherwise have to infer without downloading your media. Add an optional duration_seconds inside the media object (input_audio, audio_url, or video_url) to state the real length yourself. Hosted audio (audio_url) requires it on current models - an undeclared remote clip is otherwise bounded at the model's duration ceiling, which is too long for every serving band and gets the row rejected. Hosted video's ceiling is only 120s, so it's optional there, but still recommended:

{"type":"audio_url","audio_url":{"url":"https://example.com/clip.mp3","duration_seconds":42}}

Declared, it's validated against the model's duration cap as stated, and the per-minute surcharge (see credits and billing) is billed on exactly that length. Undeclared, we estimate it from the byte size (a conservative floor, so the estimate only ever runs long). Admission always resolves a duration offline, without waiting on your host: for an undeclared remote URL it assumes the model's worst-case duration ceiling, which exceeds every serving band's limit, so the row fails the length check and is rejected - it is never billed. A follow-up HEAD request only refines the byte-size estimate for pricing on rows admission already accepted; it never affects whether a row is admitted. Either way, declaring the real duration gets you an accurate check and an accurate charge.

Parquet - a table with one column holding each row's prompt. You name that column as instruction_column at submit time. Parquet rows carry no per-row settings: the job-level thinking default and response_format apply to every row.

Embedding jobs

Point a job at one of the embedding models and both input formats carry a vector job's input instead of a chat prompt.

JSONL - url must be /v1/embeddings, and body carries input and an optional dimensions. On every embedding model, input may be a single non-empty string - one row is one embedding:

{"custom_id":"row-1","method":"POST","url":"/v1/embeddings","body":{"model":"qwen3-vl-embedding-8b","input":"a plain text string"}}

On qwen3-vl-embedding-8b and qwen3-vl-embedding-2b, input can instead be a list of chat-style content parts - text, image_url, and video_url - so a row embeds an image or a video clip, alone or alongside text, into the same vector space as a plain text row:

{"custom_id":"row-2","method":"POST","url":"/v1/embeddings","body":{"model":"qwen3-vl-embedding-8b","input":[{"type":"image_url","image_url":{"url":"https://example.com/photo.jpg"}}]}}
{"custom_id":"row-3","method":"POST","url":"/v1/embeddings","body":{"model":"qwen3-vl-embedding-8b","input":[{"type":"video_url","video_url":{"url":"https://example.com/clip.mp4"}}],"dimensions":64}}
{"custom_id":"row-4","method":"POST","url":"/v1/embeddings","body":{"model":"qwen3-vl-embedding-8b","input":[{"type":"text","text":"caption for the image"},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0..."}}]}}

image_url and video_url each accept a public URL or a data: URI, the same shapes as chat content parts. A part's modality must be one the chosen model actually supports - a media part sent to qwen3-embedding-8b (text only) is rejected naming the modality, and no embedding model accepts audio. A row's parts must include at least one with media content or non-empty text. A list that isn't shaped as content parts - not every element an object - is rejected with the same message as a bare list of strings (embeddings input must be a single string per row, not a list (submit one row per input)); submit one row per string instead. A messages key is rejected too (an embeddings request carries 'input', not 'messages'), and so is any encoding_format other than "float". A file's rows must all target the same endpoint - a job can't mix /v1/embeddings rows with /v1/chat/completions rows.

Inline image and video parts are size-capped the same as inline chat media, and a video clip's duration is capped the same way too (see limits). A row can carry at most 4 images and 1 video clip on a model that takes them; a row over that isn't rejected up front - the surplus is refused at serving time, the same as chat media.

Parquet - name the column to embed with input_column at submit time (the embedding-job counterpart of instruction_column); only that column's text is embedded, the rest of the row passes through untouched. That column is always plain text, even on a qwen3-vl-embedding-* model - parquet has no column shape for inline media, so a parquet job never carries images or video; use JSONL for those.

dimensions (optional, either format) truncates the output vector to a narrower width, Matryoshka style - an integer from the model's own floor (32 for qwen3-embedding-8b, 64 for qwen3-vl-embedding-8b and qwen3-vl-embedding-2b) up to the model's native width. Set it job-level at submit to apply to every row, or per-row in a JSONL line's body.dimensions; when both are set, the row's own value wins for that row. Leave it out entirely for the model's full native width.

For what reaches the model - whether any instruction or chat template is applied to your text, how the vector is pooled and normalized, and why two embeddings of the same text are not bit-identical - see how embedding inputs are processed.

Generation-only fields don't apply here: max_tokens, reasoning, response_format, and instruction_column are all rejected with 422 on an embedding model. The reverse also holds - input_column and dimensions are rejected with 422 on a chat-completions model.

Pricing and estimation work differently too: an embedding job bills input tokens only, and its estimate comes from an exact token count rather than a sample - see embedding output and poll a job below. qwen3-embedding-8b bills $0.02 per 1M input tokens (0.002 cents per 1k). The two qwen3-vl-embedding-* models price a row by what it carries: qwen3-vl-embedding-8b bills $0.02 per 1M input tokens on text-only rows, $0.03 on rows with images, and $0.10 on rows with video (alone or alongside images); qwen3-vl-embedding-2b bills $0.005, $0.01, and $0.05 for the same three. Every embedding model prices an image or video part as input tokens through the model's own encoder, the same as text, rather than a separate per-item charge.

A job's vectors are only comparable to another job's when both used the same model, at the same dimensions width if either job set one. Every embedding model produces its own vector space; qwen3-vl-embedding-8b and qwen3-embedding-8b, the two production-facing families, are not compatible with each other.

How embedding inputs are processed

This section is for readers comparing our vectors against a local run of the same checkpoint, or deciding how to phrase their inputs. It applies to both embedding jobs and realtime embeddings.

We embed exactly the text you send. The string in input reaches the model verbatim. Nothing is prepended, appended, or rewritten: no instruction prefix, no task description, and no system message. A batch row sends {"model": ..., "input": "<your text>"} and a realtime request sends {"model": ..., "input": ["<your text>"]}, and that is the whole transformation.

This is deliberate. Several embedding models are served under one OpenAI-compatible API, and their instruction conventions differ by model and by task, so the convention stays yours to choose. Each upstream checkpoint suggests its own: Qwen's examples for the qwen3-vl-embedding-* models wrap the text in a chat template with a default system instruction (Represent the user's input.) that you replace per task, while qwen3-embedding-8b instead documents an Instruct: <task>\nQuery: <text> prefix on the query side only. We apply neither for you. If you want one of them, or an asymmetric query and document phrasing of your own, write it into the string yourself and apply it consistently to both sides of any comparison. It is worth doing if you are tuning for retrieval quality: Qwen reports that leaving the query-side instruction off costs roughly 1 to 5 percent on their retrieval benchmarks.

Text and media are not phrased the same way. A row whose input is a list of content parts carrying an image or a video reaches the model in its chat-message form, because that is the only form the multimodal path accepts, so those rows do pick up the model's own chat template and its default instruction. Plain text rows never do. Both land in the same vector space and retrieval across them works, but if you are measuring text against media on a qwen3-vl-embedding-* model and want both sides phrased identically, wrap your text in the same template rather than sending it bare.

Pooling and normalization. A vector is the model's last-token hidden state, L2 normalized to unit length, which is the pooling these checkpoints are built for. What differs from a local run of Qwen's example code is only the position that lands last: because we add no instruction and no template, the pooled token is the final token of the text you sent rather than a trailing position introduced by the wrapper. That is why our text vectors will not match a reference run element for element, and why phrasing your inputs the same way on both sides matters more than matching us exactly.

dimensions truncates before normalizing, Matryoshka style, so a narrowed vector comes back at unit length rather than as a raw slice of the full-width one. Because every returned vector is unit length, cosine similarity and dot product give the same ranking, and no further normalization on your side is needed.

Weights. Every embedding model runs the unmodified upstream checkpoint at its published precision, with no quantization, distillation, or fine tuning of ours. The exact Hugging Face repository for each model is in the models table.

Vectors are not bit-for-bit reproducible. Embedding the same text twice can return numbers that differ in their last digits: floating point results depend on the GPU, the batch the request landed in, and the order of operations, so identical output is never guaranteed across two machines or two runs. Cosine similarity between two such vectors should still come out very close to 1.0. If you compare a repeated embedding of the same text, at the same model and the same dimensions width, and get meaningfully less than that, send us the examples through support and we will look into it.

POST /inputs - ingest an input from a URL

The API can copy your input file into storage from any HTTPS URL - a presigned GET URL from your own bucket is the simplest source. (The console uploads from the browser instead; this endpoint is API-only.)

curl -X POST "$BASE/inputs" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{
  "source_url": "https://your-bucket.s3.amazonaws.com/path/input.jsonl?X-Amz-...",
  "format": "jsonl",
  "callback_url": "https://you.example.com/hooks/ingest"
}'

{"id":"<ingest-id>","status":"pending","input_s3_uri":"s3://.../<ingest-id>.jsonl",
 "callback_secret":"whsec_..."}

This endpoint requires credits: an organization whose balance is zero or below is answered 402 here, before an upload target is minted or a source URL is contacted, so nothing can be placed in storage before anything is paid for.

format (jsonl or parquet) is optional; without it the extension of the source URL decides. callback_url is optional, and callback_secret comes back only when you supply one - it is returned on this response only, never again.

Then poll until the copy has finished:

curl "$BASE/inputs/<ingest-id>" -H "Authorization: Bearer $TOKEN"

{"id":"<ingest-id>","status":"ready","input_s3_uri":"s3://...","error":null}

status goes pendingcopyingready, or failed with an error. With a callback_url registered you get an input.ready or input.failed callback instead of polling.

Keep the returned id (or input_s3_uri): input_id is preferred for job submission. Both source_url and callback_url must be https://, and a source that advertises a size over the input cap is refused with 413 before any bytes are copied. A source that does not advertise one, or is too slow to answer, is accepted here and refused during the copy instead: the ingest then settles as failed with the reason in error.

POST /inputs - or upload a file directly

If the file lives on your machine rather than somewhere we can fetch it from, call the same endpoint with no source_url - just a format:

curl -X POST "$BASE/inputs" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"format": "jsonl"}'

{"id":"<id>","status":"awaiting_upload",
 "upload_url":"https://...s3.amazonaws.com/",
 "fields":{"key":"...","policy":"...",...},
 "expires_at":"2026-07-26T15:04:05Z",
 "input_s3_uri":"s3://.../uploads/.../<id>.jsonl"}

The response is a presigned upload target rather than a copy job. Upload with a form POST: send every entry of fields as a form field, then the file itself as the file field, last.

curl "$UPLOAD_URL" \
  -F key=... -F policy=... -F x-amz-algorithm=... \
  -F x-amz-credential=... -F x-amz-date=... -F x-amz-signature=... \
  -F file=@data.jsonl

The upload must start before expires_at (about an hour out); a lapsed window can't be refreshed - create a new input instead. A file over the input cap is rejected by the upload itself, at the S3 level, rather than by this API. GET /inputs/{id} reports awaiting_upload until the object lands, then ready, and echoes expires_at while the window is open - you can submit the job immediately after uploading, since submission settles readiness itself. An uploaded object is kept for 7 days, so submit well inside that window. callback_url isn't available in this mode (there's no server-side copy to report on) and is rejected with 400 if supplied.

POST /jobs - submit a job

curl -X POST "$BASE/jobs" -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{
  "input_id": "<id>",
  "model": "<model-id>"
}'

{"id":"<job-id>","status":"submitted"}
input_id
Required unless input_s3_uri is given. The id from an ingest or upload. Preferred: it lets this call settle an upload for you, so you can submit right after uploading with no separate poll. Submitting one that isn't ready yet answers 409.
input_s3_uri
Required unless input_id is given. The input_s3_uri from an ingest. Give exactly one of input_id / input_s3_uri.
model
Required. A model id from the allowlist; the console's model picker lists the ids on offer. An unknown id is rejected before anything is created.
instruction_column
Required for a parquet input, ignored for JSONL. The column whose cell value is each row's prompt.
max_tokens
Optional, 1–32,768. Omit it and the cap is estimated from a sample of your rows: the value every row is expected to stay under with 95% confidence, which is also what the job is priced on. Supply it and your number is the cap and the price basis; on a reasoning model the sample is still taken to set the thinking budget unless you set reasoning_budget yourself or turn reasoning off.
reasoning
Optional boolean, for the models marked "text (reasoning)" in the models table above. It is the job-level default; a JSONL row that sets chat_template_kwargs.enable_thinking itself keeps its own setting. Omit the field to leave the model's own default in place. true on a model that produces no reasoning trace is refused (422 at submit, or a rejected row naming the line), rather than accepted and answered without one; false stays valid on every generation model, so one body submits across a mixed model set. See thinking.
reasoning_budget
Optional, 0–32,768, for the models marked "text (reasoning)" in the models table above. Omit it and it is estimated from the same sample as the output cap: the thinking length every row is expected to stay under with 95% confidence, so rows that think longer are cut off gracefully and still answer. Set a number to cap thinking yourself (at least 64 below an explicit max_tokens), or 0 for no cap. When the sample shows that even the answers alone would not fit under your max_tokens, estimation fails and says so; raise max_tokens or set reasoning_budget. 0 is accepted on any generation model, reasoning or not, so the same submit body works across a mixed set; a value of 1 or higher is only for the models marked "text (reasoning)". See thinking.
response_format
Optional. Applied to every row. See structured output.
input_column
Embedding models only, required for a parquet input, ignored for JSONL. The column whose cell value is each row's text to embed. 422 on a non-embedding model. See embedding jobs.
dimensions
Embedding models only, optional. Truncates the output vector to a narrower width (32 up to the model's native width). A JSONL row's own body.dimensions wins over this for that row. 422 on a non-embedding model. See embedding jobs.

The whole input is validated before the job exists: format, size, row count, inline media size (image, audio, and video alike), and the shape of response_format. A failure returns an error and creates nothing.

Submission has no idempotency key: every POST /jobs that succeeds creates a new job. If a submit times out without a response, check for the job (the console's Jobs page lists every job of the organization) before retrying, or you may create it twice. Nothing runs without approval either way, so a duplicate costs nothing until someone approves it.

A credit balance of zero or below answers 402, before any of your input is read and before a job is ever priced - a free grant or a paid top-up, either counts; see credits and billing. The same check guards uploads, so an account with no balance cannot place a file either. This is a coarser check than approval's: it only asks whether you have any balance at all, not whether it covers this particular job.

An embedding model rejects max_tokens, reasoning, response_format, and instruction_column with 422 - see embedding jobs for the full field list.

GET /jobs/{id} - poll a job

curl "$BASE/jobs/<job-id>" -H "Authorization: Bearer $TOKEN"

{"id":"<job-id>","status":"cost_ready",
 "est_input_tok":120000,"est_output_tok":40000,"est_cost_cents":72,
 "est_processing_seconds":900,"max_tokens":600,"reasoning_budget":null,
 "success_rows":0,"error_rows":0,"format_error_rows":0,"truncated_rows":0,
 "pending_rows":1000,
 "created_at":"...","approved_at":null,"data_expires_at":null,"partial":null,
 "failure_code":null,"failure_reason":null,
 "model":"qwen3.8-27b","reasoning":true,"task_type":"chat","format":"parquet",
 "instruction_column":"prompt","response_format":null,"dimensions":null}

status is the job's current state; the values and the order they come in are described under job lifecycle.

est_input_tok, est_output_tok, est_cost_cents
The estimate, filled in when the job reaches cost_ready. est_cost_cents is the amount approval holds and the most the job can consume. A job that does any work costs at least 1 credit, so both this estimate and the final charge are floored there; a job that consumed nothing is not charged. Each model also has a minimum job price - the cost of the GPU machine start a small job forces, never added on top of a job whose token price clears it - so a large job pays pure per-token rates; see the pricing page for the current minimums. An embedding job's est_output_tok is always 0, and its est_input_tok comes from an exact token count rather than a sample, so it reaches cost_ready in seconds - see embedding jobs.
est_processing_seconds
Expected wall-clock run time from approval to results, in whole seconds, frozen at estimate time: the fixed startup overhead recent jobs of this model have paid (booting a machine, pulling the weights) plus the job's own expected generation at the model's measured throughput. Advisory; most jobs are dominated by the startup overhead.
max_tokens
The effective per-row output cap: yours, or the estimated one (null until estimated). An embedding job reports 0: it has no completion budget.
reasoning_budget
The effective thinking budget: yours, or the estimated one (null until estimated). Always null on a non-reasoning model, which cannot set this field at all.
model
The model the job ran against.
reasoning
The job-level thinking setting. null means you set no job-level default and the model's own behaviour applied - it does not mean thinking was off. A JSONL row that carried its own chat_template_kwargs.enable_thinking overrides this for that row, which this job-level field cannot report.
task_type
chat (a completion per row) or embed (a vector per row).
format
The input file's format, parquet or jsonl.
instruction_column
The parquet column rendered into each row's prompt; null on a JSONL job.
response_format
The job-wide structured-output schema, if you set one. A JSONL row may carry its own, which wins for that row.
dimensions
The embedding width on an embed job; null means the model's full width.
success_rows, error_rows
Rows that produced an answer, and rows that failed.
format_error_rows
Rows whose constrained generation could not satisfy the response_format. Counted separately, not in error_rows.
truncated_rows
Rows whose generation stopped on the output-token cap. Counted separately, not in error_rows. On a reasoning model, reasoning_budget keeps thinking from consuming the whole cap, so rows still answer instead of truncating.
pending_rows
Rows not yet accounted for by any of the four counts above.
partial
True on a terminal job that still shipped the rows that finished.
data_expires_at
When the job's data is deleted: the input file and the result are kept for 30 days after the job finishes, then purged. Set once the job reaches a terminal state (done, failed, cancelled), null before that. Download the result before this time; afterwards the result endpoint answers 410 Gone. The job record itself (statuses, counts, cost) stays queryable.
failure_code, failure_reason
Set on a failed job. failure_code is a stable value (invalid_input or internal_error); failure_reason is a readable message. Three media-specific invalid_input reasons: a content part whose type the model has no encoder for (names the model and modality), a clip whose duration exceeds the model's cap (worded differently for a declared vs. an estimated length - see duration_seconds), and an inline media item over its size cap on a row past the sampled submit check (same wording as the submit-time rejection, naming the line). Jobs that go through estimation (no max_tokens, or a reasoning model with no reasoning_budget) can also fail during estimation with invalid_input when the output does not fit the model: the sample needs more output tokens than the model can generate, the model's reasoning runs past the estimation time budget, the sampled outputs hit the model's maximum output length and would be cut off, or (with an explicit max_tokens on a reasoning model) the sample shows the answers alone would not fit once the thinking budget and its cut-off notice are reserved. Each reason states the fix: set or raise max_tokens, set reasoning_budget (0 for no cap), or disable reasoning.

POST /jobs/{id}/approve - approve a job

Approval is what starts paid GPU work. It holds est_cost_cents against your prepaid balance; see credits and billing.

curl -X POST "$BASE/jobs/<job-id>/approve" -H "Authorization: Bearer $TOKEN"

{"id":"<job-id>","status":"approved"}

A job must be in cost_ready: any other state answers 409. A balance that does not cover the estimate answers 402 - top up and approve again. With auto-approve turned on for the organization, this call is made for you.

An estimate waits at most 24 hours: a job left in cost_ready past that window is cancelled automatically, free of charge. An organization can hold at most 10 jobs awaiting approval; past that cap, submit answers 429 until some are approved, cancelled, or expired.

POST /jobs/{id}/cancel - cancel a job

curl -X POST "$BASE/jobs/<job-id>/cancel" -H "Authorization: Bearer $TOKEN"

{"id":"<job-id>","status":"cancelling"}

A job with no work in flight stops immediately and answers cancelled. A job that has already started answers cancelling: no new work is started, rows already running finish, and the job then settles as cancelled with the completed rows downloadable as a partial result. Once a job is merging or already terminal it is too late, and the call answers 409.

A job that is estimating answers 409 too: wait for cost_ready and simply never approve it - estimates are free, and an unapproved job cancels itself after 24 hours.

GET /jobs/{id}/result - download the result

curl "$BASE/jobs/<job-id>/result" -H "Authorization: Bearer $TOKEN"

{"id":"<job-id>","status":"done","result_url":"https://...signed...",
 "expires_in":3600,"partial":false,"row_count":1000}

result_url is a presigned link you GET to download the file; expires_in is how many seconds it stays valid. Request the endpoint again for a fresh link. row_count is the number of rows actually in the file. What is inside it is described under output format.

A result exists for a done job, and for a failed or cancelled job that delivered the rows it had finished - that one comes back with "partial": true. A job with nothing to deliver answers 409.

Job payloads are deleted 30 days after the job finishes. After that this endpoint answers 410 and the data is gone - download anything you want to keep well inside that window.

Webhooks

An organization can register one endpoint (an owner does this on the console's Settings page). It then receives a signed POST for every job event, for every job in the organization - there is no per-event or per-job subscription.

submitted
The job was created.
estimating
Output length is being measured to price the job.
cost_ready
The estimate is ready. Carries est_input_tok, est_output_tok and est_cost_cents.
approved
The estimate was approved and the credits are held.
in_progress
GPU work has started.
chunk_done
A progress ping while the job runs, sent as its row counts move.
completed
The job finished. Carries status, result_url and result_expires_in, so the event is directly actionable.
failed
The job failed. Carries status, partial, the failure_code/failure_reason when one was recorded, and the result URL when there is something to download.
cancelled
The job was cancelled. Same payload as failed.

A test event is also sent by the Settings page's test button. Every event has the same envelope; the ones not listed with a payload above carry only job_id.

{"type":"completed","timestamp":"2026-07-22T10:31:04.512+00:00",
 "id":"<event-id>",
 "data":{"job_id":"<job-id>","status":"done",
         "result_url":"https://...signed...","result_expires_in":3600}}

Deliveries follow the Standard Webhooks convention. Each POST carries webhook-id, webhook-timestamp (unix seconds) and webhook-signature headers. To verify, base64-decode the secret with its whsec_ prefix stripped, HMAC-SHA256 the bytes {webhook-id}.{webhook-timestamp}. followed by the raw request body, base64-encode the digest and compare it with the v1,-prefixed value in webhook-signature. Verify against the exact bytes received, not a re-serialized copy.

Only a 2xx response counts as delivered. Anything else is retried on a widening backoff for about a day, then given up. Deliveries are HTTPS only and redirects are never followed.

Ingest callbacks are separate: they go to the callback_url of one ingest, are signed with that ingest's own callback_secret, carry input.ready / input.failed, and their envelope timestamp is unix seconds rather than a date string.

Realtime embeddings

A separate, OpenAI-compatible surface for single-request embeddings - for a query vector at request time rather than a batch of rows. Point the OpenAI SDK at it directly:

from openai import OpenAI
client = OpenAI(base_url="https://api.anex.sh/realtime/v1", api_key="<your-api-key>")

The base URL is https://api.anex.sh/realtime/v1, and it takes the same sk- API keys as the batch API above - no separate key needed. Two endpoints: GET /models lists the models on offer, and POST /embeddings returns a vector for one request:

curl https://api.anex.sh/realtime/v1/embeddings \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "qwen3-embedding-8b", "input": "a search query", "dimensions": 1024}'

input is a string or a list of strings; model is one of the realtime-served models below. Text only - realtime requests carry no image or video content, and one that does is rejected; embed media on the batch API instead (see embedding jobs), and the resulting vectors share the same space as a realtime text query on the same model at the same dimensions.

A request may carry at most 128 inputs, 262,144 characters in any one input, and 2,097,152 characters across all of them together; past any of those the request is rejected with 400. Split a larger set of texts across requests, or embed them on the batch API.

dimensions is optional and takes the same range as batch: 32 up to 4096 on qwen3-embedding-8b, 64 up to 2048 on qwen3-vl-embedding-2b (text queries only in realtime - image and video stay batch-only for this model too). encoding_format only accepts float.

A realtime query is preprocessed exactly like a batch text row: the string reaches the model verbatim, with no instruction and no chat template added, and the vector comes back at unit length. See how embedding inputs are processed for the details, including why a repeated query is not bit-identical.

Pricing is the listed batch text input rate (see models above) plus 10 percent, billed from the same credit balance as batch jobs - floored at what serving the request actually costs us upstream.

Errors worth naming beyond the ones below: 402 when the organization has no credits, 429 on a concurrency or upstream rate limit (honor the Retry-After header), 501 when the model exists but isn't served in realtime, and 503 when the realtime API is frozen or billing is unavailable.

Errors

An error response carries a detail field describing it:

400
The request or the input file failed validation - a non-HTTPS URL, a file over the size cap, too many rows, a missing instruction column for a parquet input, a malformed response_format, a missing format on a direct upload, a callback_url supplied in upload mode, or a media content part that failed the checks below found while sampling the JSONL at submit:
  • unsupported content part type '<type>': supported types are ... - a content part whose type we don't recognize at all.
  • an embedded <image|audio file|video file> exceeds the size limit (max N MB) - an inline media item over the cap in limits.
Both checks also run on every row while the job is checked and priced after submit (the estimating stage, when the job has one) - submit samples only the file's first rows, so a violation on a later row fails the job with failure_code: invalid_input and the same message, naming the line. Two more media failures surface the same way, during that full check rather than as a submit-time 400: a content part whose type your model has no encoder for, and a clip whose duration (declared or estimated) exceeds the model's cap - see the input file above.
401
The API key is invalid or no longer active.
402
The balance does not cover the estimate on approval.
404
Unknown id, or an id belonging to another organization.
409
Wrong state - approving a job that is not awaiting approval, cancelling one that is past the point of stopping, submitting an input_id that is not ready yet, or asking for a result that does not exist yet.
410
The result data has been deleted - job payloads are kept for 30 days after completion.
413
The ingest source advertises a size over the input cap. A direct upload over the cap is rejected by the upload itself instead.
422
The request does not match the endpoint's shape - a missing Authorization header, a missing or wrongly typed body field, or an unknown model id.
503
A check the request depends on could not run - the key could not be validated, or the balance could not be read, so the approval was not made. Also answered while an operator has the API frozen for maintenance (downloading a result keeps working then). Retry.

5xx responses and timeouts are transient: retry with backoff. Approving and cancelling are guarded, so a retried approval never holds credits twice - but submitting is not (see submit a job): confirm a timed-out submit before repeating it.