API reference

Three calls, and the bytes never touch us.

PureMatte removes backgrounds with classical computer vision — no model weights, no training data, byte-identical output forever. The API is deliberately shaped so your image goes straight from your machine to object storage and back, never through our servers.

before you start

You need an API key. Create an account, verify your email, then mint one on your dashboard. The key is shown once and stored only as a hash — we cannot recover it for you.

Authentication

One header, on every request

Send your key as X-API-Key. There is no OAuth flow and no bearer-token exchange. Base URL:

text
https://purematte-production.up.railway.app
bash
curl https://purematte-production.up.railway.app/v1/keys \
  -H "X-API-Key: pm_live_your_key_here"

A missing key and a wrong key both return 401 unauthorized, deliberately — the API will not confirm whether a key exists. Keys are hashed with SHA-256 at rest, so a database leak does not expose them.

Never put your key in browser code. It authorises spend. Keep it server-side, and rotate from the dashboard if it leaks — rotation kills the old key immediately.

Architecture

How a job works

Most background-removal APIs take a multipart POST with the file in it. PureMatte does not, and the reason is the whole cost model: your image never passes through our application servers. You upload directly to object storage with a presigned URL, the worker reads it from storage, and you download the result from storage.

text
1. POST /v1/uploads      -> presigned PUT url + object_key
2. PUT  <upload_url>     -> your bytes, straight to storage (not to us)
3. POST /v1/jobs         -> reference the object_key, get the cutout back

   If the job outlives the ~22s synchronous window you get 202 +
   a Location header, and poll GET /v1/jobs/{job_id} until terminal.

The practical consequence: a POST with a file body will not work anywhere in this API. That is by design, not a limitation.

Quickstart

A complete working example

Copy-paste runnable. Replace the key and the file path.

bash
API=https://purematte-production.up.railway.app
KEY=pm_live_your_key_here
FILE=photo.png
SIZE=$(wc -c < "$FILE")

# 1. Ask for a presigned upload URL.
PRESIGN=$(curl -sS "$API/v1/uploads" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d "{\"content_type\":\"image/png\",\"declared_size\":$SIZE}")

UPLOAD_URL=$(echo "$PRESIGN" | python3 -c 'import json,sys;print(json.load(sys.stdin)["upload_url"])')
OBJECT_KEY=$(echo "$PRESIGN" | python3 -c 'import json,sys;print(json.load(sys.stdin)["object_key"])')

# 2. PUT the bytes straight to storage. Note: no API key here.
curl -sS -X PUT "$UPLOAD_URL" --data-binary @"$FILE" -H "Content-Type: image/png"

# 3. Submit the job.
curl -sS "$API/v1/jobs" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d "{\"object_key\":\"$OBJECT_KEY\"}"

And the same thing in Python:

python
import httpx

API = "https://purematte-production.up.railway.app"
KEY = "pm_live_your_key_here"

data = open("photo.png", "rb").read()

with httpx.Client(base_url=API, headers={"X-API-Key": KEY}, timeout=120) as c:
    presign = c.post(
        "/v1/uploads",
        json={"content_type": "image/png", "declared_size": len(data)},
    ).json()

    # Straight to storage — this request does not go to the PureMatte API.
    httpx.put(
        presign["upload_url"],
        content=data,
        headers={"Content-Type": "image/png"},
        timeout=120,
    ).raise_for_status()

    job = c.post("/v1/jobs", json={"object_key": presign["object_key"]})

    if job.status_code == 202:  # slower than the sync window; poll for it
        job_id = job.json()["job_id"]
        while True:
            job = c.get(f"/v1/jobs/{job_id}")
            if job.json().get("status") in ("succeeded", "failed"):
                break

    result = job.json()
    print(result["route"], result["confidence"], result["flagged"])

    cutout = httpx.get(result["output_url"], timeout=120).content
    open("cutout.png", "wb").write(cutout)

POST /v1/uploads

Request a presigned upload

Returns a URL you PUT your bytes to. The exact byte count you declare is signed into that URL, so storage rejects a body of any other length — this is a real cryptographic constraint, not an advisory check.

  • content_typestringrequired

    MIME type of the image you are about to upload, e.g. image/png. Must match the Content-Type you send on the PUT.

  • declared_sizeintegerrequired

    Exact byte count of the file. Not an estimate — a mismatch is rejected by storage with no bytes stored. Maximum 26,214,400 (25 MB).

json
{
  "upload_url": "https://…r2.cloudflarestorage.com/…?X-Amz-Signature=…",
  "object_key": "uploads/9f2c…/original.png",
  "expires_at": "2026-08-15T14:31:00Z"
}

object_key is server-generated. You echo it back when submitting the job, but you never choose it.

POST /v1/jobs

Submit a job

  • object_keystringrequired

    The key returned by /v1/uploads, after you have successfully PUT the bytes.

  • quality"cheap" | "quality"optional

    Defaults to cheap. The quality tier gets a larger memory ceiling and a longer timeout; it is worth it for large or difficult images and unnecessary for a clean studio shot.

  • confidence_thresholdnumber, 0–1optional

    Overrides the engine default. A result scoring below it comes back flagged and unbilled. Raise it if you would rather be told “no” than shipped a marginal cutout.

  • format"png" | "jpg" | "webp"optional

    Defaults to png. PNG and WebP carry an alpha channel; JPEG cannot, so a jpg request without a background is flattened onto white rather than silently losing transparency.

  • backgroundstring, hex colouroptional

    Flattens the cutout onto a solid colour, e.g. #ffffff or #fff. Omit it to keep transparency. The foreground is decontaminated before compositing, so the original backdrop does not bleed through the soft edge.

  • cropbooleanoptional

    Crops the output to the subject’s bounding box. An image with no detectable subject is returned uncropped rather than as a zero-pixel file.

  • crop_margininteger, 0–1000optional

    Pixels of padding around that box. Clamped to the image bounds.

