Public API Reference

Read your receipts, and the raw text behind them, from your own code.

This is the public API. Bearer keys, the Pro Plus plan, read access to your own data.

Create and revoke keys in /settings.

Introduction

Base URL and Versioning

Every endpoint sits under /api/v1 and every path ends in a slash. Without the slash you get a 308 and pay for a second round trip — harmless if your client follows redirects, and broken if it does not. The version is in the path, so a breaking change arrives as a new one rather than as a field that quietly changes meaning.

Request
https://skanota.app/api/v1/
Response
# every path ends in a slash
# without it you get a 308 and a second round trip

GET https://skanota.app/api/v1/me   ->  308  Location: /api/v1/me/
GET https://skanota.app/api/v1/me/  ->  200

Authentication

Send your key as a bearer token on every request. Keys are shown once when you create them and cannot be recovered afterwards. Each key carries its own scopes and its own rate-limit budget, so a leaked key can be revoked without disturbing your other integrations.

Request
curl https://skanota.app/api/v1/me/ \
  -H "Authorization: Bearer rs_live_..."
Response
{
  "success": true,
  "data": {
    "userId": "0c8f2b1e-4a77-4c1e-9a2b-6f4d1e9c33aa",
    "tier": "pro_plus",
    "scopes": ["receipts:read", "receipts:write"],
    "scanCredits": {
      "limit": 500,
      "used": 128,
      "purchased": 40,
      "remaining": 412,
      "resetsAt": "2026-09-01T00:00:00.000Z"
    }
  }
}

Errors

Every refusal answers the same envelope: success is false, error is a sentence for a person, and code is a stable identifier for a program. Branch on code — the prose gets improved. Authentication failures are deliberately indistinguishable from one another, so a missing key, an unknown one and a revoked one all read the same.

Request
curl -i https://skanota.app/api/v1/receipts/ \
  -H "Authorization: Bearer rs_live_wrong"
Response
HTTP/2 401
www-authenticate: Bearer realm="Skanota API"

{
  "success": false,
  "error": "Invalid or missing API key",
  "code": "UNAUTHORIZED"
}

# codes you can branch on
# UNAUTHORIZED       401  no key, malformed, unknown, revoked or expired
# FORBIDDEN          403  valid key, account below the Pro Plus plan
# INSUFFICIENT_SCOPE 403  key does not carry the scope this route needs
# NOT_FOUND          404  no such receipt for this account
# INVALID_PARAMETER  400  a query parameter was not one of the accepted values
# RATE_LIMITED       429  budget spent; see Retry-After
# INTERNAL_ERROR     500

Rate Limits

600 requests an hour, counted per key rather than per account — one runaway integration cannot throttle the others. Successful responses carry the remaining budget and a 429 carries Retry-After in seconds.

Request
curl -i https://skanota.app/api/v1/receipts/ \
  -H "Authorization: Bearer rs_live_..."
Response
HTTP/2 200
x-ratelimit-limit: 600
x-ratelimit-remaining: 597

# and when the budget is spent
HTTP/2 429
retry-after: 2841

{ "success": false, "error": "Rate limit exceeded", "code": "RATE_LIMITED" }

Receipts

The Receipt Object

One scanned receipt. Every endpoint that returns receipts returns this shape, and so does the archive export, so there is one format to write a parser against. It carries its own schema version on every object rather than only on the envelope — an exported line has to be able to say what it is once it has been split from the rest.

Attributes

