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.
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:
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.
X-API-Key: sf_live_xxxxxxxxxxxxxxxxxxxxxxxx
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.
| Plan | Included calls |
|---|---|
| Starter | 10 / month |
| Growth | Unlimited |
| Pay as you go | Prepaid 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:
{ "detail": "Provide 'content' (or a title/description) to scan." }
| Status | Meaning |
|---|---|
400 | Bad request — missing/invalid input (e.g. no content, batch over 50 rows) |
401 | Missing or invalid API key |
403 | Key revoked, or not linked to the action |
429 | Monthly call limit reached — upgrade |
500 | Detection engine error — safe to retry |
Scan content
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
| Field | Type | Description |
|---|---|---|
content * | string | The full text to analyze. Required unless you send title or image_data. |
image_data | string | Base64 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_mime | string | Image type, e.g. image/png or image/jpeg. Defaults to image/png. |
contact_email | string | Sender address. Checked against your blocklist; a match forces a critical verdict. |
language | string | Language 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_links | boolean | Open 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. |
title | string | Job/listing title (structured input). |
company | string | Company or advertiser name. |
salary | string | Stated pay or price. |
description | string | Alias for content if you prefer. |
location | string | Stated location. |
Example request
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"
}'
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
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
{
"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
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
| Field | Type | Description |
|---|---|---|
items * | array | 1–50 objects, each { "content": string, "contact_email"?: string }. |
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
{
"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)
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
| Field | Type | Description |
|---|---|---|
items * | array | Up to 10,000 rows, each { content, contact_email? }. |
webhook_url | string | Optional — we POST a batch_job.completed event here when the job finishes. |
Lifecycle
| Call | Does |
|---|---|
POST /api/batch/jobs | Create the job → { job_id, status:"pending", total }. |
POST /api/batch/jobs/{job_id}/process | Score 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)
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.
| Mode | How items arrive |
|---|---|
| Push | You POST items to the ingest endpoint below. The first ~15 are scored immediately; the rest drain on the next scheduled run. |
| Pull | We 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
| Field | Type | Description |
|---|---|---|
items * | array | Up to 100 per call, each { id, content, sender? }. id is your own identifier — re-sending the same id is ignored. |
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" } ] }'
{
"received": 1, "queued": 1, "duplicates": 0, "skipped": 0,
"scanned_now": 1, "flagged_now": 1, "pending": 0, "error": null
}
Other calls
| Call | Does |
|---|---|
POST /api/streams/{source_id}/run | Advance 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.
{
"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
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
| Field | Type | Description |
|---|---|---|
values * | string[] | 1–10 phone numbers (any format; Nigerian local numbers like 0803… match +234 803…), emails, domains or URLs. |
Verdicts
| verdict | Meaning |
|---|---|
known_scam | On the shared blocklist or one of your block rules, or reported by 3+ different people. |
suspicious | risk_score ≥ 40: reported by 1–2 people and/or strong warning signs. |
no_known_reports | Nothing found. That isn't proof it's safe. |
trusted | On your or your team's trusted list. |
invalid | Not a phone number, email, domain or link. |
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"] }'
{
"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
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
| Field | Type | Description |
|---|---|---|
vote * | string | right, wrong, or report (it’s a scam). |
verdict | string | The overall_verdict you’re rating. |
risk_score | integer | The overall_risk_score you’re rating. |
links_checked | string[] | The scan’s links_checked, if any — tells us which site the vote is about. |
note | string | What was wrong, up to 500 characters. |
content + share_content | string, boolean | The scanned content — stored only when share_content is true. |
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
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
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
| Field | Type | Description |
|---|---|---|
domain * | string | e.g. acme.com or jobs@acme.com (the host is extracted). |
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" }'
{
"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
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
| Field | Type | Description |
|---|---|---|
official_url * | string | Your real page — the one being impersonated. Usually your login page. |
suspect_url | string | The suspected lookalike. Best option: it's what the domain forensics score. |
suspect_text | string | …or the suspect's HTML/visible text, if we can't fetch it. |
suspect_image_data | string | …or a base64 screenshot of it (raw or a data: URL). |
suspect_image_mime | string | e.g. image/png. Defaults to image/png. |
Supply at least one of suspect_url, suspect_text or suspect_image_data.
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"
}'
{
"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
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
| Field | Type | Description |
|---|---|---|
user_id * | string | Your own end-user's id. Opaque to us — it's only the key we group history by. |
device_fingerprint | string | A stable per-device id from your client. Enables new-device detection. |
ip | string | Source IP of the event. |
country | string | ISO code or name. Drives new-country and impossible-travel detection. |
action | string | What happened, e.g. login, password_change, payment_update, profile_edit. Money-moving / account-control actions raise the stakes. |
user_agent | string | Client user-agent (stored for the queue). |
at | string | ISO timestamp of the event. Defaults to now. |
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"
}'
{
"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
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
| Field | Type | Description |
|---|---|---|
events * | array | Two or more communications. Each: channel (email/phone/sms/chat/letter), content, optional sender (address/number/name) and at (ISO timestamp). |
claimed_identity | string | Who the events collectively claim to be from, e.g. "Jane Okafor, CEO". |
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"}
]
}'
{
"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
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
| Field | Type | Description |
|---|---|---|
invoice_data * | string | Base64 image or PDF-page of the new invoice (raw or data: URL). |
invoice_mime | string | e.g. image/png, application/pdf. |
prior_data | string | Optional prior invoice from the same vendor, to diff against. |
prior_mime | string | MIME of the prior invoice. |
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>" }'
{
"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
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
| Field | Type | Description |
|---|---|---|
messages * | array | Messages in order. Each: direction ("them" or "me"), text, optional at (ISO timestamp). |
contact | string | Who the messages are from (name / handle / number). |
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?" }
] }'
{
"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
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
| Field | Type | Description |
|---|---|---|
image_data | string | Base64 image (raw or data: URL). Provide this or image_url. |
image_mime | string | e.g. image/png, image/jpeg. |
image_url | string | A public URL we can fetch instead of base64. |
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" }'
{
"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
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
| Field | Type | Description |
|---|---|---|
image_data | string | Base64 image (raw or data: URL). Provide this or image_url. |
image_mime | string | e.g. image/png, image/jpeg. |
image_url | string | A public URL we can fetch instead of base64. |
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" }'
{
"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
Return the current plan and usage for the authenticating key. Doesn't consume quota.
curl https://fraudfilter.space/api/key/status -H "X-API-Key: sf_live_your_key"
{ "plan": "starter", "calls_used": 16, "calls_remaining": 984 }
Health
Unauthenticated liveness check.
{ "status": "ok", "version": "2.0.0", "stack": "Vercel + Supabase + Gemini" }
Scan response object
Returned by /api/check-job.
| Field | Type | Description |
|---|---|---|
overall_risk_score | integer | 0–100, higher = riskier. Reflects the strongest threat present. |
overall_verdict | string | low · medium · high · critical — see scale. |
scam_categories | object | All 7 categories, each { score, flags[], details }. |
signals | object | urgency, payment_request, contact_mismatch — each with detected + severity. |
reasons | array | Ranked findings: { type, severity, description }. |
recommendations | array | Actionable next-step strings. |
explanation | object | { summary, confidence } — plain-English verdict + model confidence 0–100. |
blacklisted | object? | Present only on a blocklist hit: { value, reason }. |
flagged | boolean | true 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_threshold | integer | The threshold flagged was computed against. |
rule | object? | 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_checked | string[]? | 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_guard | object? | 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_contacts | array | Emails, 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_remaining | integer | Calls left in this billing period after this request. |
Verdict scale
overall_verdict maps directly from overall_risk_score:
| Score | Verdict | Suggested action |
|---|---|---|
| 0–24 | low | Looks clean — allow. |
| 25–49 | medium | Suspicious — warn or review. |
| 50–79 | high | Likely fraud — block or hold. |
| 80–100 | critical | Almost certainly fraud — block. |
Scam categories
Every scan scores all seven, so a single call covers them at once:
| Key | Covers |
|---|---|
fake_job | Fake/ghost job offers, unrealistic pay, chat-app-only hiring. |
advance_fee | Upfront fees, "pay to start", prize/inheritance scams. |
phishing | Credential/ID/bank-detail harvesting, spoofed links. |
impersonation | Brand/recruiter/agency impersonation, sender mismatch. |
investment | Guaranteed returns, crypto/forex/USDT "opportunities". |
romance | Rapid emotional escalation, off-platform money asks. |
fake_goods | Counterfeit/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
{
"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.
X-FraudFilter-Event: scan.flagged
X-FraudFilter-Signature: sha256=<hex digest>
X-FraudFilter-Delivery: 7c9e6679-7425-40de-944b-e07fc1f90ae7
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):
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);
}
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.
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
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.