REST API
Everything the console, the CLI and the SDK do goes through this API. It's JSON over HTTPS at https://api.matmul.cloud/v1.
Authentication
Create an API key on the CLI & API page of the console and send it as a bearer token:
export MATMUL_API_KEY=lmk_... curl -s https://api.matmul.cloud/v1/offers/?gpu=H100 \ -H "Authorization: Bearer $MATMUL_API_KEY"
A key belongs to the person who created it and acts with their role in the organization. The organization comes from the key; there's no org id in any request. The key is shown once. Treat it like a password.
| Role | Can |
|---|---|
| viewer | List and read offers, instances, SSH keys and jobs. |
| member | Also launch instances and jobs, add and remove SSH keys, and delete their own instances and jobs. |
| admin | Also delete anyone's instances and jobs. |
| owner | Also billing: balance, ledger and checkout. The person who signs up is the owner. |
Request bodies are JSON with Content-Type: application/json. Unknown fields are rejected, so a typo fails loudly instead of being ignored. Money is always integer US cents; prices are per hour for the whole machine.
GET /v1/ needs no key and answers {"api": "matmul", "version": "v1"}, so you can check that you're pointed at the right server.
Offers
GET /v1/offers/
Machines you can launch right now, cheapest first. All query parameters are optional.
| Parameter | Meaning |
|---|---|
gpu | GPU model, e.g. H100. |
count | GPUs per machine. |
region | One region. |
max_price_cents | Only offers at or under this price per hour. |
kind | gpu or cpu. Leave it out for both. |
{
"offers": [
{
"id": "…",
"gpu_type": "H100",
"num_gpus": 1,
"vram_per_gpu_gb": 80,
"vcpus": 26,
"memory_gb": 200,
"storage_gb": 1000,
"region": "…",
"hourly_price_cents": 299,
"boot_min_sec": 180,
"boot_max_sec": 600
}
]
}interconnect, boot_min_sec and boot_max_sec appear only when the provider reports them. The numbers above are an example, not a quote.
SSH keys
| Endpoint | Does |
|---|---|
POST /v1/ssh-keys/ | Body {"name": "laptop", "public_key": "ssh-ed25519 AAAA…"}. Returns 201 with id (key_...), name, fingerprint, created_at. Up to 20 keys; the same key can't be added twice. |
GET /v1/ssh-keys/ | {"ssh_keys": [...]} |
DELETE /v1/ssh-keys/{id} | 204. Needs the member role or higher, like adding a key. |
Instances
POST /v1/instances/
curl -s https://api.matmul.cloud/v1/instances/ \
-H "Authorization: Bearer $MATMUL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"offer_id": "…", "name": "dev", "ssh_key_id": "key_…",
"image": "pytorch/pytorch:latest", "keep_alive": true,
"max_hourly_price_cents": 300}'| Field | Meaning |
|---|---|
offer_id | Required. From GET /v1/offers/. |
name | Required. Matches ^[a-z][a-z0-9-]{0,31}$. |
ssh_key_id | Required. The key installed on the machine. |
max_hourly_price_cents | Price guard: the launch is refused (409) if the live price is above it. |
image | Docker image reference. Leave it out for the provider's plain VM (Ubuntu with the NVIDIA driver; no CUDA toolkit). |
keep_alive | Keep an interactive image running. Can't be combined with args. |
args | Container arguments: one line, up to 1024 characters. |
envs | Object of container environment variables, up to 20. |
ports | Container ports to publish on the machine, up to 10, not 22. |
shared_memory_gb | 0 to 256. |
expose | Ports to give public HTTPS links. Not switched on yet. |
args, keep_alive, envs, ports and shared_memory_gb need an image. The response is 202 with the instance, status: "provisioning". Poll GET until it's running. With an image, that's once the container is up: the machine stays provisioning while the provider pulls and starts the image, and turns error if the pull or start fails.
GET /v1/instances/ and GET /v1/instances/{id}
{"instances": [...]} for your organization (deleted ones included), or one instance:
{
"id": "ins_…",
"name": "dev",
"status": "running",
"image": "pytorch/pytorch:latest",
"gpu_type": "H100",
"num_gpus": 1,
"region": "…",
"hourly_price_cents": 299,
"ip": "…",
"ssh_user": "…",
"ssh_port": 22,
"ssh_command": "ssh -p 22 <user>@<ip>",
"container_shell_command": "ssh -t -p 22 <user>@<ip> …",
"created_at": "2026-09-23T10:00:00Z",
"created_by": "…",
"running_seconds": 1260,
"accrued_cost_cents": 105,
"ports": [],
"exposable": false
}status is provisioning, running, error, deleting or deleted; error carries the reason when there is one. The SSH fields appear once the machine has an address, and container_shell_command only for instances with an image. running_seconds and accrued_cost_cents are display estimates; the ledger is what's billed.
DELETE /v1/instances/{id}
202 with the instance, now deleting. Billing stops at the moment of this request; the status turns deleted once the provider has released the machine. It stops counting toward your instance limit at the same moment, so you can launch a replacement right away. Deleting an instance that's already deleting or deleted is a no-op.
App link endpoints
POST /v1/instances/{id}/ports (body {"port": 8188, "visibility": "public"}), GET /v1/instances/{id}/ports and DELETE /v1/instances/{id}/ports/{port} manage public links. They're built but switched off for now.
Capabilities
GET /v1/capabilities
Which optional features this server has switched on:
{"instances": true, "app_links": false, "jobs": false}When app_links is on, the response also has app_links_limit (and app_links_blocked_reason when it's 0).
Billing
All billing endpoints need the owner role.
GET /v1/billing/
{
"balance_cents": 1840,
"frozen": false,
"frozen_reason": "",
"verified": false,
"limits": {
"min_topup_cents": 1000,
"max_topup_cents": 10000,
"daily_topup_limit_cents": 10000,
"daily_spend_limit_cents": 5000,
"max_instances": 1,
"max_jobs": 1
},
"window_24h": {"purchased_24h_cents": 2000, "spent_24h_cents": 160}
}verified: false means the new-account limits apply. When the account is frozen, the response adds frozen_at and grace_hours_remaining. See Pricing & billing.
GET /v1/billing/ledger
{"entries": [...]}, newest first. limit (default 50, up to 200) and before (an entry id, for the next page). Each entry has id, kind (credit_purchase, usage_charge, adjustment, refund or dispute), amount_cents (negative for charges), created_at, and instance_id or job_id for usage.
POST /v1/billing/checkout
Body {"amount_cents": 2000}. Returns {"checkout_url": "https://checkout.stripe.com/…"}; the credit lands when the payment completes. A top-up outside your limits gets 422 with the reason.
Managed jobs
Not available on MatMul yet: these return 503 jobs are not enabled until they are. Details in Managed jobs.
| Endpoint | Does |
|---|---|
POST /v1/jobs/ | Launch a job. 202 with the job. |
GET /v1/jobs/ | {"jobs": [...]} |
GET /v1/jobs/{ref} | One job, by job_... id or by name (the most recent). |
GET /v1/jobs/{ref}/logs | The logs, as text/plain. |
DELETE /v1/jobs/{ref} | Stop the job and release its machine. 202 with the job. |
{
"name": "train",
"run": "python train.py",
"setup": "pip install -r requirements.txt",
"envs": {"WANDB_PROJECT": "demo"},
"num_nodes": 1,
"resources": {"accelerators": "H100:1", "cpus": "8+", "memory": "32+", "disk_size": 100},
"max_hourly_price_cents": 350
}Errors
Errors are JSON, {"error": "message"}, with a message you can show to a person.
| Status | Means |
|---|---|
| 400 | The body isn't valid JSON or has an unknown field. |
| 401 | invalid api key. |
| 403 | not allowed for your role. |
| 404 | No such instance, key or job in your organization. |
| 409 | The offer is gone, the price rose above your guard, or a job name is still in use. |
| 422 | A field failed validation; the message says which. |
| 429 | A limit: instance or job count, a frozen account, not enough balance (you need an hour of runway), or GPU capacity is temporarily limited; please try again later when MatMul is at its platform-wide capacity cap (see Pricing & billing). |
| 502 | The GPU provider failed. Try again. |
| 503 | The feature isn't switched on for this server. |