schemastring
The format version of this object. Currently skanota.receipt.v1.
idstring
The receipt's identifier, and what you pass to the retrieve endpoints.
merchant.namestringnullable
The shop or restaurant name, as printed.
merchant.addressstringnullable
The address printed on the receipt, when there is one.
merchant.phonestringnullable
The phone number printed on the receipt, when there is one.
receiptNumberstringnullable
The transaction or receipt number the merchant printed.
transactionDatestringnullable
The date and time printed on the paper, with no timezone. It is a wall-clock label rather than an instant: the model can read the clock on the receipt but cannot know the shop's timezone, so do not attach one.
amounts.totalstringnullable
The final total. A decimal string, not a number — do not put it through a float.
amounts.taxstringnullable
Tax, when the receipt separates it.
amounts.subtotalstringnullable
The total before tax and service charge, when printed.
amounts.discountstringnullable
Any discount applied, when printed.
amounts.serviceChargestringnullable
Service charge, when printed.
amounts.currencystring
Always IDR. Stated so a consumer does not have to infer it from the digits.
category.builtInstringnullable
One of the built-in category keys, when the receipt has one.
category.customIdstringnullable
The identifier of a category the account created, when the receipt is filed under one instead.
notesstringnullable
Whatever the account owner typed against this receipt.
tagsarray
The receipt's tags. Always an array — an absent list and an empty one mean the same thing, so you never have to branch on which you got.
isVerifiedboolean
Whether somebody has checked the extracted fields against the paper.
ocr.confidencenumbernullable
How confident the model was overall, between 0 and 1.
ocr.rawTextstringnullable
Everything the model read off the receipt, unparsed. This is the field that makes the archive worth keeping.
ocr.fieldCountinteger
How many individual fields the model located on the image.
imagePathstringnullable
Where to fetch the image, or null when the receipt has none. It is a path on this API, never the underlying storage address.
createdAtstringnullable
When the receipt was scanned. A real instant, as an ISO string.
updatedAtstringnullable
When the receipt was last edited. A real instant, as an ISO string.
Response
{
  "schema": "skanota.receipt.v1",
  "id": "8f14e45f-ceea-467a-9a36-dedd4bea2543",
  "merchant": {
    "name": "GRAND LUCKY SUPERSTORE",
    "address": "Jl. Jend. Sudirman Kav. 52-53, Jakarta",
    "phone": "+62215155678"
  },
  "receiptNumber": "TRX-20260812-0043",
  "transactionDate": "2026-08-12T19:24:00",
  "amounts": {
    "total": "184500.00",
    "tax": "18450.00",
    "subtotal": "166050.00",
    "discount": null,
    "serviceCharge": null,
    "currency": "IDR"
  },
  "category": { "builtIn": "grocery", "customId": null },
  "notes": null,
  "tags": [],
  "isVerified": true,
  "ocr": {
    "confidence": 0.94,
    "rawText": "GRAND LUCKY SUPERSTORE\n...",
    "fieldCount": 11
  },
  "imagePath": "/api/v1/receipts/8f14e45f-ceea-467a-9a36-dedd4bea2543/image/",
  "createdAt": "2026-08-12T12:31:07.412Z",
  "updatedAt": "2026-08-12T12:31:07.412Z"
}

List Receipts

GET/api/v1/receipts

Returns a page of receipts, newest first unless you ask otherwise. It accepts the same filters the Skanota app itself uses, so a filter saved in the product and a query written here mean the same thing.

Parameters

pageintegeroptional

Which page to return, starting at 1.

Defaults to: 1

limitintegeroptional

How many receipts to return per page. Anything above the ceiling is clamped to it rather than refused.

Defaults to: 20

sortenumoptional

The order to return receipts in.

One of: newest · oldest · highest · lowest

Defaults to: newest

statusenumoptional

Return only verified receipts, or only unverified ones. Omit for both.

One of: verified · pending

categorystringoptional

A built-in category key, or custom:<id> for one the account created. The prefix is what keeps the two apart. A value matching neither is ignored rather than refused.

monthstringoptional

A single calendar month, written YYYY-MM.

searchstringoptional

Matched against the merchant name and the receipt number. Anything past 255 characters is truncated, since a longer needle cannot match either column.

Returns

A list of receipt objects, and a pagination block giving the page, the page size, the total number of matches and whether more pages follow.

Request
curl "https://skanota.app/api/v1/receipts/?limit=2&sort=highest" \
  -H "Authorization: Bearer rs_live_..."
