API reference
Everything the service accepts and everything it can answer.
The limits below are fetched from GET https://api.nums.world/v1/limits when this page
is served, so they are the ones the service is enforcing right now.
Base URL and authentication
https://api.nums.world
Send the key either way:
Authorization: Bearer csl_... X-Causilo-Key: csl_...
Routes
| Method | Path | What it does | Key |
|---|---|---|---|
POST | /v1/predict | Predict. Multipart with context, query and params, or one Parquet file. | yes |
GET | /v1/limits | What this deployment accepts. Numbers a client can check before sending. | no |
GET | /v1/estimate | Whether a table of a given shape will be accepted, and how long it will take. | no |
GET | /v1/usage | What this credential has spent this month and today. | yes |
GET | /v1/contexts | The contexts stored for this caller. | yes |
DELETE | /v1/contexts/{id} | Release one stored context. | yes |
GET | /v1/ping | Whether the service can take a request. | no |
There is no cancel. A request you abandon runs to the end and is billed in
full — Limits explains why, and POST /v1/cancel answers 501 rather than
404 so that a client calling it is told the feature is missing
instead of hunting for a typo.
Sending a prediction
The Python client builds all of this for you; the quickstart is one call in each direction, classification and regression. What follows is the wire format, for callers not using the client.
POST /v1/predict takes multipart with three parts:
context and query as Parquet
(application/vnd.apache.parquet), and params as JSON. A
single Parquet file also works, carrying the same JSON in its
causilo-params schema metadata; the rows whose target is missing
are the query.
Feature columns must appear in both tables with the same names in the same order. The model reads them by position.
What a column may hold
The first question anybody asks, answered by the code that refuses.
Everything below is enforced, and every refusal is a 422 whose
detail names the column.
| In your table | What happens |
|---|---|
| Missing values in a feature | Fine, send them as they are. The model handles them natively. Imputing first is work you do not need to do and information you throw away. |
| Strings | Fine. They are treated as categories. No encoding on your side. |
| Columns on wildly different scales | Fine. No scaling or normalising needed. |
| A query column that is empty on every row | Refused. Left alone the prediction would ignore that column silently. |
| Dates, times, durations | Refused. Split them into numeric parts — year, month, day of week — or send epoch seconds. Preprocessing would treat a date as a category rather than a number, and the answer would be worse without saying so. |
Decimal columns |
Refused. This is what a Postgres or BigQuery NUMERIC
becomes in pandas; cast to float first. |
| Lists, dicts, structs | Refused. |
| Infinities, or values at or above 2.4e+151 | Refused. Rescale the column or drop those rows. |
float16 | Widened to float32 for you. |
| Duplicate column names | Refused. |
| Your DataFrame index | Dropped, never sent. Put anything you need to keep in a column. |
Feature columns must match in name and order between the two tables, and the same column must hold the same kind of value in both. The model reads them by position, so a reordered query is refused rather than answered with confident nonsense.
The context must have something that varies. A context whose every feature column holds one value is refused: there is nothing to predict from.
What the target column may hold
The target is in the context and absent from the query. Empty rows in a single Parquet file are the rows to predict.
| Rule | Applies to |
|---|---|
| No missing values in the context's target | both models |
| At most 64 distinct values, and at least two | causilo-clf |
| Whole numbers or strings, not fractions | causilo-clf |
| Numeric, finite, under 3.4e38 | causilo-reg |
| Not a datetime | both — predictions would come back as epoch integers with no unit |
params
context_id is the one worth reading about before you use it:
storing a context saves the upload and not the cells, measured both ways in
Asking one table many
questions.
| Field | Type | Required | Meaning |
|---|---|---|---|
model | string | yes | causilo-clf or causilo-reg. |
target | string | with a context table | The column holding the answers. Not read when context_id is given: the stored context already knows it. |
output_type | string | no | Per task, see below. Defaults to the first for the task. |
quantiles | list of numbers | with output_type=quantiles | Between 0 and 1. |
cache_context | boolean | no | Store this request's context table and return a handle. |
context_id | string | no | Use a context stored earlier. Send no context part with it. |
Unlisted fields are ignored, so a caller moving from another API does not have to strip its knobs out.
output_type
| Task | Allowed |
|---|---|
classification | probas, preds (default probas) |
regression | mean, median, quantiles (default mean) |
What comes back
prediction is one value per query row.
metadata carries the shape of the request, the elapsed time,
quota, and served_by — the image, the checkpoint and
the ensemble width that produced the numbers. Compare
served_by before comparing predictions across runs.
Limits
Two different things are called limits here, and they come from different places, so they are two tables.
What your account is allowed
The free tier's allowance. It belongs to your account rather than to the
service, so GET /v1/limits does not carry it — what you have
left is at GET https://api.nums.world/v1/usage, which needs your key, and every
successful response carries the same figures.
| Cells per month | 25,000,000 |
| Cells per day | 6,250,000 |
| Requests per minute | 60 |
| Requests per hour | 1500 |
What one request may be
GET /v1/limits returns these without a key, so a client can check
a table locally before uploading it.
Usage, limits and
cancellation does exactly that, and handles both rate limits.
| Rows | 300,000 |
| Columns | 4,096 |
| Cells in one request | 60,000,000 |
| Classes | 64 |
| Request bytes | 1,073,741,824 |
| Response bytes | 33,554,432 |
| Seconds of GPU per request | 300 |
Two ceilings, and which one you meet depends on how wide your table is. max_rows is a flat cap; the deadline refuses a table the service predicts will take longer than 300 seconds, before any GPU time is spent. On the production hardware a narrow table is stopped by the cap — 300,000 rows, context and query together — and a wide one by the deadline, at about 59,939 context rows by a thousand columns. The two swap over somewhere near two hundred columns. The refusal is request_too_large either way, and its detail names a row count that fits at your column count — GET https://api.nums.world/v1/estimate answers the same question before you send anything.
Asking about your own table
GET https://api.nums.world/v1/estimate answers the question the table above cannot:
whether your shape will be accepted. It needs no key, uploads
nothing, and runs the same checks admission control runs, so its verdict is
the one a real request would get.
$ curl 'https://api.nums.world/v1/estimate?n_context=300000&n_query=1000&n_features=10'
{
"accepted": false,
"cells": 3010000,
"counts_against_allowance": 3010000,
"estimated_seconds": 347.88,
"model": "causilo-clf",
"deadline_s": 300.0,
"max_context_rows": 277583,
"code": "request_too_large",
"reason": "This table is predicted to take about 348 s of GPU time, over
this deployment's 300 s limit ..."
}
Everything in the answer is a prediction from a fitted model except the
cell count, which is exact. max_context_rows is how many context
rows fit at your column count — the number a refusal would have named,
given before the upload rather than after it. When a shape is refused,
reason says why, in the words the refusal would use.
Nothing is invoiced. These are an allowance: when it runs out the service answers 429 until it resets, and there is no bill and no overage.
Sending more than one call at a time
The rate limits above are not a parallelism budget. Calls beyond what the service is running are queued and answered in turn — none is refused for being concurrent, and the wait is part of your latency rather than an error. Two things follow, and both hold whatever capacity we are running.
How much fanning out buys you depends on the table. A call spends time on things that are not the GPU — the table goes to storage, the request queues, the answer comes back — and those overlap between calls while the GPU work does not. So the smaller the table, the more a worker pool helps. Measured on 2026-09-18, four calls against one instance:
| Context rows | Four in sequence | Four at once | |
|---|---|---|---|
| 4,000 × 12 | 3.03 s | 1.78 s | 1.7× faster |
| 20,000 × 12 | 9.24 s | 8.13 s | 1.1× faster |
At 20,000 rows the card is the bottleneck and the calls serialise on it whatever you do. Two or three workers are worth having for small tables; a large pool is not.
Either way, if you have many small queries against one table, send them as one call with more query rows rather than as many calls. Query rows cost almost nothing next to context rows, so one call of a thousand queries is far faster than a thousand calls of one — and unlike fanning out, that holds at every table size.
A large table delays what is behind it. A call that runs for four minutes is four minutes the queue is not moving. Where latency matters, keep tables small and store the context rather than resending it.
elapsed_s in the response is the prediction alone and excludes
any time the call spent queued, so a client measuring the wall clock will see
a larger number. Both are right; the difference is the queue.
A cell is one value in a table you send, counting context and query rows together and excluding the target column. A request naming a stored context counts the context rows too — storing saves the upload, not the cells.
A request that fails does not count against the allowance, and neither does one that produces a response too large to return.
A request you abandon does count, in full. Hanging up does not reach
the machine running the prediction — it finishes the work whatever the caller
does — and a call that has started cannot be cancelled;
POST /v1/cancel answers 501 for that reason. The client
reports this as CausiloTimeout and does not retry, because a
retry would count again. Give it a timeout long enough that you read the answer
you have already spent.
Errors
Errors are RFC 7807 problem documents. Branch on code; the
status is in the body as well as on the response. Every one
carries a request_id, also sent as the X-Request-Id
header, and a retryable boolean that says whether sending the
same request again can succeed. A 429 and a 503 also
carry Retry-After, always in whole seconds. Successful responses
carry X-Request-Id as well, in the header only.
Two statuses mean the work already ran. A
504 with code timeout means the service stopped
waiting, not that the work stopped: the prediction runs to the end and counts
in full. A 502 with code upstream_unavailable means
it ran and answered with something we could not read. Both carry
retryable: false, because a resend is charged again and buys
nothing — send a smaller table instead. The Python client refuses to
retry either one whatever the body says.
The type of every error links back to its row below. The Python client raises one exception class per group; the
cookbook lists which
is which.
| code | status | What happened | What to do |
|---|---|---|---|
unauthorized | 401 | No key, a wrong key, or one that has been revoked. | Check the key. A revoked key stops working within about a minute. |
validation_failed | 422 | The tables or the params do not fit the contract. | The detail names the column or field. Feature columns must match in name and order. |
bad_request | 400 | The body could not be parsed at all. | Check the content type and that both parts are present. |
empty_body | 400 | The request arrived with no body. | Send the tables. A proxy that strips bodies will produce this. |
payload_too_large | 413 | The body is over this deployment's ceiling. | Send fewer rows, or store the context once and name it afterwards. |
request_too_large | 413 | The table would take longer than the deployment allows. | The detail says how many context rows fit at your column count. |
context_not_found | 404 | The stored context has expired or been released. | Send the table again. The Python client does this for you. |
quota_exceeded | 429 | The monthly or daily cell cap. | The body says which cap, what is used and when it resets. |
rate_limited | 429 | Too many requests in the minute or hour window. The cell caps are separate. | Wait for the Retry-After seconds and resend. |
response_too_large | 413 | The prediction is bigger than can be returned. | Ask for fewer query rows. Nothing counts against your allowance. |
not_ready | 503 | The service is starting or being replaced. | Retry. This is not an error on your side. |
unavailable | 503 | The GPU was busy for the whole wait, the service is short of memory, or it is shutting down. | Retry. Nothing counts against your allowance. |
upstream_unavailable | 503 | A store the service depends on could not be reached. | Retry. |
inference_failed | 500 | The prediction failed inside the service. | Nothing counts against your allowance. Quote the request_id if it repeats. |
client_gone | 499 | The caller disconnected before the answer was returned. | Recorded in the logs only. You will not receive this one. |
cancel_key_in_use | 409 | Two requests sent the same cancel key. | Use a fresh key per request. |
not_found | 404 | No such route. | Check the path. |
method_not_allowed | 405 | Wrong method for that route. | Check the method. |
not_implemented | 501 | The route exists but this deployment cannot do it. | POST /v1/cancel is the only one. A call that has started cannot be stopped. |
timeout | 504 | The service did not answer within its own wait. | The work is still running and still counts. Do not retry; send a smaller table. |