API Reference

One HTTP call scores any message, email, marketplace listing or job post across seven scam types — and returns a risk score, a verdict, a per-category breakdown, and plain-English recommendations. Usually under a second.

Everything is a JSON POST with your API key in a header. No SDK required — curl works. Get a key from the dashboard.

Base URL

All endpoints are served under your Fraud Filter origin:

base url
https://fraudfilter.space/api

So the scan endpoint is https://fraudfilter.space/api/check-job. All requests must be HTTPS.

Prefer to click around first? Try every request live with your own key in the API playground, or download the OpenAPI 3.1 spec to generate a client.

Official SDKs: npm i @fraudfilter/sdk (JS/TS) · pip install fraudfilter (Python). Both wrap the endpoints below and support text + screenshot scanning.

Authentication

Authenticate every request with your secret API key in the X-API-Key header. Keys look like sf_live_… and are created, rotated and revoked from the dashboard.

header
X-API-Key: sf_live_xxxxxxxxxxxxxxxxxxxxxxxx
Keep your key secret. It carries your full quota — never ship it in front-end JavaScript or a mobile app. Call Fraud Filter from your server, or proxy through your own backend. If a key leaks, rotate it from the dashboard. A missing or invalid key returns 401; a revoked key returns 403.

Quota & rate limits

Each successful scan consumes one call from your plan's monthly allowance. Responses include calls_remaining so you can track usage inline.

PlanIncluded calls
Starter10 / month
GrowthUnlimited
Pay as you goPrepaid credits — 1 per scan, no monthly reset

When the allowance is exhausted, scan endpoints return 429. Batch requests scan as many rows as remaining quota allows and mark the rest skipped_quota rather than failing the whole request. Check the live counter any time with GET /api/key/status.

Pay-as-you-go keys draw down a prepaid credit balance instead of a monthly allowance — each scan uses one credit, calls_remaining is your remaining credits, and at zero the API returns 429 until you top up. Buy or top up credit packs from the billing screen.

Errors

Errors use standard HTTP status codes and a JSON body with a human-readable detail:

error
{ "detail": "Provide 'content' (or a title/description) to scan." }
StatusMeaning
400Bad request — missing/invalid input (e.g. no content, batch over 50 rows)
401Missing or invalid API key
403Key revoked, or not linked to the action
429Monthly call limit reached — upgrade
500Detection engine error — safe to retry

Scan content

POST/api/check-job

The core endpoint. Send any text — an email, DM, job post, marketplace listing, investment pitch — and get a full fraud analysis. Provide content for free-form text, or use the structured job fields; at least one of content or title is required.

Business workspace keys also get org-scoped BEC guards layered on top of the content score: a message referencing a registered vendor from a wrong domain/bank account adds vendor_deviation to the response, and one impersonating a registered executive from a non-registered address adds executive_impersonation — either escalates the verdict to at least high, however clean the text looks. Manage these in the Vendors and Executives console screens.

Every scan also runs a deterministic West-African / Nigeria fraud layer. When locally-specific markers appear — BVN/NIN/OTP credential harvesting, EFCC/NDLEA/bank authority-impersonation demanding payment, 419 advance-fee language, or fake WAEC/JAMB/NPower portal fees — they're returned as regional_signals (each with a signal, category, severity and the matched terms) and the strongest ones escalate the verdict. Simply naming a Nigerian bank or agency is not enough on its own — escalation needs a money, credential, or ID request alongside it.

Body parameters

FieldTypeDescription
content *stringThe full text to analyze. Required unless you send title or image_data.
image_datastringBase64 of a screenshot (raw or a data:image/png;base64,… URL). The model reads the text in the image (built-in OCR) and scores it. Max ~5MB. Can be sent with or without content.
image_mimestringImage type, e.g. image/png or image/jpeg. Defaults to image/png.
contact_emailstringSender address. Checked against your blocklist; a match forces a critical verdict.
languagestringLanguage for the summary, reasons and recommendations: en (default), pcm (Nigerian Pidgin), yo (Yorùbá), ha (Hausa), ig (Igbo), or auto to answer in the content’s own language. JSON keys, verdicts and category names are always English; the response then includes reply_language.
check_linksbooleanOpen the links in content (up to 2) and weigh what’s there: the page itself, domain age, email setup, HTTPS certificate, traffic rank and Google’s list of dangerous sites. Makes verdicts on links much more accurate (a new but genuine shop isn’t flagged just for being new) but adds a few seconds. Default false. Findings come back in links_checked.
titlestringJob/listing title (structured input).
companystringCompany or advertiser name.
salarystringStated pay or price.
descriptionstringAlias for content if you prefer.
locationstringStated location.

