Skip to Content

The NexOR Optimization API

Submit a problem, get a solution. One REST call in, one answer out, stable under /solve/v1 and authenticated with a bearer key. Every endpoint on this page runs today; the Python SDK is a preview and is marked as such.

Free tier, no card. Your first solve takes minutes.

  1. POST /problems
  2. queued the problem has an id
  3. POST /problems/{id}/wait
  4. running the wait is held open here
  5. finished the wait returns, or your webhook fires
  6. GET /problems/{id}/result
  7. solution you have the answer
Get started

A solver you call over HTTP

You send a mathematical optimisation problem as JSON. We run it on our solvers and hand back the solution. It is domain agnostic: linear programs, mixed-integer models, routing, scheduling, whatever you can express, all travel the same envelope.

Every integration has the same three moves: submit a problem and get an id, wait on one request or let a signed webhook reach you, then fetch the solution. The next section does all three in under a minute.

We treat your problem body as opaque. The manager validates the envelope and meters compute, but never reads your model. That is what keeps one API generic across every problem class.

Get started

Your first solve, in three steps

From zero to a real answer in minutes. A two-product production mix: maximise margin under machine-hour and material limits. The optimum is 30 chairs and 5 tables, objective 1750.

Test it live first

No account, no key, free. Write a model in Python and run it against the real solver, in your browser.

  1. Copy an API key

    Give it any name and pick an expiration, that is the whole form. This key is what identifies you on every API call you make. The secret is shown once, so copy it right away. This is the only step that happens in the browser.

    Open API keys
  2. Submit, wait, fetch

    Paste your key into the example below and run it: submit the envelope, hold one request open until the problem ends, then fetch the solution. This is the shortest path from a laptop, where nothing can receive a webhook. Once you run a server, register a webhook and drop the middle step.

# Set these once. BASE is your NexOR host; the key comes from your portal.
export BASE="https://<your-nexor-host>/solve/v1"
export API_KEY="<your-api-key>"

# 1. Submit the envelope (saved as envelope.json).
curl -s -X POST "$BASE/problems" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d @envelope.json
# -> {"problem_id":"prb_9f3c...","status":"queued","time_limit_seconds":5,...}

# 2. Wait. The request stays open until the problem ends, and timeout_seconds
#    is capped at 60, so repeat the call while the status is queued or running.
#    A server registers a webhook instead and skips this step altogether.
curl -s -X POST "$BASE/problems/prb_9f3c.../wait?timeout_seconds=60" \
  -H "Authorization: Bearer $API_KEY"
# -> the snapshot, with "outcome" set once the solve has ended

# 3. Fetch the solution. Without inline=1 the route answers 302 to a presigned
#    object URL, so a plain curl prints nothing; pass -L to follow it instead.
curl -s "$BASE/problems/prb_9f3c.../result?inline=1" \
  -H "Authorization: Bearer $API_KEY"
# -> {"problem_id":"prb_9f3c...","outcome":"solved","solution":{
#     "outcome":"solved","termination_status":"OPTIMAL","objective_value":1750.0,
#     "result":{"values":{"chairs":30.0,"tables":5.0}}}}
import os, time, requests

BASE = os.environ["SOLVE_BASE"]      # https://<your-nexor-host>/solve/v1
HEAD = {"Authorization": f"Bearer {os.environ['SOLVE_API_KEY']}"}

envelope = {
    "api_version": "1",
    "problem": {
        "variables": [{"name": "chairs", "lower": 0}, {"name": "tables", "lower": 0}],
        "constraints": [
            {"expr": "4*chairs + 8*tables <= 160", "name": "machine_hours"},
            {"expr": "2*chairs + 3*tables <= 75", "name": "material"},
        ],
        "objective": {"sense": "max", "expr": "45*chairs + 80*tables"},
    },
    "solver": "highs",
    "options": {"compute_preset": "cpu-standard", "time_limit_seconds": 5},
}

# 1. Submit.
prb = requests.post(f"{BASE}/problems", json=envelope, headers=HEAD).json()
print("submitted", prb["problem_id"], prb["status"])

