Billing has exactly one unit and one moment. Understanding both takes about a minute and saves a lot of reconciliation later.
One credit per scanned document
A completed scan costs one credit, test or live, debited when a document is actually produced — not when the session is created.
Pages are free. A twelve-page contract and a single ID card both cost one credit.
Opening a session is free. A session that is created but never scans anything — a preview, a QR
hand-off nobody used, an abandoned camera — costs nothing. Those sessions still appear in analytics
(as created or expired), but they never touch the ledger.
To keep the balance authoritative, we refuse to open a session when the balance is below one
credit: an integration that cannot pay for a scan fails fast at POST /v1/sessions rather than after
the user has already scanned. The check debits nothing — it only reads the balance.
Test vs live is a label, not a price
Sessions created with a pk_test_… / sk_test_… key cost the same as pk_live_… / sk_live_…. The
mode is how you tell staging traffic from production in analytics, and how you revoke one set of keys
without touching the other.
Every new account starts with 250 credits. That grant is the sandbox — enough to finish an integration and a demo — not an unmetered key. Switching from test to live is still a key swap, not a code change.
The ledger
Every balance change appends a row. Rows are never edited or deleted, and each records the resulting balance, so the number on your screen always reconciles against its own history.
curl https://api.instantscan.io/v1/dashboard/credits \
-b "sc_session=<console session cookie>"
{
"balance": 2483,
"spent_30d": 267,
"ledger": [
{
"id": "ctx_8Kd2p",
"delta": -1,
"balance_after": 2483,
"reason": "session",
"session_id": "ss_9Qw1p2Kt",
"note": null,
"created_at": "2026-01-01T09:12:44.000Z"
},
{
"id": "ctx_5Nb7q",
"delta": 2500,
"balance_after": 2484,
"reason": "topup",
"session_id": null,
"note": "2,500 credit pack",
"created_at": "2025-12-10T11:02:10.000Z"
}
]
}
reason | Meaning |
|---|---|
signup_grant | The 250 credits every new account starts with. |
topup | A credit pack was purchased. |
session | A document was scanned. Always -1 today, and always carries session_id. |
adjustment | A manual correction by us. Always carries a note explaining it. |
spent_30d is the sum of negative deltas in the last 30 days — the number to watch if you want to
know when to buy the next pack.
Running out
When the balance is zero, creating a session fails:
{
"error": "insufficient_credits",
"message": "This session costs 1 credit(s); the balance is 0."
}
The response is 402, and no session is created — you are not charged and there is nothing to
clean up.
Handle it explicitly rather than letting it surface as a generic failure:
const res = await fetch("https://api.instantscan.io/v1/sessions", {
method: "POST",
headers: { Authorization: `Bearer ${SECRET_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ metadata: { application_id } }),
});
if (res.status === 402) {
// Do not show the user a raw error. Fall back to a plain file upload and
// alert your own team.
await notifyOps("scanner credits exhausted");
return renderPlainFileInput();
}
A good integration keeps a plain file input as a fallback path anyway. It costs almost nothing and turns a hard failure into a degraded one.
Topping up
curl -X POST https://api.instantscan.io/v1/dashboard/credits/topup \
-H "Content-Type: application/json" \
-b "sc_session=<console session cookie>" \
-d '{ "amount": 2500 }'
Packs are fixed: 500, 2500, 10000, 50000. Any other amount is a 400 — an arbitrary amount
would imply a price we have not charged. The new balance is available immediately; the next session
creation succeeds without any propagation delay.
Credits do not expire. They are a prepaid balance, not a monthly allowance.
Usage analytics
curl "https://api.instantscan.io/v1/dashboard/analytics?days=30" \
-b "sc_session=<console session cookie>"
{
"from": "2025-12-02",
"to": "2026-01-01",
"days": 30,
"totals": {
"sessions": 312,
"completed": 268,
"failed": 9,
"expired": 27,
"pages": 641,
"bytes": 187443210,
"credits_spent": 312,
"completion_rate": 0.859
},
"series": [{ "date": "2025-12-02", "created": 11, "completed": 9, "failed": 0 }],
"by_status": [{ "status": "delivered", "count": 254 }],
"by_mode": [
{ "mode": "test", "count": 45 },
{ "mode": "live", "count": 267 }
],
"recent": [
{ "id": "ss_9Qw1p2Kt", "status": "delivered", "mode": "live", "pages": 3, "created_at": "…" }
]
}
Details that matter when you build on this:
seriesalways contains exactlydaysbuckets, including days with no activity, so you can chart it without filling gaps yourself.completion_rateis completed divided by created within the window, and is0when there were no sessions — neverNaN.daysaccepts 1–90. Anything absent, out of range or unparseable falls back to30rather than erroring.by_modealways contains bothtestandlive, including zeros, in that order.- Analytics only ever contain your own tenant’s events.
Reading the numbers
A completion rate below ~70% usually means placement, not scanning: the scan is being asked for
before the user has the document in hand. A rising expired count means users are opening the
scanner and walking away — most often a QR flow where the code was shown too early.
Not implemented
Being explicit, so you do not plan around them: no real payment processing (the top-up endpoint stands
in for a checkout), no invoices, no VAT handling, no per-key or per-integration cost attribution, and
no usage alerts. Watch spent_30d yourself.