Flovvy Receipts API — v1
Send a photo of a receipt, get back structured JSON: merchant, date, currency, totals and every line — with quantity, weight, unit and price per unit — plus a check that the lines add up to the printed total.
- Contract (OpenAPI 3.1):
openapi.json— import it into Postman, Insomnia or your code generator. - Receipt schema version:
2026-10-15. - Risk checks (duplicates, payment match, edited or AI-generated images) on request: section 9.
- Data stays in the EU.
Last updated 29 October 2026. The examples are real responses of the API.
1. Quick start
You receive from us:
| What | Looks like | Use |
|---|---|---|
| Base URL | https://phxkxiktoklomfmflvcw.supabase.co/functions/v1/b2b-api | prefix of every call |
| Test key | flv_test_… (52 characters) | free, returns a fixed example — build your integration with it |
| Live key | flv_live_… (52 characters) | reads real receipts, uses credits |
export FLOVVY_BASE_URL="https://phxkxiktoklomfmflvcw.supabase.co/functions/v1/b2b-api"
export FLOVVY_KEY="flv_test_…"
# 1. Is the service up?
curl "$FLOVVY_BASE_URL/v1/status"
# 2. Send a photo and wait up to 30 seconds for the result
curl -X POST "$FLOVVY_BASE_URL/v1/receipts" \
-H "Authorization: Bearer $FLOVVY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d "{\"image_base64\": \"$(base64 -w0 receipt.jpg)\", \"client_reference\": \"order-1042\", \"wait\": 30}"
(On macOS use base64 -i receipt.jpg instead of base64 -w0 receipt.jpg.) With a test key the answer comes at once and is always the same example (section 7).
No code yet? On the try page you paste your key, choose a photo of a receipt and see the JSON the API returns. Switch to the live key when
your integration works: nothing else changes.
Call the API from your server only. Never put a key in a browser page or a mobile app: anyone could read it. (Calls from a browser are accepted only from flovvy.app, for the try page.)
2. Authentication
Every call except /v1/status needs your key in the Authorization header:
Authorization: Bearer flv_live_…
- Test keys (
flv_test_) never read real images and never use credits. Test and live are separate worlds: a test key cannot read receipts created with a live key, and the other way round. - Keep keys secret. If a key leaks, or you want to rotate it, ask us: we give you a new one and keep the old one working for up to 72 hours, so you can switch without downtime.
- A missing, malformed, unknown or revoked key gets
401 unauthorized.
3. Credits
Live receipts are prepaid with credits: 1 credit = 1 receipt. You buy credits from us; we load them on your account.
- The credit is reserved when the receipt is accepted. If you have 5 credits and send 100 photos at once, 5 are accepted and 95 get
402 insufficient_creditsimmediately (nothing is stored for them). - You pay for every reading we actually perform. A
completedreceipt uses its credit (also withreconciled: false), and so does afailedone whose photo we did read:unreadable(not a receipt, too blurry) ortotal_not_found. - The credit comes back automatically when the failure is on our side and the reading was not performed or not delivered in time:
provider_unavailable,timeout, andprocessing_errorbefore the reading. Every failed receipt tells you which case it is:failure.credit_refunded(section 7). - Free: test keys, and repeats of a request with the same
Idempotency-Key(same receipt, section 6). - Every response that creates a live receipt carries
Flovvy-Credits-Remaining: your balance right after this receipt reserved its credit. A 402 carries it too. - Read your balance at any time with
GET /v1/balance(section 8). - Packs (one-off top-ups) never expire. With a monthly plan, the month's credits are used first and the unused ones expire at renewal, when the new month's credits arrive; if the plan ends, at the end of the last paid month. A credit that comes back returns to the part it came from (to your other credits if the plan has expired meanwhile).
4. Send a receipt — POST /v1/receipts
Body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
image_base64 | string | yes | The photo, base64 (no data: prefix). Max 8 MB once decoded. |
mime | string | no | image/jpeg (default), image/png or image/webp. |
client_reference | string, 1-128 | no | Your own reference, returned as is. |
wait | integer, 0-30 | no | Seconds to wait for the result in this same call. Default 0. |
Optional header: Idempotency-Key (section 6).
The photo. One receipt per photo, the whole receipt in the frame. We recommend what our own app does: longest side 2,000 px, JPEG quality 85.
The answer.
202 Accepted— the receipt isqueuedorprocessing. TheLocationheader says where to read it (relative to the base URL), for example/v1/receipts/rcp_966dfd59aa2148328aad3878a08cb4fd.200 OK— only when you usedwaitand the receipt finished in time: it is alreadycompletedorfailed.
Example — 202 from a live key, without wait:
HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /v1/receipts/rcp_966dfd59aa2148328aad3878a08cb4fd
Flovvy-Credits-Remaining: 99
Flovvy-Request-Id: req_1701e4119387d59592682c19
{
"id": "rcp_966dfd59aa2148328aad3878a08cb4fd",
"object": "receipt",
"status": "queued",
"environment": "live",
"client_reference": "order-1043",
"created_at": "2026-10-15T14:06:22.231Z",
"completed_at": null,
"schema_version": "2026-10-15",
"pipeline_version": null,
"merchant": null,
"category": null,
"issued_on": null,
"currency": null,
"totals": null,
"lines": [],
"reconciled": null,
"reconciliation": null,
"failure": null,
"warnings": []
}
The receipt always has all its fields: when something is not known (yet), the value is null (or [] for lines).
5. Get the result
Two ways, use whichever suits you:
- Wait in the same call — send
"wait": 30. A receipt is usually read in a few seconds (4-7 s in our measurements), so you usually get200with the result. If not ready in time you get202and continue with option 2. - Poll —
GET /v1/receipts/{id}every 1-2 seconds untilstatusiscompletedorfailed.
Status lifecycle:
queued ──► processing ──► completed
▲ │
└────────────┤ (our AI provider busy: automatic retry after 1, then 4 minutes)
└──► failed
If our AI provider is temporarily unavailable, the receipt goes back to queued and is retried automatically (up to 3 attempts in total): in that case the result can take a few minutes. You do not need to resend it.
Send in parallel. You don't need to wait for one receipt before sending the next: receipts are read in parallel. In three load tests in October 2026, 106 receipts sent at once were all read in 6.8 to 8.3 seconds, measured from when they reached us. When several customers are sending at the same time, each gets an equal share of the capacity, and a customer alone gets all of it.
GET /v1/receipts/{id} returns 404 not_found for an id that does not exist or belongs to someone else (or to the other environment): we never confirm that someone else's receipt exists. Receipts are kept for 90 days (section 14).
6. Idempotency — safe retries
Network errors happen. To retry without paying twice or getting two receipts, send an Idempotency-Key header with any value you choose — your own document id is ideal.
- 1-255 printable ASCII characters, no spaces at the start or end.
- Valid for 24 hours, separately for each account and environment (your key never collides with another customer's).
- Same key + same request (same image,
mimeandclient_reference) → the same receipt, with headerIdempotent-Replayed: true. No new reading, no new credit.waitdoes not count: you can retry with a differentwait. - Same key + different request →
409 idempotency_conflict.
7. The receipt object
A real example (test key, the fixed example):
{
"id": "rcp_66f9b6e6f336492cb548530936316513",
"object": "receipt",
"status": "completed",
"environment": "test",
"client_reference": "order-1042",
"created_at": "2026-10-15T14:06:22.179Z",
"completed_at": "2026-10-15T14:06:22.179Z",
"schema_version": "2026-10-15",
"pipeline_version": null,
"merchant": { "name": "EXAMPLE MARKET", "country": "IT" },
"category": "groceries",
"issued_on": "2026-10-14",
"currency": "EUR",
"totals": { "total": 3.98, "total_source": "printed", "tax": 0.36, "discounts": null, "net_of_tax": 3.62 },
"lines": [
{ "n": 1, "kind": "item", "description": "BANANAS ORGANIC", "quantity": 1, "unit_price": 2.58, "total": 2.58,
"weight": 1.29, "unit": "kg", "price_per_unit": 2, "applies_to": null },
{ "n": 2, "kind": "item", "description": "STILL WATER 1.5L", "quantity": 6, "unit_price": 0.35, "total": 2.1,
"weight": null, "unit": null, "price_per_unit": null, "applies_to": null },
{ "n": 3, "kind": "discount", "description": null, "quantity": null, "unit_price": null, "total": -0.7,
"weight": null, "unit": null, "price_per_unit": null, "applies_to": 2 }
],
"reconciled": true,
"reconciliation": { "status": "reconciled", "delta": 0, "tolerance": 0, "basis": "lines" },
"failure": null,
"warnings": ["test_mode"]
}
Top level
| Field | Type | Description |
|---|---|---|
id | string | rcp_ + 32 hex characters. |
object | "receipt" | |
status | string | queued, processing, completed, failed. |
environment | string | live or test. |
client_reference | string | null | What you sent. |
created_at, completed_at | ISO 8601 UTC | completed_at is set when the receipt is completed or failed. |
schema_version | "2026-10-15" | Version of this format (section 12). |
pipeline_version | string | null | Version of our reading engine that produced the result. |
merchant | object | null | name: the name printed on the receipt. country: ISO 3166-1 alpha-2, only when the receipt itself shows it (address, tax id, phone prefix, language). |
category | string | null | One of the codes below. |
issued_on | YYYY-MM-DD | null | The date printed on the receipt. |
currency | ISO 4217 | null | For example EUR. |
totals | object | null | See below. |
lines | array | See below. Empty while not completed. |
reconciled | boolean | null | See below. |
reconciliation | object | null | See below. |
failure | object | null | { "code": …, "credit_refunded": true | false } when failed. |
warnings | array of strings | See below. |
Categories: groceries, food_and_drink, vehicle, home, utilities, health, shopping, entertainment, sport, education, travel, technology, subscriptions, gifts, beauty, tobacco, other.
totals
| Field | Description |
|---|---|
total | The total paid. |
total_source | printed (read from the receipt) or sum_of_lines (no total printed: we added up the lines). |
tax | Tax (VAT) when printed. |
discounts | Discounts applied to the whole receipt, as a positive number. Line discounts are lines of their own. |
net_of_tax | Total without tax, when printed. |
lines
In printed order. Amounts are numbers in the receipt's currency.
| Field | Description |
|---|---|
n | Line number, from 1. |
kind | item (something bought) or discount (a negative line). |
description | The printed text, never translated. null for discount lines. |
quantity | Number of pieces. null for discounts. |
unit_price | Price of one piece. |
total | Line total as printed; negative for discounts. |
weight | Weight or volume of one piece, in unit (for example 1.29 kg of bananas). |
unit | kg, g, l, ml, lb, oz, m, ft, or null. |
price_per_unit | Price per unit (for example 2.00 per kg). |
applies_to | For discounts: the n of the line the discount refers to, when known. |
reconciled and reconciliation — do the numbers add up?
reconciled: true means: the lines, together with the printed tax and discounts, add up exactly to the printed total (to the cent; to the unit for currencies without decimals, such as JPY). It is a strong sign that no line is missing or misread — but it does not prove every single line is right. false means they do not add up: check that receipt. null means it cannot be checked (no printed total, or no itemised lines).
| Field | Description |
|---|---|
status | reconciled, not_reconciled, not_applicable. |
delta | Lines minus printed total, with the best combination below. |
tolerance | Always 0: exact match. |
basis | Which combination matched: lines, lines_plus_tax, lines_minus_discounts, lines_plus_tax_minus_discounts. null if none. |
failure — when status is failed
credit_refunded says whether the credit of this receipt came back to your balance. The rule: a reading we performed is paid, a reading we could not perform or deliver in time is not.
| Code | Meaning | Credit | What to do |
|---|---|---|---|
unreadable | The photo was read, but it is not a readable receipt. | used | Retake the photo. |
total_not_found | The photo was read, but no total could be found on it. | used | Retake the photo with the whole receipt. |
provider_unavailable | Our AI provider was unavailable for 3 attempts. | refunded | Send it again later. |
timeout | Reading took too long, 3 times. | refunded | Send it again later. |
processing_error | Something went wrong on our side. | refunded if it happened before the reading, used if after (credit_refunded) | Send it again; if it repeats, contact us. |
warnings
| Code | Meaning |
|---|---|
merchant_not_printed | No merchant name on the receipt: merchant.name is null. |
issued_on_not_printed | No date on the receipt: issued_on is null. |
total_not_printed | No total printed: totals.total is the sum of the lines (total_source: "sum_of_lines"). |
lines_not_itemized | The receipt shows no itemised lines (only a total): lines may be empty. |
test_mode | Created with a test key: a fixed example, not a reading. |
8. Your balance — GET /v1/balance
{ "object": "balance", "credits": 5099, "plan_credits": 5000, "plan_credits_expire_at": "2026-11-19T10:00:00.000Z",
"updated_at": "2026-10-19T14:06:22.231Z" }
| Field | Description |
|---|---|
credits | Everything you can use now: monthly plan and packs together. |
plan_credits | The part from your monthly plan (0 without a plan). These are used first. |
plan_credits_expire_at | End of the paid month: the unused plan credits expire at the next renewal, or at this time if the plan ends. null without plan credits. |
updated_at | Last movement; null if no credit was ever loaded. |
Test keys can read the balance too.
9. Risk checks — POST /v1/receipts/{id}/checks
On request, we check a receipt you already sent: duplicates among your receipts, a payment that does not match, an edited or AI-generated image, totals that do not add up. The answer is a list of signals with a risk level (none, low, medium, high): what a person should look at, never a verdict. No credits, no AI, nothing changes.
This call has its own base URL:
https://phxkxiktoklomfmflvcw.supabase.co/functions/v1/b2b-antifrode.
curl -X POST "https://phxkxiktoklomfmflvcw.supabase.co/functions/v1/b2b-antifrode/v1/receipts/rcp_966dfd59aa2148328aad3878a08cb4fd/checks" \
-H "Authorization: Bearer $FLOVVY_KEY" \
-H "Content-Type: application/json" \
-d '{ "expected": { "amount": 12.50, "currency": "EUR", "date": "2026-10-19", "merchant": "UBER *TRIP" } }'
The body is optional: without expected every check runs except the payment match. The full guide — every signal, how each check works, the response — is in Risk checks.
10. Errors
Errors follow RFC 9457 (Content-Type: application/problem+json). Use code in your code: it never changes. detail is for humans and may be reworded.
{
"type": "https://api.flovvy.app/errors/insufficient_credits",
"title": "Insufficient credits",
"status": 402,
"code": "insufficient_credits",
"detail": "Not enough credits: your balance is 0. Each receipt uses 1 credit; top up to continue.",
"request_id": "req_f8751e21e1195cf96c60b00e"
}
| HTTP | code | When | Retry? |
|---|---|---|---|
| 400 | invalid_request | Body or header not valid (detail says which field). | No — fix the request. |
| 401 | unauthorized | Key missing, malformed, unknown or revoked. | No. |
| 402 | insufficient_credits | No credit left. Nothing was stored. | After a top-up. |
| 403 | forbidden | Account suspended, or the key lacks the permission for this call. | No — contact us. |
| 404 | not_found | Unknown path, or no such receipt for you. | No. |
| 405 | method_not_allowed | Wrong method (Allow header says the right one). | No. |
| 409 | idempotency_conflict | Same Idempotency-Key, different request. | No — use a new key. |
| 409 | receipt_not_completed | Risk checks only: the receipt is still queued or processing, or it failed. | When it is completed. |
| 413 | image_too_large | Image over 8 MB. | No — resize. |
| 415 | unsupported_media_type | mime not JPEG, PNG or WebP. | No. |
| 500 | internal_error | Our fault. | Yes, with the same Idempotency-Key. |
| 503 | service_unavailable | Temporarily unavailable. | Yes, after Retry-After seconds, same key. |
11. Response headers
| Header | Description |
|---|---|
Flovvy-Request-Id | On every response. Quote it when you contact us. |
Flovvy-Credits-Remaining | Live receipts: your balance after this receipt reserved its credit; also on a 402. |
Location | On 202: where to read the receipt. |
Idempotent-Replayed | true when the answer replays an earlier request with the same Idempotency-Key. |
Retry-After | On 503: seconds to wait. |
12. Versions and compatibility
- The path carries the API version (
/v1/); the receipt carriesschema_version. - We may add new fields (and new
warningsorcategorycodes) at any time without changing the version: ignore fields you do not know, and treat unknown codes asother. - Renaming or removing a field, or changing its meaning, means a new
schema_version, announced in advance. - Results improve over time:
pipeline_versiontells you which version of our engine read a receipt.
13. Code examples
JavaScript (Node 18+)
import { readFile } from 'node:fs/promises';
const BASE = process.env.FLOVVY_BASE_URL;
const KEY = process.env.FLOVVY_KEY;
async function readReceipt(file, reference) {
const image_base64 = (await readFile(file)).toString('base64');
const headers = { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': reference };
let res = await fetch(`${BASE}/v1/receipts`, {
method: 'POST', headers, body: JSON.stringify({ image_base64, client_reference: reference, wait: 30 }),
});
if (res.status === 402) throw new Error('No credits left');
if (!res.ok) throw new Error(`${res.status} ${(await res.json()).code} — request ${res.headers.get('flovvy-request-id')}`);
let receipt = await res.json();
while (receipt.status === 'queued' || receipt.status === 'processing') { // not ready within 30 s: poll
await new Promise((r) => setTimeout(r, 2000));
res = await fetch(`${BASE}/v1/receipts/${receipt.id}`, { headers: { Authorization: `Bearer ${KEY}` } });
receipt = await res.json();
}
return receipt; // status: 'completed' or 'failed'
}
const r = await readReceipt('receipt.jpg', 'order-1042');
console.log(r.status, r.merchant?.name, r.totals?.total, r.currency, `${r.lines.length} lines`, 'reconciled:', r.reconciled);
Python (requests)
import base64, os, time, requests
BASE = os.environ["FLOVVY_BASE_URL"]
KEY = os.environ["FLOVVY_KEY"]
AUTH = {"Authorization": f"Bearer {KEY}"}
def read_receipt(path, reference):
with open(path, "rb") as f:
image_base64 = base64.b64encode(f.read()).decode()
res = requests.post(f"{BASE}/v1/receipts",
headers={**AUTH, "Idempotency-Key": reference},
json={"image_base64": image_base64, "client_reference": reference, "wait": 30},
timeout=40)
if res.status_code == 402:
raise RuntimeError("No credits left")
res.raise_for_status()
receipt = res.json()
while receipt["status"] in ("queued", "processing"): # not ready within 30 s: poll
time.sleep(2)
receipt = requests.get(f"{BASE}/v1/receipts/{receipt['id']}", headers=AUTH, timeout=10).json()
return receipt # status: "completed" or "failed"
r = read_receipt("receipt.jpg", "order-1042")
print(r["status"], (r["merchant"] or {}).get("name"), (r["totals"] or {}).get("total"), r["currency"], r["reconciled"])
for line in r["lines"]:
print(line["n"], line["kind"], line["description"], line["quantity"], line["weight"], line["unit"], line["total"])
curl — read a receipt and the balance
curl "$FLOVVY_BASE_URL/v1/receipts/rcp_966dfd59aa2148328aad3878a08cb4fd" -H "Authorization: Bearer $FLOVVY_KEY"
curl "$FLOVVY_BASE_URL/v1/balance" -H "Authorization: Bearer $FLOVVY_KEY"
14. Data retention
- Photos are deleted 7 days after the receipt was sent.
- Receipts (with their result) are deleted 90 days after they were sent. After that,
GET /v1/receipts/{id}returns404 not_found: store the results you need on your side. - Your credit history is kept (it is what you are billed on), without the receipt content.
- Receipts we misread —
failed, orreconciled: false— may be kept up to 12 months to improve the reading of your receipts and the service, unless you opt out (in the console, Team & account → Documents, or by writing to us; data processing agreement, article 13). Receipts in thehealthcategory never are. They are never shown to other customers or used to train anyone else's AI.
15. Support
Write to us with the Flovvy-Request-Id of the call and, if there is one, the receipt id. With those two we can find exactly what happened. Never send us your API key.