# 2. Wait. The request is held open until the problem ends; the 60 second cap
#    means a longer solve answers a snapshot that is not terminal yet, so the
#    call is repeated. For live progress open GET /problems/{id}/events instead.
RUNNING = {"queued", "running"}
while True:
    reply = requests.post(
        f"{BASE}/problems/{prb['problem_id']}/wait",
        params={"timeout_seconds": 60},
        headers=HEAD,
    )
    if reply.status_code == 429:                 # asked to slow down
        time.sleep(int(reply.headers.get("Retry-After", 5)))
        continue
    reply.raise_for_status()
    state = reply.json()
    if state["status"] not in RUNNING:
        break

# 3. Fetch the solution.
reply = requests.get(f"{BASE}/problems/{prb['problem_id']}/result", headers=HEAD)
while reply.status_code == 429:              # a burst of submits spent the budget
    time.sleep(int(reply.headers.get("Retry-After", 5)))
    reply = requests.get(f"{BASE}/problems/{prb['problem_id']}/result", headers=HEAD)
reply.raise_for_status()                     # 410 once past retention
sol = reply.json()
print("outcome:", sol["outcome"])                              # "solved"
print("objective:", sol["solution"]["objective_value"])        # 1750.0
print("values:", sol["solution"]["result"]["values"])  # {"chairs": 30.0, ...}
The envelope.json used above
envelope.json
{
  "api_version": "1",
  "problem": {
    "variables": [
      {
        "name": "chairs",
        "lower": 0
      },
      {
        "name": "tables",
        "lower": 0
      }
    ],
    "constraints": [
      {
        "expr": "4*chairs + 8*tables <= 160",
        "name": "machine_hours"
      },
      {
        "expr": "2*chairs + 3*tables <= 75",
        "name": "material"
      }
    ],
    "objective": {
      "sense": "max",
      "expr": "45*chairs + 80*tables"
    }
  },
  "solver": "highs",
  "options": {
    "compute_preset": "cpu-standard",
    "time_limit_seconds": 5,
    "tags": [
      "quickstart"
    ]
  }
}

Ready for the details? Full API reference

Get started

Bearer keys

Every request carries your API key as a bearer token. Keys are scoped to the optimisation API, so a leaked key cannot touch anything else in your account.

Authorization: Bearer <your-api-key>

Create your first key in the portal. After that you can list, mint and revoke keys over the API itself (see API keys). Keep keys server-side, and rotate them with no downtime when needed.

Core concepts

How a solve flows

Solving is asynchronous. Submit returns immediately with an id and a queued status; the solve runs on our infrastructure; the outcome comes to you.

POST /problems                 -> id, status: queued
POST /problems/{id}/wait       -> held open while the solver works
        the solver picks it up      -> running
        it finishes                 -> finished  (the wait returns)
GET  /problems/{id}/result     -> the solution itself

Three ways to know it is done. A webhook reaches you the moment the problem ends and costs you no request, which is what a server should use. POST /problems/{id}/wait holds one request open until then, which is what a script on a laptop should use. GET /problems/{id}/events streams the same events over one connection, and carries the solver's progress while it works. Every payload is thin, so you still fetch the solution with one call.

Core concepts

The submit envelope

One versioned wrapper around four parts. We own and validate the wrapper; the problem inside is yours.

api_version
The wire version. Always "1" today.
problem
Your model: variables, constraints, an objective. Opaque to us, validated by the solver.
solver
The engine to run: a concrete solver name, or a meta-solver name, which is a named set of member engines, any of which may take the job. Solver tuning travels inside problem.
options
time_limit_seconds, compute_preset, webhook, idempotency_key and tags. All optional.
options.compute_preset
The compute the solve reserves, by code (cpu-standard, gpu-standard). Omit it and the engine runs on its own default. GET /solvers lists every preset and which engines offer which.
envelope.json
{
  "api_version": "1",
  "problem": {
    "variables": [
      {
        "name": "chairs",
        "lower": 0
      },
      {
        "name": "tables",
        "lower": 0
      }
    ],
    "constraints": [
      {
        "expr": "4*chairs + 8*tables <= 160",
        "name": "machine_hours"
      },
      {
        "expr": "2*chairs + 3*tables <= 75",
        "name": "material"
      }
    ],
    "objective": {
      "sense": "max",
      "expr": "45*chairs + 80*tables"
    }
  },
  "solver": "highs",
  "options": {
    "compute_preset": "cpu-standard",
    "time_limit_seconds": 5,
    "tags": [
      "quickstart"
    ]
  }
}

