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.
| model | input | output | quantization | exact weights served |
|---|---|---|---|---|
| qwen3.8-27b | text + image + video | text (reasoning) | fp8 | Qwen/Qwen3.8-27B-FP8 |
| glm-5.2 | text | text (reasoning) | nvfp4 | nvidia/GLM-5.2-NVFP4 |
| qwen3.6-27b | text + image | text (reasoning) | fp8 | Qwen/Qwen3.6-27B-FP8 |
| gemma4-31b | text + image | text (reasoning) | fp8 | RedHatAI/gemma-4-31B-it-FP8-dynamic |
| qwen3-14b | text | text (reasoning) | fp8 | Qwen/Qwen3-14B-FP8 |
| qwen3-omni-30b | text + image + audio + video | text | unquantized | Qwen/Qwen3-Omni-30B-A3B-Instruct |
| qwen3-embedding-8b | text | embedding vector | unquantized | Qwen/Qwen3-Embedding-8B |
| qwen3-vl-embedding-8b | text + image + video | embedding vector | unquantized | Qwen/Qwen3-VL-Embedding-8B |
| qwen3-vl-embedding-2b | text + image + video | embedding vector | unquantized | Qwen/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 pending →
copying → ready, 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_uriis given. Theidfrom 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'treadyyet answers 409. - input_s3_uri
- Required unless
input_idis given. Theinput_s3_urifrom an ingest. Give exactly one ofinput_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_budgetyourself 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_thinkingitself keeps its own setting. Omit the field to leave the model's own default in place.trueon 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;falsestays 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), or0for no cap. When the sample shows that even the answers alone would not fit under yourmax_tokens, estimation fails and says so; raisemax_tokensor setreasoning_budget.0is 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.
422on 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.dimensionswins over this for that row.422on 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_centsis 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'sest_output_tokis always0, and itsest_input_tokcomes from an exact token count rather than a sample, so it reachescost_readyin 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
(
nulluntil estimated). An embedding job reports0: it has no completion budget. - reasoning_budget
- The effective thinking budget: yours, or the estimated one
(
nulluntil estimated). Alwaysnullon a non-reasoning model, which cannot set this field at all. - model
- The model the job ran against.
- reasoning
- The job-level thinking setting.
nullmeans 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 ownchat_template_kwargs.enable_thinkingoverrides this for that row, which this job-level field cannot report. - task_type
chat(a completion per row) orembed(a vector per row).- format
- The input file's format,
parquetorjsonl. - instruction_column
- The parquet column rendered into each row's prompt;
nullon 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
embedjob;nullmeans 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 inerror_rows. - truncated_rows
- Rows whose generation stopped on the output-token cap. Counted
separately, not in
error_rows. On a reasoning model,reasoning_budgetkeeps 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),nullbefore that. Download the result before this time; afterwards the result endpoint answers410 Gone. The job record itself (statuses, counts, cost) stays queryable. - failure_code, failure_reason
- Set on a failed job.
failure_codeis a stable value (invalid_inputorinternal_error);failure_reasonis a readable message. Three media-specificinvalid_inputreasons: 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 - seeduration_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 (nomax_tokens, or a reasoning model with noreasoning_budget) can also fail during estimation withinvalid_inputwhen 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 explicitmax_tokenson 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 raisemax_tokens, setreasoning_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_tokandest_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_urlandresult_expires_in, so the event is directly actionable. - failed
- The job failed. Carries
status,partial, thefailure_code/failure_reasonwhen 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 missingformaton a direct upload, acallback_urlsupplied 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.
estimatingstage, when the job has one) - submit samples only the file's first rows, so a violation on a later row fails the job withfailure_code: invalid_inputand 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_idthat is notreadyyet, 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
Authorizationheader, 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.