Flovvy Receipts API — Risk checks
Part of the developer guide (section 9); the contract is in openapi.json.
Risk checks look at a receipt you have already sent and that was read, and tell you what does not add up: a duplicate, a payment that does not match, an edited or AI-generated image, totals that do not add up.
They are signals, not verdicts. A signal means "a person should look at this", never "this is fraud": a receipt can be photographed twice in good faith, a tip makes the payment higher than the receipt, and many apps strip or rewrite image metadata. You decide what to do with each signal.
- Checks run only when you ask for them, on one receipt at a time, as many times as you want.
- They use no credits and do not change the receipt.
- No AI is involved: the checks work on the result you already have and on the image file.
1. The call
| Base URL | https://phxkxiktoklomfmflvcw.supabase.co/functions/v1/b2b-antifrode |
|---|---|
| Call | POST /v1/receipts/{id}/checks |
| Permission | receipts:read (every key has it unless you asked otherwise) |
| Body | optional JSON (below); an empty body runs every check except the payment match |
The base URL is different from the main API (
…/b2b-api). It will become the same address when the API moves toapi.flovvy.app.
curl -X POST "https://phxkxiktoklomfmflvcw.supabase.co/functions/v1/b2b-antifrode/v1/receipts/rcp_966dfd59aa2148328aad3878a08cb4fd/checks" \
-H "Authorization: Bearer $FLOVVY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "expected": { "amount": 12.50, "currency": "EUR", "date": "2026-10-19", "merchant": "UBER *TRIP" } }'
The body
| Field | Type | Meaning |
|---|---|---|
expected.amount | number | the amount that was paid (e.g. the card transaction) |
expected.currency | string | ISO 4217 code of that amount (EUR); if it differs from the receipt's, the amounts are not compared |
expected.date | string | date of the payment, YYYY-MM-DD or an ISO 8601 date-time (only the date is used) |
expected.merchant | string | merchant name as you have it (the card statement descriptor is fine: SQ *BAR ROSSI) |
date_tolerance_days | integer 0-30 | how many days the receipt date may differ from the payment date (default 2) |
amount_tolerance | number | how much the amounts may differ (default 0: exact to the cent; use it for tips) |
All fields are optional; expected, if present, needs at least one of amount, date, merchant. Unknown fields are rejected with 400 invalid_request, so a typo never silently skips a check.
2. The response
{
"object": "receipt_checks",
"receipt_id": "rcp_966dfd59aa2148328aad3878a08cb4fd",
"environment": "live",
"checks_version": "2026-10-19",
"checked_at": "2026-10-19T19:40:12.381Z",
"risk": "high",
"signals": [
{ "code": "duplicate", "severity": "high",
"message": "Possible duplicate of rcp_5b1e…, sent on 2026-10-16: the same merchant and lines." },
{ "code": "payment_date_mismatch", "severity": "medium",
"message": "The receipt date (2026-10-18) is 5 days from the payment date (2026-10-23)." }
],
"checks": {
"arithmetic": { "status": "passed", "issues": [] },
"duplicates": { "status": "failed", "total_matches": 1, "matches": [
{ "receipt_id": "rcp_5b1e…", "created_at": "2026-10-16T08:12:44.000Z", "match": "same_lines" } ] },
"payment": {
"status": "failed",
"amount": { "result": "match", "receipt": 12.5, "expected": 12.5, "difference": 0 },
"date": { "result": "mismatch", "receipt": "2026-10-18", "expected": "2026-10-23", "days_apart": 5 },
"merchant": { "result": "match", "receipt": "ESSELUNGA SPA", "expected": "Esselunga Milano", "similarity": 0.73 }
},
"file": { "status": "passed", "reason": null, "format": "jpeg", "camera": "Apple iPhone 15", "software": "18.0",
"creator_tool": null, "taken_at": "2026-10-18T12:30:00", "content_credentials": false, "ai_declared": null }
},
"warnings": []
}
risk: the highest severity among the signals:none,low,medium,high. There is no score from 0 to 1 on purpose: it would suggest a precision nobody can promise.signals: what deserves attention, highest severity first.codeis stable (codes are added, never changed);messageis for people and may be reworded.checks: the details of the four checks, also when they pass.
3. The signals
| Code | Severity | When |
|---|---|---|
duplicate | high / medium | the same receipt was already sent before, in the last 90 days (§4) |
payment_amount_mismatch | high | the receipt total differs from expected.amount (beyond amount_tolerance) |
payment_date_mismatch | medium | the receipt date is more than date_tolerance_days from expected.date |
payment_merchant_mismatch | medium | the merchant on the receipt does not look like expected.merchant |
payment_currency_differs | low | the receipt and the payment are in different currencies: amounts not compared |
ai_generated | high | the image file declares it was generated by an AI model (C2PA content credentials or IPTC metadata) |
ai_edited | high | the image file declares it was edited with an AI model |
edited_with_software | high / medium | the file was saved by image-editing software (high: Photoshop, GIMP and similar; medium: photo-adjustment apps such as Lightroom or Camera Raw) |
photo_taken_before_receipt_date | medium | the camera says the photo was taken more than one day before the date printed on the receipt |
totals_do_not_add_up | medium | the lines, with the printed tax and discounts, do not add up to the printed total |
line_arithmetic | low | a line where quantity × unit price, or weight × price per unit, is not the line total |
tax_mismatch | low | amount before tax + tax ≠ total |
screenshot | low | the file declares it is a screenshot |
not_a_camera_photo | low | a PNG or WebP image with no camera information |
The arithmetic signals can also come from a misread digit, not only from an edited one: that is why they weigh little.
4. How each check works
Duplicates. Among the receipts you sent before this one, in the last 90 days and in the same environment (live or test), with the same total:
same_file: the very same image file (while images are kept: 7 days) → high;same_lines: the same lines (and the same merchant, when both receipts show one) → high;same_merchant_date_total: same merchant, date and total, different lines → medium.
Checking the original after a copy arrived does not flag the original: the copy is the duplicate. Two identical purchases on the same day (two coffees at the same bar) look like a duplicate: receipts rarely let us tell them apart.
Up to 10 matches are listed in matches, the strongest first; total_matches says how many were found. Only the 50 most recent receipts with the same total (and the same date, when it is printed) are compared. Receipts of other customers are never compared.
Payment. Amount: exact to the cent unless you set amount_tolerance. Date: within date_tolerance_days (default 2). Merchant: names are compared ignoring case, accents, punctuation and company forms (S.p.A., GmbH, B.V., Ltd…). They match when every word of one name is found in the other: the same word, a misprint (Carrefur / Carrefour), or — for your expected.merchant — a last word cut short as on card statements (SUMUP *TRATTORIA DA M matches Trattoria da Mario). One shared word alone is not enough: Bar Rossi is not Bar Bianchi. Names written in different alphabets (Greek / Latin) are not matched. Each part is match, mismatch, unknown (the receipt does not show it) or not_requested; the check is passed, failed, incomplete (something unknown, nothing wrong) or not_requested.
File. We read the standard metadata of the image (Exif, XMP, C2PA content credentials) — never the GPS position. Missing metadata is not a signal: many apps remove it. The file can be checked only while we keep it (7 days): after that, and for test receipts, file.status is not_available with a reason (deleted_after_7_days, test_mode, unavailable). Images resized in a browser (as on our try page) have no metadata left.
Arithmetic. Each line (quantity × unit price; weight × price per unit, also when the price is per kg and the weight in grams, per 100 g, per lb with ounces), the receipt (the same check as reconciled), and amount before tax + tax = total.
5. Errors
| Status | code | When |
|---|---|---|
| 400 | invalid_request | the body is not JSON, has an unknown field, or a value out of range |
| 401 | unauthorized | missing, invalid or revoked API key |
| 403 | forbidden | the key has no receipts:read permission, or the account is suspended |
| 404 | not_found | no receipt with this id for your account and environment |
| 409 | receipt_not_completed | the receipt is still queued or processing (retry when it is completed), or it failed |
6. Test mode
With a test key the checks run on test receipts. Every test receipt is the same sample, so from the second one on you will see the duplicate signal: an easy way to see a signal without spending anything. The file check is not_available (test_mode).