Machine-readable JSON Schemas: admission_request_v1.json

The schema types problem as an opaque object, because the manager never reads it. The section below is the shape the solvers do read.

Core concepts

Describing a model

Three keys inside problem: the variables you decide, the constraints they must respect, and one objective. Expressions are strings, and they are linear.

variables
A list. Each entry needs a name of letters, digits and underscores, not starting with a digit. type is continuous (the default), integer or binary. lower and upper are optional numeric bounds; omit one and the variable is unbounded on that side.
constraints
A list of expr strings, each with an optional name that comes back in the diagnosis. An expr is terms on the left, one number on the right, joined by <=, >= or ==.
objective
sense is min or max; expr uses the same terms with no comparison and no constant. A constant only shifts the value, so it is refused rather than silently dropped.

Terms. A term is a coefficient, an asterisk and a variable name: 4*chairs. A coefficient of one may be written as the name alone. Terms join with a plus or a minus and a space either side: 4*chairs + 8*tables - 2*offcuts. Anything that is not linear is not accepted: no product of two variables, no functions, no exponents.

Declaring a variable integer or binary is what turns a linear program into a mixed-integer one. Nothing else in the envelope changes, and the price is still the base fee plus the seconds. The worked example below is a complete model with five binary variables.

Core concepts

Status and outcome

Two frozen vocabularies. status is the lifecycle of the problem; once it reaches finished, outcome classifies the answer.

StatusMeaning
pending_inputAccepted, still waiting for the model you are uploading separately.
queuedAccepted and waiting to be solved.
runningAn engine has picked it up and is solving.
finishedAn answer exists (see outcome). The solution is ready to fetch.
failedThe service could not run the solve; error_code says why. A model the chosen solver cannot handle is finished instead (outcome error).
cancelledCancelled by you, or expired while queued.
OutcomeMeaning
solvedThe answer that was asked for: an optimal or within-tolerance solution. result holds the point, objective_value is present.
no_solutionProven: no answer exists as asked (infeasible or unbounded). termination_status carries the exact diagnosis.
limitA budget stopped the solve first (time, memory, iterations). If an incumbent exists, result holds it and objective_value is present.
errorThe solver ran and broke on this model: numerical failure, invalid model, or a constraint class this engine cannot express. Still an answer about this model. Not retried. Pick another solver or reformulate.

Branch on these two fields only. The solution also carries the solver's verbatim MOI termination_status (such as OPTIMAL or TIME_LIMIT) as diagnosis detail; treat it as display text, not a contract.

Core concepts

Idempotency

Set options.idempotency_key to a unique string. If a network blip makes you retry, the second submit returns the original problem instead of creating a duplicate (and a duplicate credit hold).

"options": { "idempotency_key": "order-4821-solve", "time_limit_seconds": 5 }

Keys are scoped to your account. Reusing one always returns the first problem created with it.

Core concepts

Errors

Failures come back with the matching HTTP status and a JSON body. Match on code, show message, and read details when a field is at fault.

{ "error": { "code": "invalid_envelope", "message": "...", "details": {...} } }
HTTPCodeWhen
401invalid_keyMissing, unknown or revoked bearer key. Answered in the usual error envelope, with a WWW-Authenticate: Bearer header.
402insufficient_creditsThe maximum cost of the submission is more than the balance available. Lower time_limit_seconds, or choose a cheaper compute preset.
403customer_suspendedThe account is suspended.
404not_foundNo such problem, solution, or key for this customer.
409last_keyRefused: you cannot revoke your only API key.
410purgedThe solution payload is past its retention window.
413envelope_too_largeThe request body exceeds 20 MB.
422invalid_envelopeThe envelope failed validation. details lists the fields.
422unknown_solverThe requested solver is not in the catalogue.
422compute_preset_not_availableThat compute preset does not exist, or the requested solver does not run on it.
429rate_limitedToo many requests for this account. Retry-After says how long to wait.
Core concepts

