First-run guide
From a new account to downloaded results. Every step has curl and Python, and the reference follows.
1. Get a key
- Sign in with GitHub or an email and password.
- On your account, buy a pack. File uploads return
402 payment_requireduntil your first payment, because there is no free storage. - Create a key. It starts
op_live_and is shown once, so copy it then.
export OFFPEAK_KEY=op_live_...
export API=https://www.y-pad.com
curl -s $API/v1/account -H "Authorization: Bearer $OFFPEAK_KEY"
import os
import time
import uuid
from pathlib import Path
import requests
API = "https://www.y-pad.com"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['OFFPEAK_KEY']}"
print(session.get(f"{API}/v1/account", timeout=30).json())
2. Upload one page of files
Ask for upload links, up to 100 files a call, then PUT each file straight to storage. Your files never pass through our API. A link lasts 1 hour and signs its
Content-Type, so send the same type you declared. If an upload outlasts its link, POST /v1/files/{id}/upload_url gives you a fresh one.
Declare bytes as the file's exact size, as the samples below do. Once its links have expired, an upload that differs from what you declared in size or type is
deleted, and an item using it fails as file_missing. Storage answers 413 to any upload over 500 MiB, the most any model takes.
Each model takes certain types, listed on its page: Real-ESRGAN x4plus, Whisper large-v3, BiRefNet general.
curl -s $API/v1/files \
-H "Authorization: Bearer $OFFPEAK_KEY" \
-H "Content-Type: application/json" \
-d "{\"files\": [{\"content_type\": \"image/jpeg\", \"bytes\": $(wc -c < photo.jpg)}]}"
{
"files": [
{
"id": "2f6d0c1e-8a54-4e0a-9d0b-6f0f3a1c7b11",
"upload_url": "https://<project>.storage.supabase.co/storage/v1/s3/...",
"content_type": "image/jpeg",
"bytes": 482113,
"expires_at": "2026-11-08T09:00:00Z"
}
]
}
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --upload-file photo.jpg
paths = sorted(Path("photos").glob("*.jpg"))
file_ids = []
for start in range(0, len(paths), 100):
page = paths[start:start + 100]
body = {"files": [{"content_type": "image/jpeg", "bytes": p.stat().st_size} for p in page]}
res = session.post(f"{API}/v1/files", json=body, timeout=30)
res.raise_for_status()
for path, file in zip(page, res.json()["files"]):
with path.open("rb") as data:
# Plain requests, not the session: the link is signed, and your key stays out of storage logs.
put = requests.put(file["upload_url"], data=data, headers={"Content-Type": "image/jpeg"}, timeout=300)
put.raise_for_status()
file_ids.append(file["id"])
3. Dry run
Send the batch with dry_run: true first. It returns the hold this batch would take, checks each file is in storage, and reports any that are missing. It
writes nothing and needs no Idempotency-Key.
curl -s $API/v1/batches \
-H "Authorization: Bearer $OFFPEAK_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "real-esrgan",
"items": [{"file": "2f6d0c1e-8a54-4e0a-9d0b-6f0f3a1c7b11", "params": {"scale": 4}}],
"dry_run": true
}'
{
"dry_run": true,
"held_micros": 7500,
"item_count": 1,
"missing": []
}
items = [{"file": file_id, "params": {"scale": 4}} for file_id in file_ids]
body = {"model": "real-esrgan", "items": items}
res = session.post(f"{API}/v1/batches", json={**body, "dry_run": True}, timeout=60)
res.raise_for_status()
print(res.json())
4. Submit
Send the same body without dry_run, plus an Idempotency-Key. Retrying with the same key and body returns the original batch with
"duplicate": true, so a timed-out request is safe to repeat, even if a model's defaults have changed since. The same key with a different body is
409 idempotency_conflict. A refusal writes nothing, so the same key still works after a top-up.
curl -s $API/v1/batches \
-H "Authorization: Bearer $OFFPEAK_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: photos-2026-11-01" \
-d '{
"model": "real-esrgan",
"items": [{"file": "2f6d0c1e-8a54-4e0a-9d0b-6f0f3a1c7b11", "params": {"scale": 4}}]
}'
# Keep this value until the batch is accepted. Retrying with the same key can't double-charge.
idempotency_key = str(uuid.uuid4())
res = session.post(f"{API}/v1/batches", json=body, headers={"Idempotency-Key": idempotency_key}, timeout=60)
res.raise_for_status()
batch_id = res.json()["id"]
If your balance is too small
The hold is the larger of $0.0075 and the sum of every item's max_units at the list fare, so a batch can be refused even when its real cost would fit.
Set max_units to the real size, or buy a pack.
{
"error": {
"code": "insufficient_funds",
"message": "Your balance doesn't cover the hold for this batch.",
"required_micros": 3200000,
"balance_micros": 1000000
}
}
One job on its own
POST /v1/predictions takes {model, input: {file, ...params}, max_units?} and an Idempotency-Key. It is a batch of one, so it
pays the $0.0075 minimum. If you want one job done now, that costs more here than elsewhere: see who charges less.
A retry after the item has been deleted, which is 7 days for a cancelled item and 90 for the rest, gets "id": null with how it ended.
5. Poll
There are no webhooks, so you poll. This is a queue: expect 10 to 30 s of cold start, occasionally minutes, and longer behind other work. waiting_for says what the
batch is waiting on, and items_ahead how many items are in front of it. Every 10 seconds is plenty.
A batch has ended when settled_at is set. That is when every item has finished, charged_micros appears and the rest of the hold comes back,
so poll for that. state moves through queued, running and done, and shows cancelled as soon as anything cancels the queue,
while items already running carry on. An item moves through queued, dispatching and running, and ends as succeeded,
failed or cancelled. POST /v1/batches/{id}/cancel cancels whatever is still queued. Anything already running finishes.
curl -s $API/v1/batches/$BATCH -H "Authorization: Bearer $OFFPEAK_KEY"
{
"id": "9b1f3c52-77a0-4f16-8a53-2c1d0e6f4a90",
"model": "real-esrgan",
"state": "running",
"item_count": 1000,
"counts": {"queued": 640, "running": 3, "succeeded": 355, "failed": 2, "cancelled": 0},
"held_micros": 3200000,
"charged_micros": null,
"settled_at": null,
"waiting_for": "capacity",
"items_ahead": 0
}
while True:
res = session.get(f"{API}/v1/batches/{batch_id}", timeout=30)
res.raise_for_status()
batch = res.json()
if batch["settled_at"] is not None:
break
time.sleep(10)
print(batch["counts"], batch["charged_micros"])
| waiting_for | Meaning |
|---|---|
capacity |
Every GPU slot for this model is busy. This is the usual case |
gpu_cap |
The day's GPU budget is used up. Items keep their place and start again after 00:00 UTC |
account_share |
Your account has used its share of the day's GPU budget while other accounts wait |
breaker |
The GPU service is failing, so we have stopped sending it work for a short while |
dispatch_paused |
We have paused the queue. Items keep their place |
model_disabled |
The model is switched off. Items keep their place |
6. Download one page at a time
GET /v1/batches/{id}/items returns up to 100 items and a next_after. Output links are signed when you read the page and last 1 hour, so fetch a
page, download it, then ask for the next. Links in a page you read earlier may have expired, and reading the page again gives new ones.
curl -s "$API/v1/batches/$BATCH/items?limit=100&state=succeeded" \
-H "Authorization: Bearer $OFFPEAK_KEY"
# Then, for each item on the page:
curl -o output.png "$OUTPUT_URL"
# And for the next page, pass the next_after you were given:
curl -s "$API/v1/batches/$BATCH/items?limit=100&state=succeeded&after=$NEXT_AFTER" \
-H "Authorization: Bearer $OFFPEAK_KEY"
out = Path("outputs")
out.mkdir(exist_ok=True)
after = None
while True:
params = {"limit": 100, "state": "succeeded"}
if after is not None:
params["after"] = after
res = session.get(f"{API}/v1/batches/{batch_id}/items", params=params, timeout=30)
res.raise_for_status()
page = res.json()
for item in page["items"]:
if item["output_expired"]:
continue
got = requests.get(item["output_url"], timeout=300)
got.raise_for_status()
(out / f"{item['position']}.png").write_bytes(got.content)
after = page["next_after"]
if after is None:
break
7. Size max_units
Each item holds max_units at the list fare until the batch settles. Whisper defaults to 60 minutes, which holds $0.0264 a file however short
the file is. On a small balance, that gets a batch refused, so send each item's real length. This one line prints it for a file, with a small margin:
ffprobe -v error -show_entries format=duration -of csv=p=0 talk.mp3 | awk '{ v = $1 / 60 * 1.02 + 0.02; if (v < 0.5) v = 0.5; if (v > 240) v = 240; printf "%.2f\n", v }'
import subprocess
def max_units(path):
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(path)],
capture_output=True, text=True, check=True,
).stdout
return max(0.5, min(round(float(out) / 60 * 1.02 + 0.02, 2), 240))
items = [{"file": file_id, "max_units": max_units(path)} for file_id, path in zip(file_ids, paths)]
The margin costs nothing, because you pay for what we measure and the rest of the hold comes back. We measure from decoded samples and ignore the file's header. An item that measures
more than its max_units fails as too_many_units at no charge, so err high. Whisper takes 240 at most, so both samples stop there, and a longer
file can't run.
A file under 30 seconds bills as 30 seconds. That's 0.5 units, so both samples start there. A smaller max_units is refused with 422 invalid_input, carrying
reason: max_units_below_min_units, the item's position and the model's min_units.
units on an item is billed units: what we measured, or 0.5 for a file under 30 seconds. Workers report what they measure, and we apply
the minimum when the item succeeds. units on a batch adds up its succeeded items, and that's what the charge is worked out from. An item that fails as
too_many_units costs nothing, and its units is usually empty.
Image models default to their largest size, so you only need max_units there to shrink the hold. For an image, it is its megapixels, and at least 1.
| Model | Default max_units | Smallest max_units | Largest max_units | Unit |
|---|---|---|---|---|
real-esrgan |
2 | 1 | 2 | image |
whisper-large-v3 |
60 | 0.5 | 240 | audio minute |
birefnet |
4 | 1 | 4 | image |
Retention
| What | How long |
|---|---|
| Upload link | 1 hour |
| Uploaded file | 7 days from upload, then deleted |
| File used in a batch | Must have over 24 hours left when you submit |
| Output link | 1 hour from when you read the page |
| Output file | 7 days from when the item finished, then deleted |
After output_expires_at, an item shows output_url: null and output_expired: true. Downloading is on you, because we keep no copy. An item still in the
queue after 7 days fails with file_missing and costs nothing.
Limits
Rate limits apply per account, across all your keys. Failed items cost you nothing, but the GPU they used counts towards the unbilled budget below. A failed item counts for at least the time a worker stays up after it, unless a job that succeeded was keeping that worker busy.
| Files per POST /v1/files | 100 |
|---|---|
| Items per batch | 2,000 |
| Request body | 4 MB |
| Params per item | 1 KB |
| Idempotency-Key | 128 characters |
| Active API keys | 10 |
| Unexpired files per account | 60 GB, counted from the sizes you declare |
| Files created per day | 50,000 |
| Items created per day | 50,000 |
| Submissions, including dry runs | 60 a minute |
| Files checked by dry runs | 50,000 a day |
| POST /v1/files | 30 a minute |
| Reads | 300 a minute |
| Writes signed in with a JWT | 10 a minute |
| Unbilled GPU per day | $1, then 429 failure_cap until 00:00 UTC |
| First 7 days after first payment | Today's charges plus open holds can't pass $25 |
A batch also stops itself. Once it has 20 failures, and failures outnumber successes, the rest is cancelled. This counts only invalid_input, file_too_large,
too_many_units, decode_failed and file_missing.
API reference
JSON throughout, shaped like Replicate's. Amounts ending in _micros are integer millionths of a dollar. Routes marked key take Authorization: Bearer op_live_....
Routes marked JWT take a sign-in token from the dashboard. You won't normally call those yourself.
| Route | Auth | Does |
|---|---|---|
GET /v1/models |
none | Public allowlist of catalog fields |
GET /v1/status |
none | Launch seats left, queue depth and waiting_for per model, and whether payments are on. Cached 30 s |
POST /v1/files |
key | {files: [{content_type, bytes}]}, 1 to 100. Returns ids and presigned PUT URLs valid 1 h. 402 payment_required until your first payment |
POST /v1/files/{id}/upload_url |
key | Mints a fresh PUT, for uploads that outlast the first |
POST /v1/batches |
key | {model, items: [{file, params?, max_units?}], dry_run?}, up to 2,000 items. Idempotency-Key required unless dry_run. dry_run also checks each file and reports missing ones |
GET /v1/batches |
key or JWT | ?after=&limit= |
GET /v1/batches/{id} |
key or JWT | State, counts, held, charged, waiting_for, items_ahead |
GET /v1/batches/{id}/items |
key or JWT | ?after=<position>&limit=100&state=, returns next_after. Output URLs presigned for 1 h at read time. output_expires_at per item. After that, output_url: null and output_expired: true |
POST /v1/batches/{id}/cancel |
key or JWT | Cancels queued items |
POST /v1/predictions |
key | {model, input: {file, ...params}, max_units?}. A batch of one, returning the item. Idempotency-Key required |
GET /v1/predictions/{id} |
key or JWT | One item, with waiting_for and items_ahead |
GET /v1/account |
key or JWT | Balance, owed, held, launch fare remaining, seat result |
POST /v1/keys, DELETE /v1/keys/{id} |
JWT | Key shown once, stored as sha256 |
POST /v1/billing/checkout |
JWT | {pack}. Returns a Stripe Checkout URL |
GET /healthz |
none | 503 if the database fails or a background thread is stale |
Two more routes exist for our payment and GPU providers, not for you: POST /webhooks/runpod/{item_id}?token= and POST /webhooks/stripe.
Errors
Every error has the shape {"error": {"code", "message", ...}}. Match on code: the message is for people.
Refusals
A refused request writes nothing, and nothing is held.
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 401 | unauthorized |
The key or sign-in token is missing, wrong or revoked | Send a live key as Authorization: Bearer |
| 402 | insufficient_funds |
Your balance is below the hold. Carries required_micros and balance_micros | Size max_units down, or buy a pack |
| 402 | payment_required |
File uploads open after your first payment | Buy a pack |
| 402 | balance_owed |
A refund or dispute left an amount owed | Buy a pack, or wait for a hold to release |
| 403 | suspended |
The account is suspended, so every request is refused | Contact support |
| 404 | not_found |
The id doesn't exist or isn't yours. The two look identical | Check the id |
| 405 | method_not_allowed |
That route doesn't take this method | See the API reference |
| 409 | idempotency_conflict |
This Idempotency-Key was used with a different body | Resend the original body to get the original batch. A new key is a new batch with a new hold, so use one only for different work |
| 409 | model_unavailable |
The model is switched off | Check GET /v1/models and retry later |
| 409 | too_many_keys |
This account has 10 active keys | Revoke one first |
| 409 | first_payment_cap |
A first payment can't be over $25 | Choose a smaller pack |
| 409 | pack_too_soon |
This pack opens 7 days after your first payment. Carries available_at | Wait, or choose another pack |
| 409 | checkout_pending |
A checkout is already open or being paid | Try again in a few minutes |
| 413 | file_too_large |
A file is over the model's size limit | Send a smaller file |
| 413 | body_too_large |
The JSON body is over 4 MB | Split the batch |
| 415 | unsupported_type |
A file's content_type isn't one the model takes | See the model page for its input types |
| 422 | invalid_input |
The request failed validation, such as an unknown param or a bad max_units. A max_units under the model's smallest carries reason max_units_below_min_units, position and min_units. A file with under 24 hours left carries reason file_expiring and position | Fix the field or position the error carries, or upload an expiring file again. A malformed body carries neither |
| 429 | rate_limited |
You passed a rate limit | Back off, then retry |
| 429 | failure_cap |
Today's failures used up your $1 unbilled GPU budget. Queued items are cancelled | Fix the inputs, and wait for 00:00 UTC |
| 429 | new_account_limit |
Day-to-day limit for a new account. Carries limit_micros, used_micros and resets_at | Wait until resets_at |
| 429 | daily_item_limit |
You created 50,000 items today. Cancelling them gives none back | Wait for 00:00 UTC |
| 429 | daily_file_limit |
You created 50,000 files today. Carries limit, used and resets_at | Wait for 00:00 UTC |
| 429 | storage_limit |
Your unexpired files come to 60 GB. Carries limit_bytes and used_bytes | Wait for older files to expire |
| 500 | internal_error |
Something failed on our side | Retry. Contact support if it lasts |
| 503 | intake_paused |
We've paused new submissions. Queued items keep their place | Retry later |
| 503 | billing_unavailable |
Payments are switched off | Try again later |
| 503 | upstream_unavailable |
A service we depend on didn't answer | Retry shortly |
Item errors
These appear as error_code on a failed item. A failed item costs nothing.
| Code | Meaning |
|---|---|
invalid_input |
The file or params aren't valid for this model |
file_too_large |
The file is over the model's size limit |
too_many_units |
The item measured more than its max_units |
decode_failed |
The file couldn't be decoded |
file_missing |
The file was gone when its turn came: it was never uploaded, it didn't match its declared size or type, it changed while its job waited, or the queue outlasted its 7 days |
download_failed |
The worker couldn't read the file from storage, after 3 tries. A fault on our side, not yours |
upload_failed |
The worker couldn't store the output, after 3 tries. A fault on our side, not yours |
internal_error |
The worker failed |
timeout |
The job ran past its time limit |
dispatch_failed |
We couldn't start the job after 3 tries. It never counts against you |
Cancelled items
A cancelled item costs nothing too. Its error_code is empty if you cancelled it. If we did, it says why.
| Code | Meaning |
|---|---|
batch_stopped |
The batch stopped itself. See Limits |
failure_cap |
Today's failures used up your unbilled GPU budget |
balance_owed |
A refund or dispute left an amount owed, so the rest of the queue was cancelled and its hold released |
account_suspended |
The account was suspended |
account_deleted |
The account was deleted |
Unit sizes for 240 audio minutes and the other limits live on each model page.