Provisional fares. Limited beta. Measured on real GPU runs, and they may change before launch. What this means

Offpeak

First-run guide

From a new account to downloaded results. Every step has curl and Python, and the reference follows.

1. Get a key

  1. Sign in with GitHub or an email and password.
  2. On your account, buy a pack. File uploads return 402 payment_required until your first payment, because there is no free storage.
  3. Create a key. It starts op_live_ and is shown once, so copy it then.
curl
export OFFPEAK_KEY=op_live_...
export API=https://www.y-pad.com

curl -s $API/v1/account -H "Authorization: Bearer $OFFPEAK_KEY"
Python
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: ask for a link
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)}]}"
Response
{
  "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: send the file
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --upload-file photo.jpg
Python: every file, a page at a time
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
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
  }'
Response
{
  "dry_run": true,
  "held_micros": 7500,
  "item_count": 1,
  "missing": []
}
Python
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
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}}]
  }'
Python
# 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.

402 response
{
  "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
curl -s $API/v1/batches/$BATCH -H "Authorization: Bearer $OFFPEAK_KEY"
Response
{
  "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
}
Python
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_forMeaning
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
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"
Python
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:

One-line ffprobe
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 }'
Python
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.

ModelDefault max_unitsSmallest max_unitsLargest max_unitsUnit
real-esrgan 2 1 2 image
whisper-large-v3 60 0.5 240 audio minute
birefnet 4 1 4 image

Retention

WhatHow long
Upload link1 hour
Uploaded file7 days from upload, then deleted
File used in a batchMust have over 24 hours left when you submit
Output link1 hour from when you read the page
Output file7 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.

RouteAuthDoes
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.

StatusCodeMeaningWhat 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.

CodeMeaning
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.

CodeMeaning
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.