Limits and credits

Two things bound your usage: how many solves run at once, and how long each may run.

Concurrency
Your concurrency_cap (see GET /account) is how many problems solve at the same time. Beyond it, new submits wait in the queue.
Credits
A solve costs its engine's base fee plus its rate on the compute preset it runs on, per second. The maximum cost is that rate over the whole of time_limit_seconds, and it is reserved when the problem is admitted; GET /quote answers the same figure before you submit. Settlement bills the seconds actually used, so a run that finishes early costs less and a cancelled one bills what it ran.

Many solves at once? GET /events carries the whole account on one connection, and ?problems=a,b,c narrows it to as many as 100. For the answer itself, a webhook costs you no request at all.

API reference

Problems

Submit optimisation problems and follow them to a result.

POST /problems

Submit one envelope. Returns the id and the effective limits.

envelopebody
A versioned submit envelope (see The envelope).
Request
curl -X POST "$BASE/problems" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d @envelope.json
Response
{
  "problem_id": "prb_9f3c...",
  "status": "queued",
  "time_limit_seconds": 5,
  "pricing": {
    "compute_preset": "cpu-standard",
    "max_runtime_seconds": 5,
    "max_cost": 6,
    "rates": {"highs": {"base_fee": 1, "effective_rate": 1.0}}
  }
}

GET /problems/{id}

Status, queue position, live progress, and the solution link when ready.

Request
curl "$BASE/problems/prb_9f3c..." \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "problem_id": "prb_9f3c...",
  "status": "running",
  "progress": {"gap": 0.03, "incumbent": 1690.0},
  "outcome": null,
  "solution_url": null
}

POST /problems/{id}/wait

Hold the request open until the problem is terminal, then answer its state.

timeout_secondsquery
How long to hold, 1 to 60 seconds, 60 by default. On expiry you get the state as it stands, not an error, so a solve longer than the cap needs the call repeated.
Request
curl -X POST "$BASE/problems/prb_9f3c.../wait?timeout_seconds=60" \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "problem_id": "prb_9f3c...",
  "status": "finished",
  "outcome": "solved",
  "termination_status": "OPTIMAL",
  "usage": {"billable_seconds": 0.4, "wall_seconds": 0.4,
            "compute_preset": "cpu-standard"},
  "cost": {"credits": 2, "settled_at": "2026-09-18T09:12:05Z"}
}

GET /problems/{id}/events

Server-sent events for one problem. Drop the id segment and call /events for the whole account.

kindsquery
Any of status, progress and log, comma separated. All three on one problem; the account stream carries status and progress, and refuses log.
problemsquery
On /events only: up to 100 comma-separated ids. The whole account when you leave it out.
Last-Event-IDheader
The id of the last event you handled. The stream resumes from it, so a dropped connection loses nothing.
Request
# one problem
curl -N "$BASE/problems/prb_9f3c.../events" \
  -H "Authorization: Bearer $API_KEY"

# every problem on the account, on one connection
curl -N "$BASE/events" -H "Authorization: Bearer $API_KEY"
Response
event: problem.updated
id: 1758306411.4.0
data: {"v":1,"type":"problem.updated","problem_id":"prb_9f3c...",
       "data":{"status":"running","outcome":null}}

event: problem.updated
id: 1758306413.5.0
data: {"v":1,"type":"problem.updated","problem_id":"prb_9f3c...",
       "data":{"status":"finished","outcome":"solved"}}

GET /problems

List your problems, or read many at once with ?ids=a,b,c.

idsquery
Comma-separated ids for a direct multi-get (200 at most).
statusquery
Filter by lifecycle status.
tagquery
Filter by a tag you set in options.tags.
limit / offsetquery
Page window (limit 200 at most).
Request
# recent problems, newest first
curl "$BASE/problems?status=running&limit=20" \
  -H "Authorization: Bearer $API_KEY"

# bulk status: one call for many ids (<=200)
curl "$BASE/problems?ids=prb_a,prb_b,prb_c" \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "problems": [
    {"problem_id": "prb_a", "status": "finished", ...},
    {"problem_id": "prb_b", "status": "running", ...}
  ]
}

POST /problems/{id}/cancel

