Wire Transfer Authorization
Add step-up verification before high-value wire transfers — the #1 enterprise use case for FaceSign.
Wire fraud costs financial institutions billions annually. A compromised password or session token is all an attacker needs to initiate a high-value wire. FaceSign adds a real-time, AI-powered verification step between the transfer request and the authorization — proving the actual account holder is present, not coerced, and not a deepfake.
Step-up, not login -- FaceSign is not a login gate. Your user is already authenticated. FaceSign triggers after your system detects a high-risk action like a wire transfer above your threshold.
How it works
- Your transfer service detects a wire above your risk threshold (e.g., $10,000).
- Your backend creates a FaceSign session with liveness detection, a conversation node to confirm transaction details, and an email OTP.
- You embed
clientSecret.urlas aniframesrcinside your authorization page — the verification runs inline on your domain, keeping the user in your app and branding. Redirecting the user to the hosted URL is the fallback when an iframe can't be used (e.g., strict parent-frame CSP, native mobile wrappers without a webview). - FaceSign's AI avatar walks the user through verification — confirming the transfer details aloud and checking for signs of coercion.
- Your webhook handler receives the result and authorizes or blocks the transfer.
Build the flow
Define the verification flow
Create a session with three verification steps: liveness detection to confirm a real person is present, a conversation node where the avatar reads back the transaction details and asks the user to confirm, and an email OTP as a second factor.
{
"clientReferenceId": "txn-8827-user-4421",
"metadata": {
"transferAmount": "50000.00",
"transferCurrency": "USD",
"recipientName": "Acme Corp",
"recipientAccount": "****7890",
"riskScore": "high"
},
"providedData": {
"email": "accountholder@example.com"
},
"flow": [
{ "id": "start", "type": "start", "outcome": "greeting" },
{
"id": "greeting",
"type": "conversation",
"prompt": "Say: Hi! I'm verifying a wire transfer on your account. First I'll take a quick look to confirm it's really you, then we'll go over the transfer details.",
"outcomes": [
{ "id": "next", "targetNodeId": "liveness", "condition": "" }
]
},
{
"id": "liveness",
"type": "liveness_detection",
"outcomes": {
"livenessDetected": "confirm_transfer",
"deepfakeDetected": "closing",
"noFace": "closing"
}
},
{
"id": "confirm_transfer",
"type": "conversation",
"prompt": "Say: I need to verify a wire transfer on your account. You're requesting a transfer of $50,000 USD to Acme Corp, account ending in 7890. Can you confirm you're authorizing this transfer?",
"outcomes": [
{
"id": "confirmed",
"targetNodeId": "email_otp",
"condition": "The user explicitly confirmed they authorize this transfer"
},
{
"id": "denied",
"targetNodeId": "closing",
"condition": "The user declined, said this is not their transfer, or disputed any of the details"
},
{
"id": "stall",
"targetNodeId": "closing",
"condition": "Conversation reached 4 exchanges with no clear answer"
}
]
},
{
"id": "email_otp",
"type": "two_factor_email",
"otpLength": 6,
"expirySeconds": 300,
"outcomes": {
"verified": "closing",
"delivery_failed": "closing",
"failed_unverified": "closing",
"cancelled": "closing"
}
},
{
"id": "closing",
"type": "conversation",
"prompt": "Thank the user for completing the verification and tell them the session is now complete. Keep it to one sentence.",
"doesNotRequireReply": true,
"outcomes": [{ "id": "done", "targetNodeId": "end", "condition": "" }]
},
{ "id": "end", "type": "end" }
],
"videoAIAnalysisEnabled": true
}Coercion detection -- With videoAIAnalysisEnabled: true, FaceSign's post-session video analysis checks for signs of duress: unusual eye movement, stressed speech patterns, or off-screen prompting. This is the key differentiator for wire fraud prevention — even if the real user is present, coercion detection catches scenarios where they are being forced to authorize a transfer.
Create the session from your backend
When your transfer service flags a wire above the threshold, call the FaceSign API to create a session.
async function requireStepUp(transfer, user) {
const res = await fetch('https://api.facesign.ai/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FACESIGN_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
clientReferenceId: `txn-${transfer.id}-user-${user.id}`,
metadata: {
transferAmount: transfer.amount,
transferCurrency: transfer.currency,
recipientName: transfer.recipient.name,
recipientAccount: transfer.recipient.maskedAccount,
riskScore: transfer.riskScore,
},
providedData: {
email: user.email,
},
flow: [
{ id: 'start', type: 'start', outcome: 'greeting' },
{
id: 'greeting',
type: 'conversation',
prompt:
"Say: Hi! I'm verifying a wire transfer on your account. First I'll take a quick look to confirm it's really you, then we'll go over the transfer details.",
outcomes: [{ id: 'next', targetNodeId: 'liveness', condition: '' }],
},
{
id: 'liveness',
type: 'liveness_detection',
outcomes: {
livenessDetected: 'confirm_transfer',
deepfakeDetected: 'closing',
noFace: 'closing',
},
},
{
id: 'confirm_transfer',
type: 'conversation',
prompt: `Say: I need to verify a wire transfer on your account. You're requesting a transfer of $${transfer.amount} ${transfer.currency} to ${transfer.recipient.name}, account ending in ${transfer.recipient.maskedAccount}. Can you confirm you're authorizing this transfer?`,
outcomes: [
{
id: 'confirmed',
targetNodeId: 'email_otp',
condition: 'The user explicitly confirmed they authorize this transfer',
},
{
id: 'denied',
targetNodeId: 'closing',
condition:
'The user declined, said this is not their transfer, or disputed any of the details',
},
{
id: 'stall',
targetNodeId: 'closing',
condition: 'Conversation reached 4 exchanges with no clear answer',
},
],
},
{
id: 'email_otp',
type: 'two_factor_email',
otpLength: 6,
expirySeconds: 300,
outcomes: {
verified: 'closing',
delivery_failed: 'closing',
failed_unverified: 'closing',
cancelled: 'closing',
},
},
{
id: 'closing',
type: 'conversation',
prompt:
'Thank the user for completing the verification and tell them the session is now complete. Keep it to one sentence.',
doesNotRequireReply: true,
outcomes: [{ id: 'done', targetNodeId: 'end', condition: '' }],
},
{ id: 'end', type: 'end' },
],
videoAIAnalysisEnabled: true,
}),
})
const { session, clientSecret } = await res.json()
// Store the session ID against the pending transfer
await db.transfers.update(transfer.id, {
status: 'pending_verification',
facesignSessionId: session.id,
})
// Hand this URL to the frontend and embed it as an iframe `src` so
// verification runs inline on your domain. Only redirect the user to
// it directly if an iframe is not an option.
return clientSecret.url
}import os
import requests
def require_step_up(transfer, user):
res = requests.post(
"https://api.facesign.ai/sessions",
headers={
"Authorization": f"Bearer {os.environ['FACESIGN_API_KEY']}",
"Content-Type": "application/json",
},
json={
"clientReferenceId": f"txn-{transfer['id']}-user-{user['id']}",
"metadata": {
"transferAmount": transfer["amount"],
"transferCurrency": transfer["currency"],
"recipientName": transfer["recipient"]["name"],
"recipientAccount": transfer["recipient"]["masked_account"],
"riskScore": transfer["risk_score"],
},
"providedData": {
"email": user["email"],
},
"flow": [
{"id": "start", "type": "start", "outcome": "greeting"},
{
"id": "greeting",
"type": "conversation",
"prompt": (
"Say: Hi! I'm verifying a wire transfer on your account. "
"First I'll take a quick look to confirm it's really you, "
"then we'll go over the transfer details."
),
"outcomes": [
{"id": "next", "targetNodeId": "liveness", "condition": ""}
],
},
{
"id": "liveness",
"type": "liveness_detection",
"outcomes": {
"livenessDetected": "confirm_transfer",
"deepfakeDetected": "closing",
"noFace": "closing",
},
},
{
"id": "confirm_transfer",
"type": "conversation",
"prompt": (
f"Say: I need to verify a wire transfer on your account. "
f"You're requesting a transfer of ${transfer['amount']} "
f"{transfer['currency']} to {transfer['recipient']['name']}, "
f"account ending in {transfer['recipient']['masked_account']}. "
f"Can you confirm you're authorizing this transfer?"
),
"outcomes": [
{
"id": "confirmed",
"targetNodeId": "email_otp",
"condition": "The user explicitly confirmed they authorize this transfer",
},
{
"id": "denied",
"targetNodeId": "closing",
"condition": "The user declined, said this is not their transfer, or disputed any of the details",
},
{
"id": "stall",
"targetNodeId": "closing",
"condition": "Conversation reached 4 exchanges with no clear answer",
},
],
},
{
"id": "email_otp",
"type": "two_factor_email",
"otpLength": 6,
"expirySeconds": 300,
"outcomes": {
"verified": "closing",
"delivery_failed": "closing",
"failed_unverified": "closing",
"cancelled": "closing",
},
},
{
"id": "closing",
"type": "conversation",
"prompt": (
"Thank the user for completing the verification and "
"tell them the session is now complete. Keep it to one sentence."
),
"doesNotRequireReply": True,
"outcomes": [
{"id": "done", "targetNodeId": "end", "condition": ""}
],
},
{"id": "end", "type": "end"},
],
"videoAIAnalysisEnabled": True,
},
)
data = res.json()
session = data["session"]
client_secret = data["clientSecret"]
# Store the session ID against the pending transfer
db.transfers.update(
transfer["id"],
status="pending_verification",
facesign_session_id=session["id"],
)
# Hand this URL to the frontend and embed it as an iframe `src` so
# verification runs inline on your domain. Only redirect the user to
# it directly if an iframe is not an option.
return client_secret["url"]Handle the webhook
When the session completes, FaceSign sends a session.status webhook. Fetch the full session to inspect the results and decide whether to authorize the transfer.
import crypto from 'node:crypto'
// Verify an HMAC-SHA256 signature of the form `t=<timestamp>,v1=<hex>`
// over `${timestamp}.${rawBody}`, using the webhook secret from the
// FaceSign dashboard. Reject any request whose signature doesn't match.
function verifyFacesignSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader) return false
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('='))
)
if (!parts.t || !parts.v1) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`, 'utf8')
.digest('hex')
try {
return crypto.timingSafeEqual(
Buffer.from(parts.v1, 'hex'),
Buffer.from(expected, 'hex')
)
} catch {
return false
}
}
export async function POST(req) {
// Read the raw body before parsing — signature is computed over the
// exact bytes we received.
const rawBody = await req.text()
const signature = req.headers.get('x-facesign-signature')
if (!verifyFacesignSignature(rawBody, signature, process.env.FACESIGN_WEBHOOK_SECRET)) {
return new Response('Invalid signature', { status: 401 })
}
const event = JSON.parse(rawBody)
if (event.type !== 'session.status') {
return Response.json({ received: true })
}
// Fetch the full session to get node reports. GET /sessions/:id
// returns `{ session, clientSecret }`.
const sessionRes = await fetch(
`https://api.facesign.ai/sessions/${event.sessionId}`,
{
headers: {
Authorization: `Bearer ${process.env.FACESIGN_API_KEY}`,
},
}
)
const { session } = await sessionRes.json()
// Find the pending transfer
const transfer = await db.transfers.findBy({
facesignSessionId: event.sessionId,
})
if (!transfer) {
return Response.json({ received: true })
}
// Decide: authorize or block
const livenessReport = session.report?.nodeReports
?.find((r) => r.nodeId === 'liveness')
const otpReport = session.report?.nodeReports
?.find((r) => r.nodeId === 'email_otp')
const livenessPass = livenessReport?.outcome === 'livenessDetected'
const otpPass = otpReport?.outcome === 'verified'
const sessionComplete = session.status === 'complete'
if (sessionComplete && livenessPass && otpPass) {
await db.transfers.update(transfer.id, { status: 'authorized' })
await transferService.execute(transfer.id)
} else {
await db.transfers.update(transfer.id, { status: 'blocked' })
await alerting.notify('wire_blocked', {
transferId: transfer.id,
reason: !livenessPass ? 'liveness_failed' : 'otp_failed',
})
}
return Response.json({ received: true })
}import hmac, hashlib, json, os
from flask import Flask, request, jsonify, abort
import requests
app = Flask(__name__)
def verify_facesign_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
"""HMAC-SHA256 of ``{timestamp}.{raw_body}`` must match ``v1`` in the header."""
if not signature_header:
return False
try:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
except ValueError:
return False
if "t" not in parts or "v1" not in parts:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{parts['t']}.".encode("utf-8") + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(parts["v1"], expected)
@app.route("/webhooks/facesign", methods=["POST"])
def handle_webhook():
# Read the raw body before parsing — signature is computed over the
# exact bytes we received.
raw_body = request.get_data()
signature = request.headers.get("X-FaceSign-Signature")
if not verify_facesign_signature(
raw_body, signature, os.environ["FACESIGN_WEBHOOK_SECRET"]
):
abort(401, description="Invalid signature")
event = json.loads(raw_body)
if event["type"] != "session.status":
return jsonify({"received": True}), 200
# Fetch the full session to get node reports. GET /sessions/:id
# returns {"session": ..., "clientSecret": ...}.
session_res = requests.get(
f"https://api.facesign.ai/sessions/{event['sessionId']}",
headers={
"Authorization": f"Bearer {os.environ['FACESIGN_API_KEY']}",
},
)
session = session_res.json()["session"]
# Find the pending transfer
transfer = db.transfers.find_by(
facesign_session_id=event["sessionId"]
)
if not transfer:
return jsonify({"received": True}), 200
# Decide: authorize or block
node_reports = (session.get("report") or {}).get("nodeReports") or []
liveness_report = next(
(r for r in node_reports if r["nodeId"] == "liveness"), None
)
otp_report = next(
(r for r in node_reports if r["nodeId"] == "email_otp"), None
)
liveness_pass = liveness_report and liveness_report.get("outcome") == "livenessDetected"
otp_pass = otp_report and otp_report.get("outcome") == "verified"
session_complete = session["status"] == "complete"
if session_complete and liveness_pass and otp_pass:
db.transfers.update(transfer["id"], status="authorized")
transfer_service.execute(transfer["id"])
else:
db.transfers.update(transfer["id"], status="blocked")
alerting.notify(
"wire_blocked",
transfer_id=transfer["id"],
reason="liveness_failed" if not liveness_pass else "otp_failed",
)
return jsonify({"received": True}), 200Implement the transfer decision
Use this decision matrix to map FaceSign outcomes to transfer actions:
| Liveness | Conversation | OTP | Action |
|---|---|---|---|
livenessDetected | Confirmed | verified | Authorize the transfer |
deepfakeDetected | Any | Any | Block and escalate to fraud team |
livenessDetected | Denied | Any | Block -- user did not confirm details |
noFace | Any | Any | Block -- no face detected, prompt retry |
| Any | Any | failed_unverified | Block -- OTP failed |
Session status incomplete | -- | -- | Block -- user abandoned before finishing |
Why conversation matters for wire fraud
Traditional step-up verification checks whether the user is present. FaceSign's conversation node checks what they know about the transaction. The avatar reads the transfer details aloud and asks the user to confirm. This catches two critical scenarios:
- Account takeover: An attacker who has stolen credentials cannot confirm transfer details they did not initiate.
- Coercion: A user being forced to authorize a transfer under duress exhibits behavioral signals that FaceSign's video AI analysis detects -- unusual speech patterns, eye movements suggesting off-screen prompting, or visible distress.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Session created but user never reaches verification | Hosted URL not delivered to the client | Return clientSecret.url to your frontend and render it as an iframe src inside your app (preferred — keeps users on your domain); redirect to it directly only if an iframe is not an option |
| Webhook never arrives | Webhook endpoint not registered, unreachable, or rejected (non-2xx) | Register the endpoint in your FaceSign dashboard (webhooks are account-level, not per-session), make sure it is publicly reachable over HTTPS, and respond 2xx. Temporarily point it at webhook.site to confirm delivery |
| Webhooks return 401 from your handler | Signature check failed | Verify the X-FaceSign-Signature HMAC with the signing secret from the dashboard, over the raw request body and the timestamp from the signature header |
Liveness always returns noFace | User's camera is blocked or the environment is too dark | Permissions are requested automatically — check the browser's site settings and ensure the user allowed camera access. Do not add a PERMISSIONS node by default |
| OTP email not delivered | Wrong address or delivery blocked by spam filter | two_factor_email sends the code to providedData.email if you pre-filled it (recommended for wire transfers — binds the OTP to the account holder on file); otherwise it asks the user for an email during the node. Verify the value you pre-filled, watch for the delivery_failed outcome, and surface the failure to the user |
| Conversation node routes to wrong outcome | Condition text is ambiguous or mixes axes | Make each condition a single, explicit decision axis: "The user explicitly confirmed" vs. "The user declined / disputed details"; add a stall fallback for the "N exchanges with no clear answer" case |