Response
{
  "success": true,
  "data": [
    {
        "schema": "skanota.receipt.v1",
        "id": "8f14e45f-ceea-467a-9a36-dedd4bea2543",
        "merchant": {
          "name": "GRAND LUCKY SUPERSTORE",
          "address": "Jl. Jend. Sudirman Kav. 52-53, Jakarta",
          "phone": "+62215155678"
        },
        "receiptNumber": "TRX-20260812-0043",
        "transactionDate": "2026-08-12T19:24:00",
        "amounts": {
          "total": "184500.00",
          "tax": "18450.00",
          "subtotal": "166050.00",
          "discount": null,
          "serviceCharge": null,
          "currency": "IDR"
        },
        "category": { "builtIn": "grocery", "customId": null },
        "notes": null,
        "tags": [],
        "isVerified": true,
        "ocr": {
          "confidence": 0.94,
          "rawText": "GRAND LUCKY SUPERSTORE\n...",
          "fieldCount": 11
        },
        "imagePath": "/api/v1/receipts/8f14e45f-ceea-467a-9a36-dedd4bea2543/image/",
        "createdAt": "2026-08-12T12:31:07.412Z",
        "updatedAt": "2026-08-12T12:31:07.412Z"
      }
  ],
  "pagination": { "page": 1, "limit": 2, "total": 418, "hasMore": true }
}

Retrieve a Receipt

GET/api/v1/receipts/{id}

Returns one receipt by id, including the raw text the model read. A receipt belonging to another account answers 404 rather than 403 — a 403 would confirm the identifier exists.

Parameters

This endpoint takes no query parameters.

Returns

A single receipt object.

Request
curl https://skanota.app/api/v1/receipts/8f14e45f-ceea-467a-9a36-dedd4bea2543/ \
  -H "Authorization: Bearer rs_live_..."
Response
{ "success": true, "data": {
    "schema": "skanota.receipt.v1",
    "id": "8f14e45f-ceea-467a-9a36-dedd4bea2543",
    "merchant": {
      "name": "GRAND LUCKY SUPERSTORE",
      "address": "Jl. Jend. Sudirman Kav. 52-53, Jakarta",
      "phone": "+62215155678"
    },
    "receiptNumber": "TRX-20260812-0043",
    "transactionDate": "2026-08-12T19:24:00",
    "amounts": {
      "total": "184500.00",
      "tax": "18450.00",
      "subtotal": "166050.00",
      "discount": null,
      "serviceCharge": null,
      "currency": "IDR"
    },
    "category": { "builtIn": "grocery", "customId": null },
    "notes": null,
    "tags": [],
    "isVerified": true,
    "ocr": {
      "confidence": 0.94,
      "rawText": "GRAND LUCKY SUPERSTORE\n...",
      "fieldCount": 11
    },
    "imagePath": "/api/v1/receipts/8f14e45f-ceea-467a-9a36-dedd4bea2543/image/",
    "createdAt": "2026-08-12T12:31:07.412Z",
    "updatedAt": "2026-08-12T12:31:07.412Z"
  } }

Retrieve a Receipt Image

GET/api/v1/receipts/{id}/image

Streams the image bytes. This is the address the imagePath field points at, and it is served through the API rather than from storage, so the underlying file address is never published. Ask for a resized copy when you are drawing a list — the stored image is up to 1500 pixels on its longest side.

Parameters

wenumoptional

Return a resized copy instead of the stored image. The shorter side comes back at least this many pixels, whichever way up the receipt is. Only the listed values are accepted — anything else is refused rather than rounded to the nearest.

One of: 160 · 320

Returns

The image itself, not JSON. Cached privately for an hour, because the bytes for an identifier never change.

Request
curl https://skanota.app/api/v1/receipts/8f14e45f-ceea-467a-9a36-dedd4bea2543/image/?w=320 \
  -H "Authorization: Bearer rs_live_..." \
  -o receipt.jpg
Response
HTTP/2 200
content-type: image/jpeg
cache-control: private, max-age=3600
referrer-policy: no-referrer

<binary>

