Verify page API
Fromenance is a communication provenance platform, and POST /v1/public/submit is the endpoint the verify widget calls. Use it directly when you build your own verify UI, a mobile app flow, or a chat bot step on your domain.
By the end you will be able to submit each of the four inputs, read every field of the response, poll for a screenshot verdict, and handle the two non 2xx cases.
Authentication
Section titled “Authentication”Two things authenticate a call: the header X-Site-Key: sk_pub_... and the request Origin, which must be in the key’s allowed origins. There is no secret. Browsers set Origin automatically; from a server or a native app set it to one of the allowed origins yourself, and treat the key as public.
Submit
Section titled “Submit”POST https://api.fromenance.com/v1/public/submit
{ "text": "…the pasted message, plain text or HTML…", "eml_base64": "…base64 of a .eml file…", "image_base64": "…base64 of a PNG, JPEG, or WebP…", "code": "KX73-PQ9G", "email": "jane.doe@example.com", "turnstile_token": "…"}| Field | Limit | Notes |
|---|---|---|
text |
200,000 characters | The message as the customer pasted it. HTML is fine |
eml_base64 |
2,000,000 characters | A full .eml with headers. Best input: headers let Fromenance check DKIM for your sending domains and read the innermost To |
image_base64 |
8,000,000 characters (6 MB decoded) | A screenshot. OCR runs asynchronously; only the extracted text feeds the deterministic extractors |
code |
12 characters | A reference code the customer typed. Combined with text or .eml when both are present |
email |
The address that received the message. Hashed with the tenant secret to match recipient_hash; the encrypted copy is purged after 30 days |
|
turnstile_token |
4,000 characters | Required after a 428 |
At least one of text, eml_base64, image_base64, or code is required. When eml_base64 is present it wins over text; when only image_base64 is present the response is 202 with a poll token.
Response: PublicVerdict
Section titled “Response: PublicVerdict”200 for text, .eml, and code. 202 for screenshots.
{ "status": "decided", "poll_token": null, "submission_id": "sub_01J...", "outcome": "verified", "rule": "code+recipient+content", "matched_sent_at": "2026-09-24T14:42:10.000Z", "signals": { "code_found": true, "recipient_match": true, "fingerprint_distance": 2 }, "display": { "institution_name": "Northfield Bank", "logo_url": "https://northfieldbank.example/logo.png", "accent": "#0f4c81", "fraud_contact": "fraud@northfieldbank.example", "support_url": "https://northfieldbank.example/security", "outcome_label": "Verified", "verdict_text": "Verified: this message matches a communication we registered and sent to you on September 24, 2026 at 2:42 PM UTC.", "next_step": "You can act on it. If anything still looks wrong, contact us at fraud@northfieldbank.example." }}| Field | Notes |
|---|---|
status |
decided or pending |
poll_token |
Set while pending; pass it to the verdict endpoint |
submission_id |
sub_..., the same id the admin app shows |
outcome |
verified, not_verified, known_fraud, or null while pending |
rule |
The matching rule, see Concepts |
matched_sent_at |
When the matched registration was sent, on Verified |
signals |
code_found, recipient_match (null when no address was given), fingerprint_distance (null when no body on either side) |
display |
Everything needed to render the answer in your branding. verdict_text is the locked block and next_step already contains your fraud contact |
Repeat submissions of the same message by the same customer return the same verdict and increment the count on the existing submission rather than creating a new one.
Poll for a screenshot
Section titled “Poll for a screenshot”GET https://api.fromenance.com/v1/public/verdict/{poll_token} with the same X-Site-Key and Origin. Returns the same shape; status becomes decided once OCR and matching finish, usually within 15 seconds. If the image could not be read the endpoint answers 502 upstream with the detail “We could not read that screenshot. Paste the message text instead.” The widget polls every 1.5 seconds for up to 60 seconds.
Errors
Section titled “Errors”| Status | Type | Meaning |
|---|---|---|
400 bad-request |
No input, or an image that is not PNG, JPEG, WebP, or GIF | |
401 unauthorized |
Missing or revoked site key, or an Origin not on the allow list |
|
413 payload-too-large |
Screenshot over 6 MB | |
428 turnstile-required |
The key is over its normal rate or has Turnstile required. Render Cloudflare Turnstile with your Turnstile site key and resend with turnstile_token |
|
429 rate-limited |
60 per minute per IP or 600 per minute per site key. Retry-After is 60 seconds for the IP limit and 10 for the key limit |
Errors are RFC 9457 problem documents; see Errors.
Sample
Section titled “Sample”/** * Call the public verify API directly, the same way the embeddable widget does. * Authenticated by the site key (sk_pub_...) and the page origin. No secret is involved. * * Environment: * FROMENANCE_SITE_KEY a site key whose allowed origins include the Origin you send * FROMENANCE_ORIGIN the page origin the site key allows, for example https://northfieldbank.example * FROMENANCE_API_ORIGIN optional, defaults to https://api.fromenance.com */
export interface PublicVerdict { status: "decided" | "pending"; poll_token: string | null; submission_id: string; outcome: "verified" | "not_verified" | "known_fraud" | null; rule: string | null; matched_sent_at: string | null; signals: { code_found: boolean; recipient_match: boolean | null; fingerprint_distance: number | null; } | null; display: { institution_name: string; logo_url: string | null; accent: string | null; fraud_contact: string | null; support_url: string | null; outcome_label: string; verdict_text: string; next_step: string; } | null;}
export interface SubmitInput { text?: string; eml_base64?: string; image_base64?: string; code?: string; email?: string; turnstile_token?: string;}
export interface PublicClientOptions { siteKey: string; origin: string; apiOrigin?: string; fetch?: typeof fetch;}
export async function submit(opts: PublicClientOptions, input: SubmitInput): Promise<PublicVerdict> { const api = (opts.apiOrigin ?? "https://api.fromenance.com").replace(/\/$/, ""); const f = opts.fetch ?? fetch; const res = await f(`${api}/v1/public/submit`, { method: "POST", headers: { "content-type": "application/json", "x-site-key": opts.siteKey, origin: opts.origin }, body: JSON.stringify(input), }); if (res.status === 428) throw new Error("Turnstile required: render the challenge and resend with turnstile_token"); if (res.status === 429) throw new Error(`Rate limited, retry after ${res.headers.get("retry-after")} seconds`); if (!res.ok) throw new Error(`submit failed: ${res.status} ${await res.text()}`); return (await res.json()) as PublicVerdict;}
/** Screenshots answer 202 with a poll token. Poll until status is decided; the widget polls every 1.5 s for 60 s. */export async function pollVerdict(opts: PublicClientOptions, token: string): Promise<PublicVerdict> { const api = (opts.apiOrigin ?? "https://api.fromenance.com").replace(/\/$/, ""); const f = opts.fetch ?? fetch; for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 1500)); const res = await f(`${api}/v1/public/verdict/${token}`, { headers: { "x-site-key": opts.siteKey, origin: opts.origin }, }); if (!res.ok) throw new Error(`poll failed: ${res.status}`); const v = (await res.json()) as PublicVerdict; if (v.status === "decided") return v; } throw new Error("screenshot verdict took longer than 60 seconds");}
export async function main(): Promise<void> { const siteKey = process.env.FROMENANCE_SITE_KEY; const origin = process.env.FROMENANCE_ORIGIN; if (!siteKey || !origin) throw new Error("Set FROMENANCE_SITE_KEY and FROMENANCE_ORIGIN"); const opts: PublicClientOptions = { siteKey, origin, apiOrigin: process.env.FROMENANCE_API_ORIGIN }; const verdict = await submit(opts, { text: "Not sure this email is from Northfield Bank? Reference: KX73-PQ9G", email: "jane.doe@example.com", }); const decided = verdict.status === "pending" && verdict.poll_token ? await pollVerdict(opts, verdict.poll_token) : verdict; console.log(decided.display?.outcome_label, decided.rule, decided.display?.verdict_text);}