Skip to content

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.

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.

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.

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.

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.

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.

samples/public-submit.ts
/**
* 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);
}