From the sandbox to a live key

Going from the shared sandbox to a key of your own changes one line of your code. This page walks the four rungs between them, with the number attached to each. It ends with what moves in the response once the call starts billing.

The ladder

Rung Key What it allows What a scan costs
Anonymous sk_sandbox_public 10 free recognitions per client, and 10 requests per hour per client address Nothing
Allowance spent sk_sandbox_public Every call answers registration_required Nothing to spend
Registered sk_live_… The 20 credits the account is created with 1 credit each
Topped up sk_live_… Whatever the balance holds 1 credit each

One credit is one US cent, so the balance is also the number of documents left in it. The rule that decides whether a given call draws a credit is on what a billed scan is.

1. Spend the anonymous allowance

The public sandbox key runs on two counters, and both apply. One is a rate limit of 10 requests per hour, counted per client address rather than per key, because everyone reading this shares the key. The other is a lifetime allowance of 10 free recognitions per client, which waiting does not refill.

An attempt is taken before the recognition runs and handed back when the result turns out not to be billable. A photograph with no document in it therefore costs you nothing, on this rung or any other.

2. Meet registration_required

When the allowance is gone, every further call answers 403 with this body.

{
  "error": {
    "code": "registration_required",
    "message": "The free trial without an account is used up; register for your own API key to keep scanning.",
    "docs_url": "https://doc.cheap/docs/errors/registration_required",
    "request_id": "req_9f2a5c7d-4e30-4b8e-9c4d-7a10c3f20199",
    "event_id": null
  }
}

event_id is null because nothing went wrong: this is a refusal you are meant to handle, not a failure we recorded. The registration_required page carries the rest.

3. Register

Create an account in the dashboard. The account is credited with 20 credits as it is created — 20 recognized documents, with no payment and no card.

4. Take both keys

An account issues two kinds of key, and they are not two environments of the same thing.

Key Runs recognition on your image Charges the balance Stores results
sk_live_… Yes Yes, 1 credit per billable scan Yes, for the retention window
sk_sandbox_… No — answers from a fixed synthetic specimen Never No

A registered sandbox key is the integration credential. Its answer is the same every time, so a client can be built against a stable result without spending anything. It stores nothing, so GET /v1/scans/{id} answers not_found for every sandbox key, live scan or not.

A key is shown once, when it is created. Store it the way you store a password.

5. Point the code at it

Only the credential changes. The endpoint, the body and the response shape are the same, which is what makes the sandbox worth building against.

curl

API_KEY=${API_KEY:-sk_sandbox_public}

curl -X POST https://api.doc.cheap/v1/scans \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"image\": \"$(base64 < document.jpg | tr -d '\n')\"}"

JavaScript

import { readFileSync } from "node:fs";

const apiKey = process.env.API_KEY ?? "sk_sandbox_public";
const image = readFileSync("document.jpg").toString("base64");

const response = await fetch("https://api.doc.cheap/v1/scans", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${apiKey}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ image }),
});

const scan = await response.json();
console.log(scan.meta.status, scan.meta.billed);

Python

import base64
import json
import os
import urllib.request

api_key = os.environ.get("API_KEY", "sk_sandbox_public")

with open("document.jpg", "rb") as file:
    image = base64.b64encode(file.read()).decode()

request = urllib.request.Request(
    "https://api.doc.cheap/v1/scans",
    data=json.dumps({"image": image}).encode(),
    headers={
        "Authorization": "Bearer " + api_key,
        "Content-Type": "application/json",
    },
)

with urllib.request.urlopen(request) as response:
    status = response.status
    scan = json.load(response)

print(scan["meta"]["status"], scan["meta"]["billed"])

Export API_KEY with your live key and run the same command again.

6. Watch billed flip

billed is the one field whose meaning depends on the key that made the call.

recognized and billed answer different questions, and a page that treats them as one will bill you for the wrong things. Reconcile against billed.

What else a live key unlocks

Billing is not the only difference. Three capabilities exist only on the live path, because all three depend on a result being stored.

How long a result stays readable is your choice, per call or per account: control history retention.

7. Check the balance

GET /v1/usage answers balance_credits: null for a key with no account behind it, and a number for a live key.

curl

API_KEY=${API_KEY:-sk_sandbox_public}

curl https://api.doc.cheap/v1/usage -H "Authorization: Bearer $API_KEY"

JavaScript

const apiKey = process.env.API_KEY ?? "sk_sandbox_public";

const response = await fetch("https://api.doc.cheap/v1/usage", {
  headers: { Authorization: `Bearer ${apiKey}` },
});

const usage = await response.json();
console.log(usage.balance_credits, usage.scans.billed, usage.credits_spent);

Python

import json
import os
import urllib.request

api_key = os.environ.get("API_KEY", "sk_sandbox_public")

request = urllib.request.Request(
    "https://api.doc.cheap/v1/usage",
    headers={"Authorization": "Bearer " + api_key},
)

with urllib.request.urlopen(request) as response:
    status = response.status
    usage = json.load(response)

print(usage["balance_credits"], usage["scans"]["billed"], usage["credits_spent"])

The counters cover the current UTC calendar month. A balance that cannot cover a scan refuses it with insufficient_credits before the engine is called, so an empty balance costs nothing and breaks nothing.

Next