Cancel a queued or running problem. Time already consumed stays billable.

Request
curl -X POST "$BASE/problems/prb_9f3c.../cancel" \
  -H "Authorization: Bearer $API_KEY"
Response
{"problem_id": "prb_9f3c...", "status": "cancelled"}
API reference

Solutions

Retrieve results, one at a time or in bulk.

GET /problems/{id}/result

The full solution envelope. Fetching it completes delivery.

Request
# 302 to a presigned URL by default; inline=1 returns the body itself.
curl "$BASE/problems/prb_9f3c.../result?inline=1" \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "problem_id": "prb_9f3c...",
  "outcome": "solved",
  "solution": {
    "solver_used": "highs",
    "outcome": "solved",
    "termination_status": "OPTIMAL",
    "objective_value": 1750.0,
    "result": {"values": {"chairs": 30.0, "tables": 5.0}},
    "metering": {"wall_seconds": 0.4}
  }
}
API reference

Solvers and account

The engine catalogue and your account state.

GET /solvers

The engine catalogue with each engine's prices, and every compute preset a submission may name.

Request
curl "$BASE/solvers" -H "Authorization: Bearer $API_KEY"
Response
{
  "solvers": [
    {"name": "highs", "display_name": "HiGHS",
     "description": "LP, MIP", "availability": "ok",
     "pricing": {
       "base_fee": 1, "runtime_rate": 1.0,
       "default_preset": "cpu-standard",
       "presets": [
         {"code": "cpu-standard", "name": "CPU Standard",
          "multiplier": 1.0, "is_default": true,
          "effective_rate": 1.0},
         {"code": "cpu-performance", "name": "CPU Performance",
          "multiplier": 2.5, "is_default": false,
          "effective_rate": 2.5}
       ]}}
  ],
  "compute_presets": [
    {"code": "cpu-standard", "name": "CPU Standard",
     "description": "2 vCPU, 4 GiB memory", "is_gpu": false}
  ]
}

GET /quote

What a submission would cost, from the rule the intake holds against. Same engine, same preset, same runtime, same number.

solverquery
Required. An engine or a meta-solver name.
compute_presetquery
Optional. Defaults to that engine's own default preset.
time_limit_secondsquery
Optional. Defaults to the platform default and is capped at the platform maximum.
Request
# what a 60 second solve on HiGHS would cost, on the larger CPU preset
curl "$BASE/quote?solver=highs&compute_preset=cpu-performance&time_limit_seconds=60" \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "solver": "highs",
  "compute_preset": "cpu-performance",
  "max_runtime_seconds": 60,
  "max_cost": 151,
  "rates": {"highs": {"base_fee": 1, "effective_rate": 2.5}}
}

max_cost is what a submission on these terms reserves: the dearest candidate running the whole runtime. A meta-solver answers one rate per member engine that offers the preset.

GET /account

Credit balance, concurrency cap, account status.

Request
curl "$BASE/account" -H "Authorization: Bearer $API_KEY"
Response
{
  "name": "Acme Corp",
  "status": "active",
  "concurrency_cap": 4,
  "credit_balance": 4820,
  "credit_available": 4770
}
API reference

Webhooks

Configure and inspect your outbound callbacks. See the Webhooks guide for signatures.

GET /account/webhook

Read your callback URL, your event filters, and the first characters of the signing secret. The secret itself is never returned; rotate if you have lost it.

Request
curl "$BASE/account/webhook" -H "Authorization: Bearer $API_KEY"
Response
{
  "url": "https://acme.example/hook",
  "events": ["terminal"],
  "secret_prefix": "whsec_<first 8>"
}

PUT /account/webhook

Set or clear the callback URL, and choose the events it receives. Validated (https, reachable) on save.

urlbody
The https endpoint, or an empty string to clear it.
Request
curl -X PUT "$BASE/account/webhook" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://acme.example/hook", "events": ["terminal", "progress"]}'
Response
{
  "url": "https://acme.example/hook",
  "events": ["terminal", "progress"],
  "secret_prefix": "whsec_<first 8>"
}

POST /account/webhook/rotate

Mint a fresh signing secret. The previous one keeps signing for 24 hours, so deliveries in flight still verify.

