Flovvyfor business

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.


1. The call

Base URLhttps://phxkxiktoklomfmflvcw.supabase.co/functions/v1/b2b-antifrode
CallPOST /v1/receipts/{id}/checks
Permissionreceipts:read (every key has it unless you asked otherwise)
Bodyoptional 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 to api.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

FieldTypeMeaning
expected.amountnumberthe amount that was paid (e.g. the card transaction)
expected.currencystringISO 4217 code of that amount (EUR); if it differs from the receipt's, the amounts are not compared
expected.datestringdate of the payment, YYYY-MM-DD or an ISO 8601 date-time (only the date is used)
expected.merchantstringmerchant name as you have it (the card statement descriptor is fine: SQ *BAR ROSSI)
date_tolerance_daysinteger 0-30how many days the receipt date may differ from the payment date (default 2)
amount_tolerancenumberhow 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": []
}

3. The signals

CodeSeverityWhen
duplicatehigh / mediumthe same receipt was already sent before, in the last 90 days (§4)
payment_amount_mismatchhighthe receipt total differs from expected.amount (beyond amount_tolerance)
payment_date_mismatchmediumthe receipt date is more than date_tolerance_days from expected.date
payment_merchant_mismatchmediumthe merchant on the receipt does not look like expected.merchant
payment_currency_differslowthe receipt and the payment are in different currencies: amounts not compared
ai_generatedhighthe image file declares it was generated by an AI model (C2PA content credentials or IPTC metadata)
ai_editedhighthe image file declares it was edited with an AI model
edited_with_softwarehigh / mediumthe 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_datemediumthe camera says the photo was taken more than one day before the date printed on the receipt
totals_do_not_add_upmediumthe lines, with the printed tax and discounts, do not add up to the printed total
line_arithmeticlowa line where quantity × unit price, or weight × price per unit, is not the line total
tax_mismatchlowamount before tax + tax ≠ total
screenshotlowthe file declares it is a screenshot
not_a_camera_photolowa 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:

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

StatuscodeWhen
400invalid_requestthe body is not JSON, has an unknown field, or a value out of range
401unauthorizedmissing, invalid or revoked API key
403forbiddenthe key has no receipts:read permission, or the account is suspended
404not_foundno receipt with this id for your account and environment
409receipt_not_completedthe 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).