Archive

List the Archive

GET/api/v1/archive

Every receipt on the account, a page at a time and with no filters. This is the endpoint for mirroring an account; use List receipts when you want to search one.

Parameters

pageintegeroptional

Which page to return, starting at 1.

Defaults to: 1

limitintegeroptional

How many receipts to return per page. Higher than the list endpoint allows, because this one exists for mirroring an account rather than for filling a screen.

Defaults to: 50

Returns

A list of receipt objects, and a pagination block.

Request
curl "https://skanota.app/api/v1/archive/?page=1&limit=100" \
  -H "Authorization: Bearer rs_live_..."
Response
{
  "success": true,
  "data": [ /* receipt objects */ ],
  "pagination": { "page": 1, "limit": 100, "total": 418, "hasMore": true }
}

Export the Archive

GET/api/v1/archive/export

The whole archive in one response, as one JSON object per line. Line-delimited rather than a single array so a consumer can read it a receipt at a time instead of holding an entire account in memory before the first byte is valid.

Parameters

formatenumoptional

The response format.

One of: jsonl · json

Defaults to: jsonl

Returns

A line-delimited JSON body, or one JSON object containing a count and an array when format is json.

Request
curl https://skanota.app/api/v1/archive/export/ \
  -H "Authorization: Bearer rs_live_..." > archive.jsonl
Response
content-type: application/x-ndjson; charset=utf-8

{"schema":"skanota.receipt.v1","id":"8f14e45f-...","merchant":{...}}
{"schema":"skanota.receipt.v1","id":"1c383cd3-...","merchant":{...}}
{"schema":"skanota.receipt.v1","id":"9f9d51bc-...","merchant":{...}}

Analytics

Retrieve Analytics

GET/api/v1/analytics

Spending aggregated by month, by category and by merchant over a period. Deliberately narrower than the figures the Analytics page shows: several of those panels are shaped by what reads well on a chart rather than by what is true of the data, and a presentation decision has no business in an API contract.

Parameters

periodenumoptional

How far back to aggregate.

One of: 1month · 3months · 6months · 1year · all

Defaults to: 6months

categorystringoptional

A built-in category key, or custom:<id> for one the account created. The prefix is what keeps the two apart. A value matching neither is ignored rather than refused.

Returns

The period that was applied, the instant it starts at, and three aggregates: monthly, categories and merchants.

Request
curl "https://skanota.app/api/v1/analytics/?period=3months" \
  -H "Authorization: Bearer rs_live_..."
Response
{
  "success": true,
  "data": {
    "period": "3months",
    "since": "2026-05-24T00:00:00.000Z",
    "monthly": [
      { "month": "2026-06", "total": "4812000.00", "count": 61 },
      { "month": "2026-07", "total": "5140500.00", "count": 74 }
    ],
    "categories": [
      { "category": "grocery", "total": "3910000.00", "count": 52 }
    ],
    "merchants": [
      { "merchant": "GRAND LUCKY SUPERSTORE", "total": "1284000.00", "count": 12 }
    ]
  }
}

Account

Retrieve the Account

GET/api/v1/me

Who the key belongs to, which plan the account is on, what the key is allowed to do and how much scan quota is left. Call it first: a 200 means the credential works, and a 401 or 403 tells you which problem you have without spending a real request to find out.

Parameters

This endpoint takes no query parameters.

Returns

The account identifier, its tier, the scopes on the key presented, and the scan credit balance with the date it resets.

Request
curl https://skanota.app/api/v1/me/ \
  -H "Authorization: Bearer rs_live_..."
Response
{
  "success": true,
  "data": {
    "userId": "0c8f2b1e-4a77-4c1e-9a2b-6f4d1e9c33aa",
    "tier": "pro_plus",
    "scopes": ["receipts:read", "receipts:write"],
    "scanCredits": {
      "limit": 500,
      "used": 128,
      "purchased": 40,
      "remaining": 412,
      "resetsAt": "2026-09-01T00:00:00.000Z"
    }
  }
}