Request
curl -X POST "$BASE/account/webhook/rotate" \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "url": "https://acme.example/hook",
  "events": ["terminal"],
  "secret_prefix": "whsec_<first 8>",
  "secret": "whsec_<the full secret, shown once>"
}

POST /account/webhook/test

Send a signed sample event to your endpoint right now.

Request
curl -X POST "$BASE/account/webhook/test" \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "id": "dlv_4c1e...",
  "event_id": "evt_8a02...",
  "type": "test",
  "status": "delivered",
  "attempts": [{"at": "2026-09-18T09:12:04Z", "http_status": 200, "error": null}]
}

GET /account/webhook/deliveries

Recent delivery attempts with their status and last error.

Request
curl "$BASE/account/webhook/deliveries" \
  -H "Authorization: Bearer $API_KEY"
Response
{
  "deliveries": [
    {"id": "dlv_4c1e...", "event_id": "evt_8a02...",
     "type": "problem.updated", "problem_id": "prb_a",
     "subscriber": "account", "url": "https://acme.example/hook",
     "status": "dead", "attempts": [{"at": "2026-09-18T09:12:04Z",
       "http_status": 500, "error": "server error"}]}
  ],
  "next_cursor": null
}

POST /account/webhook/deliveries/{id}/retry

Re-send a dead-lettered delivery.

Request
curl -X POST "$BASE/account/webhook/deliveries/dlv_4c1e.../retry" \
  -H "Authorization: Bearer $API_KEY"
Response
{"id": "dlv_4c1e...", "status": "pending", "attempts": [...]}
API reference

API keys

Manage keys programmatically. The first key is created in the portal.

GET /account/keys

List your keys by prefix. The secret is never shown again.

Request
curl "$BASE/account/keys" -H "Authorization: Bearer $API_KEY"
Response
{
  "keys": [
    {"id": 41, "name": "prod", "prefix": "3f9c1a2b",
     "created_at": "2026-07-01T09:00:00Z", "expires_at": null}
  ]
}

POST /account/keys

Mint a new key. The secret is returned exactly once.

namebody
A label for the key (optional).
expires_daysbody
Days until expiry, or omit for none.
Request
curl -X POST "$BASE/account/keys" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "prod-2026", "expires_days": 365}'
Response
{"id": 42, "name": "prod-2026", "prefix": "7b2e4f9d",
 "key": "<full key, shown once>"}

DELETE /account/keys/{id}

Revoke a key. Your last remaining key is protected (409).

Request
curl -X DELETE "$BASE/account/keys/41" \
  -H "Authorization: Bearer $API_KEY"
Response
{"revoked": 41}
Guides

Webhooks

Have us call you. Set options.webhook per problem, or a default endpoint on your account, and receive a signed event the moment a problem finishes. This is the cheapest way to run in production: it costs you no request of your own.

Events. A subscription chooses what it receives through options.webhook.events, per problem or on the account default. terminal, the default, sends problem.updated on a terminal status and the problem.delivered that follows the first download. running sends every problem.updated. progress sends problem.progress while the solve runs, each tick a cumulative snapshot where the last one wins. settled sends problem.settled, once the cost is known. Payloads are thin (status, outcome, termination_status and a solution_url), so fetch the body with one call. Log lines are never delivered to a webhook: they exist on the per-problem stream alone.

Verify every delivery. We sign with the Standard Webhooks scheme, so a library you already have may do this for you. Three headers carry it: webhook-id, webhook-timestamp, and webhook-signature, the last a base64 HMAC-SHA256 over {id}.{timestamp}.{body} under your signing secret, spelled v1,.... Refuse a timestamp more than five minutes old, and accept any one of the space-separated values: a rotation signs with both secrets for 24 hours, so a receiver that has not picked up the new one still finds a value it can verify.

verify.py
import base64, hashlib, hmac, json, os, time

# The Standard Webhooks scheme. The secret is shown once by /account/webhook and
# spells its key in base64 after a whsec_ prefix; sign with the decoded bytes.
SECRET = os.environ["SOLVE_WEBHOOK_SECRET"]          # "whsec_<base64>"
KEY = base64.b64decode(SECRET.removeprefix("whsec_"))
TOLERANCE = 300                                      # five minutes, as we send