Example request

curl
curl -X POST https://fraudfilter.space/api/check-job \ -H "X-API-Key: sf_live_your_key" \ -H "Content-Type: application/json" \ -d '{ "content": "Pay a $150 activation fee to start. Earn $25k/week from home!", "contact_email": "globalopps2024@gmail.com" }'
python
import requests r = requests.post( "https://fraudfilter.space/api/check-job", headers={"X-API-Key": "sf_live_your_key"}, json={"content": message_text, "contact_email": sender}, ) data = r.json() if data["overall_risk_score"] >= 50: block(message_text) # high / critical
node.js
const res = await fetch("https://fraudfilter.space/api/check-job", { method: "POST", headers: { "X-API-Key": process.env.FRAUDFILTER_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ content: messageText, contact_email: sender }), }); const data = await res.json();

Example response critical

200 OK
{ "overall_risk_score": 96, "overall_verdict": "critical", "scam_categories": { "advance_fee": { "score": 95, "flags": ["upfront_fee"], "details": "Asks for a $150 fee before any work." }, "fake_job": { "score": 88, "flags": ["unrealistic_pay"], "details": "$25k/week is not credible." } // …phishing, impersonation, investment, romance, fake_goods (all 7 always present) }, "signals": { "urgency": { "detected": true, "phrases": ["start now"], "severity": "high" }, "payment_request": { "detected": true, "methods": ["fee"], "amount_requested": "$150", "severity": "critical" }, "contact_mismatch": { "detected": true, "severity": "medium" } }, "reasons": [ { "type": "advance_fee", "severity": "critical", "description": "Upfront payment required." } ], "recommendations": [ "Do not pay any fee — legitimate jobs never charge to start." ], "explanation": { "summary": "Classic advance-fee job scam…", "confidence": 97 }, "blacklisted": { "value": "globalopps2024@gmail.com", "reason": "reported recruiter" }, "calls_remaining": 987 }

See the full field reference below. blacklisted is only present when the sender — or an email, phone number or link domain mentioned in the content — matches your blocklist.

Batch scan

POST/api/batch-scan

Score up to 50 rows in one request — ideal for triaging a CSV export of listings or inbound messages. Rows are scanned concurrently. Only successfully scored rows consume quota.

Body parameters

FieldTypeDescription
items *array1–50 objects, each { "content": string, "contact_email"?: string }.
curl
curl -X POST https://fraudfilter.space/api/batch-scan \ -H "X-API-Key: sf_live_your_key" -H "Content-Type: application/json" \ -d '{ "items": [ { "content": "Remote data entry, $40/hr, no experience…", "contact_email": "hr@acme.co" }, { "content": "Send 0.2 BTC to double your money", "contact_email": "x@telegram" } ] }'

Response

200 OK
{ "results": [ { "index": 0, "status": "ok", "verdict": "medium", "risk_score": 38, "top_category": "fake_job", "summary": "Pay looks too high for the role." }, { "index": 1, "status": "ok", "verdict": "critical", "risk_score": 94, "top_category": "investment", "summary": "Guaranteed-return crypto scam." } ], "scanned": 2, "total": 2, "calls_remaining": 985 }

Each result status is one of: ok, skipped (empty row), skipped_quota (beyond your remaining allowance), or error (with an error string). Results preserve their input index.

Batch jobs (async, up to 10k)

POST/api/batch/jobs

The synchronous batch caps at 50 rows. For screening tens of thousands of listings, submit them as a job: you get a job_id back immediately, then drain the work in chunks and poll for results. There's no queue to manage — each /process call scores a slice, so it fits inside serverless limits and you (or a cron) simply call it until the job is done.

Body parameters

FieldTypeDescription
items *arrayUp to 10,000 rows, each { content, contact_email? }.
webhook_urlstringOptional — we POST a batch_job.completed event here when the job finishes.

Lifecycle

CallDoes
POST /api/batch/jobsCreate the job → { job_id, status:"pending", total }.
POST /api/batch/jobs/{job_id}/processScore the next chunk (~15 rows). Repeat until done:true. Each scored row uses one credit; if you run out, the rest are marked skipped_quota.
GET /api/batch/jobs/{job_id}Read-only status + per-status counts.
GET /api/batch/jobs/{job_id}/results?offset=&limit=Per-row results in original order (idx, status, risk_score, verdict, top_category, summary).

The SDKs wrap this in one call — runBatchJob(items) (JS) / run_batch_job(items) (Python) create the job, drive it to completion, and return every result.

Streams (continuous scan)

POST/api/streams/{source_id}/ingest

For an ongoing flow of user-generated content — marketplace listings, support tickets, reviews, profiles — register a stream in the dashboard (Streams tab) instead of calling scan endpoints yourself. Every new item is scanned exactly once (deduped by your id) and the result is POSTed, HMAC-signed with the stream's own secret, to the stream's output webhook.

ModeHow items arrive
PushYou POST items to the ingest endpoint below. The first ~15 are scored immediately; the rest drain on the next scheduled run.
PullWe GET your JSON feed on every scheduled run (or on Poll now), read up to 200 items via the field paths you configure, and queue ones we haven't seen. A Supabase table works directly — use its REST URL with an apikey: … auth header.

Ingest body

FieldTypeDescription
items *arrayUp to 100 per call, each { id, content, sender? }. id is your own identifier — re-sending the same id is ignored.
curl
curl -X POST https://fraudfilter.space/api/streams/SOURCE_ID/ingest -H "X-API-Key: sf_live_your_key" -H "Content-Type: application/json" -d '{ "items": [ { "id": "listing_123", "content": "Brand new iPhone, pay by gift card only", "sender": "seller@example.com" } ] }'
200 OK
{ "received": 1, "queued": 1, "duplicates": 0, "skipped": 0, "scanned_now": 1, "flagged_now": 1, "pending": 0, "error": null }

Other calls

CallDoes
POST /api/streams/{source_id}/runAdvance the stream now: poll the feed (pull mode) and score the next chunk.
GET /api/streams/{source_id}/items?status=&limit=Most recent items, newest first (external_id, status, risk_score, verdict, top_category, summary, delivery_status).

Output event

Delivered when an item's risk_score is at or above the stream's threshold (or for every item, if the stream is set to deliver all). Signed exactly like webhooks — verify X-FraudFilter-Signature with the stream's signing secret.

POST → your output URL
{ "event": "stream.item.scanned", "created_at": "2026-09-25T10:00:00Z", "data": { "source_id": "…", "source_name": "Listings", "item_id": "listing_123", "risk_score": 92, "verdict": "critical", "top_category": "advance_fee", "summary": "…", "sender": "seller@example.com", "content_preview": "…" } }

Each scanned item uses one credit, metered against your newest active key. When you're out of quota, items stay pending and resume automatically once quota frees up — nothing is dropped. Failed deliveries retry automatically (see retries); delivery is at-least-once, so dedupe on the body's delivery_id (or data.item_id).

Reputation lookup

POST/api/reputation

Has this phone number, email address, domain or link been reported as a scam? Send up to 10 values; each comes back with a verdict built from community scam reports, contacts seen in high-risk messages checked by 2+ different people (scans), the shared blocklist, your own (and your team's) block and trust rules from the dashboard, and signals from the value itself: domain age, cheap TLDs, brand look-alikes (secure-gtbank.top), raw-IP links, link shorteners, throwaway or brand-on-free-mail addresses. No AI call, so it's fast. Lookups need a key with quota left but don't use a scan; they're limited to 60 per minute per key. GET /api/reputation?value=… looks up a single value.

Body parameters

FieldTypeDescription
values *string[]1–10 phone numbers (any format; Nigerian local numbers like 0803… match +234 803…), emails, domains or URLs.

Verdicts

verdictMeaning
known_scamOn the shared blocklist or one of your block rules, or reported by 3+ different people.
suspiciousrisk_score ≥ 40: reported by 1–2 people and/or strong warning signs.
no_known_reportsNothing found. That isn't proof it's safe.
trustedOn your or your team's trusted list.
invalidNot a phone number, email, domain or link.
curl
curl -X POST https://fraudfilter.space/api/reputation \ -H "X-API-Key: sf_live_your_key" -H "Content-Type: application/json" \ -d '{ "values": ["0803 123 4567", "secure-gtbank.top"] }'
200 OK
{ "results": [ { "input": "0803 123 4567", "kind": "phone", "value": "2348031234567", "verdict": "suspicious", "risk_score": 60, "summary": "Reported as a scam by 2 people.", "reports": { "count": 2, "first_seen": "2026-10-01", "last_seen": "2026-10-12", "categories": { "fake_job": 2 } }, "rule": null, "signals": [{ "code": "phone_country", "severity": "info", "detail": "Nigeria number (+234)" }] }, { "input": "secure-gtbank.top", "kind": "domain", "value": "secure-gtbank.top", "verdict": "suspicious", "risk_score": 100, "summary": "No reports yet, but 3 warning signs.", "reports": null, "rule": null, "signals": [ { "code": "cheap_tld", "severity": "medium", "detail": ".top domains are cheap and widely used in scams" }, { "code": "brand_lookalike", "severity": "high", "detail": "Uses the name Gtbank but isn't Gtbank's official website" }, { "code": "new_domain", "severity": "high", "detail": "Domain registered 4 days ago" } ] } ] }

scans is null or { sources, first_seen, last_seen, categories }: how many different people's checks found this contact in a high-risk message (shown from 2). It raises risk_score but never on its own makes a value a known_scam. rule.source is shared, team or yours. Signals with severity good are signs it’s a real operation and lower risk_score: popular_site (Tranco top-1M), email_configured (MX + SPF + DMARC), verified_org_cert. A young domain alone is only a mild signal. Warning codes: new_domain, young_domain, established_domain, safe_browsing (listed by Google’s Web Risk), no_valid_https, cheap_tld, brand_lookalike, phishy_words, punycode, ip_address_host, shortened_link, disposable_email, brand_on_free_email, free_email, phone_country.

Verdict feedback

POST/api/feedback

Tell us when a verdict was right or wrong (for example when your users dispute a flag). Wrong verdicts are reviewed by our team; a confirmed ruling on a site changes how every later check treats it. Free and doesn’t use a scan; limited to 30 per hour per key.

Body parameters

FieldTypeDescription
vote *stringright, wrong, or report (it’s a scam).
verdictstringThe overall_verdict you’re rating.
risk_scoreintegerThe overall_risk_score you’re rating.
links_checkedstring[]The scan’s links_checked, if any — tells us which site the vote is about.
notestringWhat was wrong, up to 500 characters.
content + share_contentstring, booleanThe scanned content — stored only when share_content is true.
curl
curl -X POST https://fraudfilter.space/api/feedback \ -H "X-API-Key: sf_live_your_key" -H "Content-Type: application/json" \ -d '{ "vote": "wrong", "verdict": "high", "risk_score": 75, "note": "This is our payment partner" }'

Brand protection

GET/api/brand/findings?days=90

For business workspaces: look-alike domains, phone numbers and emails impersonating the brands you protect (set them up on the Brand page). Found in high-risk scans across Fraud Filter — the app, WhatsApp, Telegram and the API — and in community scam reports; your official domains and contacts never match. Each finding has value, kind (domain · phone · email), why, first_seen, last_seen, sightings, sources, reports, max_risk and status. Use a workspace key; doesn’t use a scan.

New findings are also sent to your webhooks as brand.impersonation events: { event, created_at, brand: { id, name }, findings: [...] }, signed like every other delivery.

Verify domain

POST/api/verify-domain

Check whether a domain can actually send legitimate email — MX, SPF, DMARC and DKIM — over DNS. A company that can't receive mail or has no sender authentication is a strong impersonation/spoofing signal. Accepts a bare domain or a full email address.

Body parameters

FieldTypeDescription
domain *stringe.g. acme.com or jobs@acme.com (the host is extracted).
curl
curl -X POST https://fraudfilter.space/api/verify-domain \ -H "X-API-Key: sf_live_your_key" -H "Content-Type: application/json" \ -d '{ "domain": "acme.com" }'
200 OK
{ "domain": "acme.com", "resolves": true, "risk_score": 0, "verdict": "low", "is_free_email": false, "checks": { "mx": { "pass": true, "detail": "2 mail server(s)" }, "spf": { "pass": true, "detail": "v=spf1 include:_spf.google.com ~all" }, "dmarc": { "pass": true, "detail": "policy: reject" }, "dkim": { "pass": true, "detail": "selector 'google' found" } }, "mx_records": ["10 aspmx.l.google.com."], "flags": [], "summary": "Mail infrastructure and sender authentication look legitimate.", "calls_remaining": 984 }

A non-existent domain returns resolves: false with risk_score: 95. Free providers (gmail.com, outlook.com, …) return is_free_email: true — judge the individual sender, not the domain.

Brand spoof detection

POST/api/check-brand-spoof

Outbound brand protection: give us your real page and a suspected lookalike, and we tell you whether the suspect is a clone built to harvest your users' credentials.

Two layers run independently. Domain forensics are deterministic — typosquats, homoglyphs (paypa1, pay-pal), your brand buried in someone else's subdomain, punycode, bolted-on words like secure or verify. Content comparison fetches both pages and scores how closely the suspect reproduces your branding and sign-in UI. The domain layer needs no page fetch, so a phishing page that's already been taken down — or that cloaks automated requests — still gets a verdict.

Body parameters

FieldTypeDescription
official_url *stringYour real page — the one being impersonated. Usually your login page.
suspect_urlstringThe suspected lookalike. Best option: it's what the domain forensics score.
suspect_textstring…or the suspect's HTML/visible text, if we can't fetch it.
suspect_image_datastring…or a base64 screenshot of it (raw or a data: URL).
suspect_image_mimestringe.g. image/png. Defaults to image/png.

Supply at least one of suspect_url, suspect_text or suspect_image_data.

curl
curl -X POST https://fraudfilter.space/api/check-brand-spoof \ -H "X-API-Key: sf_live_your_key" -H "Content-Type: application/json" \ -d '{ "official_url": "https://acme.com/login", "suspect_url": "https://acme-secure-login.top/signin" }'
200 OK
{ "risk_score": 90, "verdict": "critical", "same_domain": false, "lookalike_domain": true, "domain_score": 85, "domain_signals": [ { "signal": "brand_plus_extras", "severity": "critical", "detail": "'acme-secure-login' is the brand name with 'securelogin' bolted on…" } ], "clone_likelihood": 93, "is_credential_harvester": true, "impersonated_brand": "Acme", "content_similarity": 96, "suspect": { "domain": "acme-secure-login.top", "source": "fetched", "has_password_field": true, "posts_to": ["collector-9x.top"] }, "flags": ["Its login form submits to a different domain (collector-9x.top) — credentials leave the site."], "recommendations": ["File a takedown with the registrar."], "explanation": { "summary": "Near-identical clone of your sign-in page.", "confidence": "high" }, "calls_remaining": 983 }

content_similarity is how much of your page's copy the suspect reproduces (0–100). clone_likelihood is null when we couldn't reach the suspect page and scored its domain alone — in that case suspect.source is "unreachable". If the suspect sits on your own registrable domain, same_domain is true and the risk is 0: it's your page, not a forgery.

Account-takeover check

POST/api/check-login-event

Stream your own users' login and security events and we score each one for account takeover. Unlike the content checks, this is a behavioural check: every event is compared against that user's own history, so a single sign-in that's fine in isolation is flagged when it's a new device, from a new country, doing something sensitive. No model runs — it's deterministic signal math, so it's fast and cheap. Requires a Business workspace key (events are scoped to your organization).

Body parameters

FieldTypeDescription
user_id *stringYour own end-user's id. Opaque to us — it's only the key we group history by.
device_fingerprintstringA stable per-device id from your client. Enables new-device detection.
ipstringSource IP of the event.
countrystringISO code or name. Drives new-country and impossible-travel detection.
actionstringWhat happened, e.g. login, password_change, payment_update, profile_edit. Money-moving / account-control actions raise the stakes.
user_agentstringClient user-agent (stored for the queue).
atstringISO timestamp of the event. Defaults to now.
curl
curl -X POST https://fraudfilter.space/api/check-login-event \ -H "X-API-Key: sf_live_your_key" -H "Content-Type: application/json" \ -d '{ "user_id": "user_8f21", "device_fingerprint": "d3v-9a1c", "ip": "102.89.x.x", "country": "NG", "action": "payment_update" }'
200 OK
{ "risk_score": 90, "verdict": "critical", "user_id": "user_8f21", "is_new_device": true, "is_new_ip": true, "is_new_country": true, "signals": [ { "code": "sensitive_action_new_context", "severity": "critical", "detail": "'payment_update' from a new device/location…" } ], "recommendations": ["Challenge this session with step-up authentication (MFA / re-verify)."], "baseline_events": 14, "calls_remaining": 982 }

The first event you ever send for a user_id is a quiet baseline (verdict low) — there's nothing to compare it against yet. As history accumulates the score sharpens. Signal codes include new_device, new_country, impossible_travel, sensitive_action_new_context, dormant_then_sensitive and ip_velocity. Risky events (score ≥ 25) also fire your registered webhooks and appear in the Takeover queue of your Business console.

Multi-channel correlation

POST/api/check-fraud-thread

Modern BEC blends channels: an email, then a phone call "to confirm", then an SMS with the account number. Each looks weak alone — the attack is the coordination. Send two or more communications about the same matter (on any mix of channels) and we score them as one attempt. A deterministic pass catches the hard cross-channel tells (the same amount or payout account pushed on multiple channels, urgency everywhere, a tight timeframe); a model pass judges the semantic coordination on top — so you still get a verdict even if the model is momentarily unavailable.

Body parameters

FieldTypeDescription
events *arrayTwo or more communications. Each: channel (email/phone/sms/chat/letter), content, optional sender (address/number/name) and at (ISO timestamp).
claimed_identitystringWho the events collectively claim to be from, e.g. "Jane Okafor, CEO".
curl
curl -X POST https://fraudfilter.space/api/check-fraud-thread \ -H "X-API-Key: sf_live_your_key" -H "Content-Type: application/json" \ -d '{ "claimed_identity": "Jane Okafor, CEO", "events": [ {"channel": "email", "content": "Please wire $48,000 to the new account.", "at": "2026-07-10T09:00:00"}, {"channel": "phone", "content": "Calling to confirm the $48k transfer I emailed - today.", "at": "2026-07-10T09:20:00"} ] }'
200 OK
{ "risk_score": 85, "verdict": "critical", "coordinated": true, "channels": ["EMAIL", "PHONE CALL"], "coordination_signals": [ { "code": "same_amount_across_channels", "severity": "high", "detail": "The same amount (48000) is pushed on 2 separate channels." } ], "scheme": "ceo_fraud", "the_ask": "Wire $48,000 to a new account today.", "recommendations": ["Verify the request with the executive on a known, trusted number before paying."], "calls_remaining": 981 }

Signal codes include multi_channel, same_amount_across_channels, same_account_across_channels, urgency_across_channels and tight_timeframe. The deterministic verdict only reaches high when a repeated ask (same amount/account) is corroborated by cross-channel time pressure — a single co-occurrence stays medium and defers to the model. model carries the full semantic analysis (or null if the model was unavailable and the verdict came from the deterministic signals alone).

Invoice / BEC screening

POST/api/check-invoice

Extract an invoice's payment fields from an image or PDF page and — optionally — diff them against a prior invoice from the same vendor. A changed bank account or remittance email between two invoices is the classic invoice-redirect (BEC/VEC) tell. Business keys also get a cross-check against your registered vendor baseline.

Body parameters

FieldTypeDescription
invoice_data *stringBase64 image or PDF-page of the new invoice (raw or data: URL).
invoice_mimestringe.g. image/png, application/pdf.
prior_datastringOptional prior invoice from the same vendor, to diff against.
prior_mimestringMIME of the prior invoice.
curl
curl -X POST https://fraudfilter.space/api/check-invoice \ -H "X-API-Key: sf_live_your_key" -H "Content-Type: application/json" \ -d '{ "invoice_data": "<base64-image-or-pdf>", "prior_data": "<base64-prior-invoice>" }'
200 OK
{ "risk_score": 90, "verdict": "high", "flags": ["bank account changed since the last invoice (NL12… → NL99…)."], "fields": { "vendor": "Acme Supplies", "iban": "NL99…", "amount": "12,400.00" }, "prior_fields": { "iban": "NL12…" }, "changes": [{ "field": "iban", "was": "NL12…", "now": "NL99…" }], "vendor_flags": [], "calls_remaining": 981 }

A changed payment field or a mismatch against a registered vendor floors the verdict at high regardless of how clean the document looks.

Conversation thread scan

POST/api/thread-scan

Score a whole conversation, not each message in isolation. Romance and grooming scams reveal themselves in the arc — love-bombing → building trust → isolation → the financial ask. Send the messages in order (theirs and yours) and get the trajectory read.

Body parameters

FieldTypeDescription
messages *arrayMessages in order. Each: direction ("them" or "me"), text, optional at (ISO timestamp).
contactstringWho the messages are from (name / handle / number).
curl
curl -X POST https://fraudfilter.space/api/thread-scan \ -H "X-API-Key: sf_live_your_key" -H "Content-Type: application/json" \ -d '{ "contact": "+1 555…", "messages": [ { "direction": "them", "text": "My love, I feel so close to you already…" }, { "direction": "them", "text": "My account is frozen — could you send $500 in gift cards?" } ] }'
200 OK
{ "overall_risk_score": 88, "overall_verdict": "high", "scam_type": "romance", "stage": "financial_ask", "patterns": ["love_bombing", "gift_card_request"], "financial_asks": ["$500 in gift cards"], "recommendations": ["Do not send money or gift cards."], "calls_remaining": 980 }

Reverse-image lookup

POST/api/reverse-image

Detect a stolen or stock/model photo — an image that already appears across the web. A strong catfish / fake-listing tell. Provide the image as base64 or a URL we can fetch. Requires the reverse-image add-on to be enabled on your account (otherwise returns 503).

Body parameters

FieldTypeDescription
image_datastringBase64 image (raw or data: URL). Provide this or image_url.
image_mimestringe.g. image/png, image/jpeg.
image_urlstringA public URL we can fetch instead of base64.
curl
curl -X POST https://fraudfilter.space/api/reverse-image \ -H "X-API-Key: sf_live_your_key" -H "Content-Type: application/json" \ -d '{ "image_url": "https://example.com/profile.jpg" }'
200 OK
{ "status": "ok", "matched": true, "signal": { "signal": "image_reused_across_web", "severity": "high", "reason": "This exact image already appears on 6 different websites…" }, "best_guess": "stock photo businessman", "calls_remaining": 979 }

signal is null when nothing was found. It's a supporting signal — surfaced on /api/check-job too when you attach a screenshot.

Deepfake / AI-image check

POST/api/check-deepfake

Detect a deepfake / face-swap or an AI-generated image — a fake-profile tell that catches synthetic photos appearing nowhere else online (complements reverse-image). Provide the image as base64 or a URL. Requires the deepfake add-on to be enabled (otherwise returns 503).

Body parameters

FieldTypeDescription
image_datastringBase64 image (raw or data: URL). Provide this or image_url.
image_mimestringe.g. image/png, image/jpeg.
image_urlstringA public URL we can fetch instead of base64.
curl
curl -X POST https://fraudfilter.space/api/check-deepfake \ -H "X-API-Key: sf_live_your_key" -H "Content-Type: application/json" \ -d '{ "image_url": "https://example.com/profile.jpg" }'
200 OK
{ "status": "ok", "matched": true, "signal": { "signal": "ai_generated_image", "severity": "high", "reason": "This image looks AI-generated, not a real photo…" }, "scores": { "deepfake": 0.08, "ai_generated": 0.94 }, "calls_remaining": 978 }

Key status

GET/api/key/status

Return the current plan and usage for the authenticating key. Doesn't consume quota.

curl
curl https://fraudfilter.space/api/key/status -H "X-API-Key: sf_live_your_key"
200 OK
{ "plan": "starter", "calls_used": 16, "calls_remaining": 984 }

Health

GET/api/health

Unauthenticated liveness check.

200 OK
{ "status": "ok", "version": "2.0.0", "stack": "Vercel + Supabase + Gemini" }

Scan response object

Returned by /api/check-job.

FieldTypeDescription
overall_risk_scoreinteger0–100, higher = riskier. Reflects the strongest threat present.
overall_verdictstringlow · medium · high · critical — see scale.
scam_categoriesobjectAll 7 categories, each { score, flags[], details }.
signalsobjecturgency, payment_request, contact_mismatch — each with detected + severity.
reasonsarrayRanked findings: { type, severity, description }.
recommendationsarrayActionable next-step strings.
explanationobject{ summary, confidence } — plain-English verdict + model confidence 0–100.
blacklistedobject?Present only on a blocklist hit: { value, reason }.
flaggedbooleantrue when overall_risk_score is at or above your account's flag threshold (set on the Rules page; default 50). Use it instead of hard-coding a cut-off.
flag_thresholdintegerThe threshold flagged was computed against.
ruleobject?Present only when one of your rules settled the scan without an AI check: { action, value, kind, shared, team }; team is true when the rule comes from your business workspace's shared rules. block = a blocked contact (critical, 95). allow = the sender and every email, link and phone number in the content are on your trusted list (low, 0). A trust rule is ignored when a hard red flag fires (a BVN/OTP/PIN request, a vendor bank-account change, executive impersonation) or an image is attached.
links_checkedstring[]?Present when check_links was on and the content had links: one plain-English line per link with what we found, e.g. "kobofoods.ng — business email set up properly (MX, SPF, DMARC), has policy and contact pages".
site_guardobject?Present when the content was only a link, the site showed no red flags and there was real evidence it’s a legitimate operation (e.g. a verified app-store app that lists the site, a top-traffic ranking, business email) — so the verdict was capped instead of flagging it on a hunch: { capped_from, cap }. Never applied when the content has a message to judge, or the site has any warning sign, imitates a brand, redirects elsewhere or asks for cards/logins.
extracted_contactsarrayEmails, phone numbers (digits) and link domains found in the content, as { value, kind } with kind email · phone · domain (free-webmail domains omitted). Handy for one-click blocking. Up to 10.
calls_remainingintegerCalls left in this billing period after this request.

Verdict scale

overall_verdict maps directly from overall_risk_score:

ScoreVerdictSuggested action
0–24lowLooks clean — allow.
25–49mediumSuspicious — warn or review.
50–79highLikely fraud — block or hold.
80–100criticalAlmost certainly fraud — block.

Scam categories

Every scan scores all seven, so a single call covers them at once:

KeyCovers
fake_jobFake/ghost job offers, unrealistic pay, chat-app-only hiring.
advance_feeUpfront fees, "pay to start", prize/inheritance scams.
phishingCredential/ID/bank-detail harvesting, spoofed links.
impersonationBrand/recruiter/agency impersonation, sender mismatch.
investmentGuaranteed returns, crypto/forex/USDT "opportunities".
romanceRapid emotional escalation, off-platform money asks.
fake_goodsCounterfeit/non-existent listings, too-good-to-be-true deals.

Webhooks

Register endpoints in the dashboard to receive a signed POST whenever a scan's risk score meets your configured threshold. Fires from both /api/check-job and /api/batch-scan.

Payload

POST your endpoint
{ "event": "scan.flagged", "created_at": "2026-06-21T09:14:00Z", "delivery_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "data": { "risk_score": 94, "verdict": "critical", "sender": "x@telegram", "content_preview": "Send 0.2 BTC to double your money…", "blacklisted": null } }

Verifying the signature

Each delivery carries three headers. Verify X-FraudFilter-Signature with an HMAC-SHA256 of the raw request body using your webhook's signing secret, then compare in constant time.

headers
X-FraudFilter-Event: scan.flagged X-FraudFilter-Signature: sha256=<hex digest> X-FraudFilter-Delivery: 7c9e6679-7425-40de-944b-e07fc1f90ae7
python — verify
import hmac, hashlib def verify(raw_body: bytes, header: str, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header)

Node.js — verify over the raw body (register the route with a raw body parser so the bytes aren't re-serialized):

node / express — verify
const crypto = require("crypto"); // app.post("/webhooks/fraudfilter", express.raw({ type: "application/json" }), handler) function verify(rawBody, header, secret) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); const a = Buffer.from(expected), b = Buffer.from(header || ""); return a.length === b.length && crypto.timingSafeEqual(a, b); }
go — verify
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" ) func verify(rawBody []byte, header, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(rawBody) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(header)) }

Retries & delivery log

A delivery that doesn't get a 2xx within 5 seconds (or can't connect) is retried automatically after 1m, 5m, 30m, 2h, 12h and 24h — 7 attempts over about 37 hours. After the last one we email the account owner and stop. Every attempt is listed in the dashboard's delivery log, where you can also retry or redeliver by hand. The same applies to stream output webhooks.

A retry re-sends the identical signed body — same delivery_id, same created_at — signed with the endpoint's current secret.

Idempotency & replays

Dedupe on the delivery_id in the body: it's covered by the signature (so it can't be altered) and stays the same across retries. Store the ids you've processed for a few days and ignore repeats — that handles both our retries and a captured request being replayed. (The X-FraudFilter-Delivery header echoes it for logging, but isn't signed — don't dedupe on the header alone.)

If you also enforce a freshness window on the signed created_at, make it at least 48 hours — a tighter window would reject our legitimate retries.

python — full check
import json from datetime import datetime, timezone def accept(raw_body: bytes, sig: str, secret: str, seen: set, max_age=48 * 3600) -> bool: if not verify(raw_body, sig, secret): return False evt = json.loads(raw_body) if evt["delivery_id"] in seen: # retry or replay — already handled return False ts = datetime.fromisoformat(evt["created_at"].replace("Z", "+00:00")) if not 0 <= (datetime.now(timezone.utc) - ts).total_seconds() <= max_age: return False seen.add(evt["delivery_id"]) return True
Respond 2xx within 5 seconds to acknowledge — do slow work after responding. Return a non-2xx only when you want us to retry.

Need something not covered here? Email support@fraudfilter.space or open the dashboard.