Flovvyfor business

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.

Last updated 29 October 2026. The examples are real responses of the API.


1. Quick start

You receive from us:

WhatLooks likeUse
Base URLhttps://phxkxiktoklomfmflvcw.supabase.co/functions/v1/b2b-apiprefix of every call
Test keyflv_test_… (52 characters)free, returns a fixed example — build your integration with it
Live keyflv_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_…

3. Credits

Live receipts are prepaid with credits: 1 credit = 1 receipt. You buy credits from us; we load them on your account.


4. Send a receipt — POST /v1/receipts

Body (JSON):

FieldTypeRequiredDescription
image_base64stringyesThe photo, base64 (no data: prefix). Max 8 MB once decoded.
mimestringnoimage/jpeg (default), image/png or image/webp.
client_referencestring, 1-128noYour own reference, returned as is.
waitinteger, 0-30noSeconds 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.

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:

  1. 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 get 200 with the result. If not ready in time you get 202 and continue with option 2.
  2. Poll — GET /v1/receipts/{id} every 1-2 seconds until status is completed or failed.

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.


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

FieldTypeDescription
idstringrcp_ + 32 hex characters.
object"receipt"
statusstringqueued, processing, completed, failed.
environmentstringlive or test.
client_referencestring | nullWhat you sent.
created_at, completed_atISO 8601 UTCcompleted_at is set when the receipt is completed or failed.
schema_version"2026-10-15"Version of this format (section 12).
pipeline_versionstring | nullVersion of our reading engine that produced the result.
merchantobject | nullname: 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).
categorystring | nullOne of the codes below.
issued_onYYYY-MM-DD | nullThe date printed on the receipt.
currencyISO 4217 | nullFor example EUR.
totalsobject | nullSee below.
linesarraySee below. Empty while not completed.
reconciledboolean | nullSee below.
reconciliationobject | nullSee below.
failureobject | null{ "code": …, "credit_refunded": true | false } when failed.
warningsarray of stringsSee below.

Categories: groceries, food_and_drink, vehicle, home, utilities, health, shopping, entertainment, sport, education, travel, technology, subscriptions, gifts, beauty, tobacco, other.

totals

FieldDescription
totalThe total paid.
total_sourceprinted (read from the receipt) or sum_of_lines (no total printed: we added up the lines).
taxTax (VAT) when printed.
discountsDiscounts applied to the whole receipt, as a positive number. Line discounts are lines of their own.
net_of_taxTotal without tax, when printed.

lines

In printed order. Amounts are numbers in the receipt's currency.

FieldDescription
nLine number, from 1.
kinditem (something bought) or discount (a negative line).
descriptionThe printed text, never translated. null for discount lines.
quantityNumber of pieces. null for discounts.
unit_pricePrice of one piece.
totalLine total as printed; negative for discounts.
weightWeight or volume of one piece, in unit (for example 1.29 kg of bananas).
unitkg, g, l, ml, lb, oz, m, ft, or null.
price_per_unitPrice per unit (for example 2.00 per kg).
applies_toFor 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).

FieldDescription
statusreconciled, not_reconciled, not_applicable.
deltaLines minus printed total, with the best combination below.
toleranceAlways 0: exact match.
basisWhich 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.

CodeMeaningCreditWhat to do
unreadableThe photo was read, but it is not a readable receipt.usedRetake the photo.
total_not_foundThe photo was read, but no total could be found on it.usedRetake the photo with the whole receipt.
provider_unavailableOur AI provider was unavailable for 3 attempts.refundedSend it again later.
timeoutReading took too long, 3 times.refundedSend it again later.
processing_errorSomething 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

CodeMeaning
merchant_not_printedNo merchant name on the receipt: merchant.name is null.
issued_on_not_printedNo date on the receipt: issued_on is null.
total_not_printedNo total printed: totals.total is the sum of the lines (total_source: "sum_of_lines").
lines_not_itemizedThe receipt shows no itemised lines (only a total): lines may be empty.
test_modeCreated 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" }
FieldDescription
creditsEverything you can use now: monthly plan and packs together.
plan_creditsThe part from your monthly plan (0 without a plan). These are used first.
plan_credits_expire_atEnd 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_atLast 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"
}
HTTPcodeWhenRetry?
400invalid_requestBody or header not valid (detail says which field).No — fix the request.
401unauthorizedKey missing, malformed, unknown or revoked.No.
402insufficient_creditsNo credit left. Nothing was stored.After a top-up.
403forbiddenAccount suspended, or the key lacks the permission for this call.No — contact us.
404not_foundUnknown path, or no such receipt for you.No.
405method_not_allowedWrong method (Allow header says the right one).No.
409idempotency_conflictSame Idempotency-Key, different request.No — use a new key.
409receipt_not_completedRisk checks only: the receipt is still queued or processing, or it failed.When it is completed.
413image_too_largeImage over 8 MB.No — resize.
415unsupported_media_typemime not JPEG, PNG or WebP.No.
500internal_errorOur fault.Yes, with the same Idempotency-Key.
503service_unavailableTemporarily unavailable.Yes, after Retry-After seconds, same key.

11. Response headers

HeaderDescription
Flovvy-Request-IdOn every response. Quote it when you contact us.
Flovvy-Credits-RemainingLive receipts: your balance after this receipt reserved its credit; also on a 402.
LocationOn 202: where to read the receipt.
Idempotent-Replayedtrue when the answer replays an earlier request with the same Idempotency-Key.
Retry-AfterOn 503: seconds to wait.

12. Versions and compatibility


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


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.