Skip to content
instantscan.io docs

Credits and metering

What consumes a credit, how the append-only ledger works, what happens at a zero balance, and how to read usage analytics.

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"
    }
  ]
}
reasonMeaning
signup_grantThe 250 credits every new account starts with.
topupA credit pack was purchased.
sessionA document was scanned. Always -1 today, and always carries session_id.
adjustmentA 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:

  • series always contains exactly days buckets, including days with no activity, so you can chart it without filling gaps yourself.
  • completion_rate is completed divided by created within the window, and is 0 when there were no sessions — never NaN.
  • days accepts 1–90. Anything absent, out of range or unparseable falls back to 30 rather than erroring.
  • by_mode always contains both test and live, 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.