def handle(request):
    body = request.get_data()                        # the raw bytes, unparsed
    msg_id = request.headers["webhook-id"]           # "evt_..." and the dedupe key
    sent_at = int(request.headers["webhook-timestamp"])
    if abs(time.time() - sent_at) > TOLERANCE:       # refuse a replayed delivery
        return "stale timestamp", 400

    signed = f"{msg_id}.{sent_at}.".encode() + body
    expect = "v1," + base64.b64encode(
        hmac.new(KEY, signed, hashlib.sha256).digest()
    ).decode()
    # A rotation signs with both secrets for 24 hours, so the header may carry
    # several space-separated values and any one of them matching is enough.
    presented = request.headers["webhook-signature"].split(" ")
    if not any(hmac.compare_digest(expect, value) for value in presented):
        return "bad signature", 400

    event = json.loads(body)
    if event["type"] == "problem.updated" and event["data"]["status"] == "finished":
        fetch_result(event["problem_id"])  # payloads are thin: pull the body
    # every other type ("problem.delivered", "problem.progress", "problem.settled",
    # "test") just needs the 200 back
    return "", 200

Retries. A delivery retries with backoff for 24 hours, then is marked dead. Inspect the log at GET /account/webhook/deliveries and replay a dead one with POST /account/webhook/deliveries/{delivery_id}/retry, from the portal or your own code; rows are kept 90 days. The same event may arrive more than once, so treat webhook-id as the dedupe key.

Guides

Managing API keys

Your first key is created in the portal (you need a key to call the API). After that, manage them over the API so rotation can be automated.

Rotate with no downtime. Create the successor, deploy it, then revoke the old key. Revoking your only key is refused so you cannot lock yourself out.

rotate.sh
# Rotate with no downtime: mint the successor, switch over, then revoke.
# 1. Create the replacement (the secret is shown exactly once).
curl -s -X POST "$BASE/account/keys" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "prod-2026"}'
# -> {"id":42,"name":"prod-2026","prefix":"7b2e4f9d","key":"<full key, shown once>"}

# 2. Deploy the new key everywhere, then revoke the old one by id.
curl -s -X DELETE "$BASE/account/keys/41" -H "Authorization: Bearer $NEW_KEY"
# -> {"revoked": 41}
Guides

Python SDK

jumpy, our Python SDK

Write the model in Python and get the solution back as objects, with submit, wait, webhook verification and idempotent retries wrapped for you. The Studio runs it in the browser, so you can read the shape before you wire anything up.

The envelope is the contract, so the SDK stays a thin convenience over it. Anything jumpy does, your own client can do.

More

Worked examples

A production mix (LP) is the quickstart above. Here is the same envelope with integer decisions: five candidate projects, one budget, pick the subset worth the most.

budget.json
{
  "api_version": "1",
  "problem": {
    "variables": [
      {
        "name": "project_1",
        "type": "binary"
      },
      {
        "name": "project_2",
        "type": "binary"
      },
      {
        "name": "project_3",
        "type": "binary"
      },
      {
        "name": "project_4",
        "type": "binary"
      },
      {
        "name": "project_5",
        "type": "binary"
      }
    ],
    "constraints": [
      {
        "expr": "12*project_1 + 5*project_2 + 8*project_3 + 21*project_4 + 9*project_5 <= 30",
        "name": "budget"
      }
    ],
    "objective": {
      "sense": "max",
      "expr": "18*project_1 + 6*project_2 + 12*project_3 + 30*project_4 + 11*project_5"
    }
  },
  "solver": "highs",
  "options": {
    "compute_preset": "cpu-standard",
    "time_limit_seconds": 5,
    "tags": [
      "budget"
    ]
  }
}

Post it exactly like the quickstart. The answer picks project_3 and project_4, spends 29 of the 30, and reports objective_value 42 with termination_status OPTIMAL.

A delivery route (VRP) and a shift cover are larger than a code panel, so they live in the Studio, written in Python and submitted through the same envelope.

Try them in the Studio

Every example loads into the live editor with one click. No key required to run the sandbox.

Your first solve is five minutes away

Run a real model against a real solver, in the browser. No account, no credit card.