Options are part of the idempotency fingerprint: replaying an Idempotency-Key with different options is a 409, not a silent reuse of the first result — otherwise a caller asking for WebP could be handed the PNG they requested a moment earlier.

bash
# A JPEG on a white plate, cropped to the subject with 24px of padding
curl -sS "$API/v1/jobs" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"object_key":"'"$OBJECT_KEY"'","format":"jpg","background":"#ffffff","crop":true,"crop_margin":24}'

Send an Idempotency-Key header on retries. Replaying the same key with the same body returns the original job rather than reprocessing or double-billing; replaying it with a different body is a 409.

200 means the job finished inside the synchronous window (~22s) and the body is the result. 202 means it is still running: the body carries job_id, the Location header points at the status endpoint, and you poll.

json
{
  "output_url": "https://…r2.cloudflarestorage.com/outputs/…/cutout.png?…",
  "output_expires_at": "2026-08-22T13:42:00Z",
  "route": "route_a",
  "confidence": 0.9208,
  "confidence_components": {"border_uniformity": 0.97, "alpha_certainty": 0.89},
  "flagged": false,
  "billed": true
}

output_url is a presigned GET valid for seven days. Download it yourself; we do not proxy it.

GET /v1/jobs/{job_id}

Poll a running job

Only needed after a 202. Returns status of queued, started, succeeded or failed; a succeeded job carries the same result body as above. Poll about once a second — jobs typically finish in 2–5 seconds, and the ceiling is 60s on the cheap tier and 120s on quality.

bash
curl -sS "$API/v1/jobs/$JOB_ID" -H "X-API-Key: $KEY"

Confidence

When we cannot do it well, we say so

Every result carries a confidence score and a flagged boolean. Flagged means the engine does not stand behind the cutout. You still get the image — it is labelled, not withheld — and a flagged job is never billed.

billed is always exactly !flagged. It is not an independent calculation, and the API refuses to construct a response where the two disagree.

The score is uncalibrated — treat it as an ordering, not a probability. It is worth surfacing in your own UI rather than hiding: an automatic cutout that cannot admit failure is worse than no cutout.

Errors

One envelope, every time

Every error — from the API or from the worker — comes back in the same shape. There is no second error format to special-case.

json
{"error": {"code": "input_rejected", "message": "image exceeds the 25 megapixel ceiling"}}
codehttpmeaning
unauthorized401Missing, unknown or revoked API key. Absent and wrong are the same answer — the API will not confirm that a key exists.
declared_size_exceeded413declared_size is above the 25 MB ceiling. Refused before any upload.
upload_size_mismatch413The object you PUT is not the size you declared. Storage enforces this; the byte count is signed into the URL.
invalid_background422background is not a hex colour. Refused before the job is claimed or quota is spent.
object_not_found422No object at that key. Usually the PUT failed or the key expired.
idempotency_key_conflict409The same Idempotency-Key was reused with a different body. Reuse it only for a genuine retry of the same request.
input_rejected422The worker could not decode the image, or it exceeds the 25 megapixel ceiling. The message names the reason.
resource_exceeded413The job hit its memory ceiling. Try the cheap tier, or a smaller image.
engine_error500The engine failed. Not your input's fault; safe to retry.
quota_exceeded429Monthly allowance spent. Resets at the start of the next UTC month; X-RateLimit-Reset carries the exact time. Not billed.
rate_limited429Over your rate limit. Retry-After and X-RateLimit-* headers say when. Rate-limited requests are never billed.

GET /v1/usage

Check your allowance

Returns what you have spent this period and what is left. Reading it costs nothing — it reserves no quota. Poll it if you would rather back off than collect 429s.

bash
curl -sS "$API/v1/usage" -H "X-API-Key: $KEY"
json
{
  "period_start": "2026-08-01T00:00:00+00:00",
  "period_end":   "2026-09-01T00:00:00+00:00",
  "used": 143,
  "limit": 500,
  "remaining": 357
}

used includes jobs still running, so it can briefly read higher than the number of finished images — that is the honest answer to “how much of my allowance is spoken for”. On an account with no cap, limit and remaining are null rather than a sentinel number.

Rate limits & quota

Headers say when to retry

A limited request returns 429 with Retry-After (seconds) and X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Rate-limited requests are never billed. Read the headers rather than guessing a backoff — they carry the reset the server actually committed to.

Quota is monthly and per account, not per key — minting a second key does not mint a second allowance. It resets at the start of each UTC calendar month. Over quota returns 429 quota_exceeded with the same four headers, where X-RateLimit-Reset is the start of next month.

Only billed images count. A flagged low-confidence cutout is not billed and does not consume quota, so an image the engine cannot do well costs you nothing.

The free web tool has its own separate allowance (10 cutouts per visitor per day) and does not consume API quota.

honest limitations

The engine is classical computer vision, not a neural matting model. It is strongest on uniform and studio backgrounds and weakest on cluttered scenes with fine detail like hair against foliage. Rather than shipping you a confident bad cutout, it flags those and does not bill for them. If your workload is mostly hard natural imagery, test before committing — and the confidence score is the number to test against.