Payment Authentication
Use FaceSign as a 3D Secure alternative for high-risk payment authorization.
3D Secure adds friction and drops conversion rates. FaceSign provides a faster, more secure alternative for payment step-up authentication: instead of redirecting to a bank's challenge page, your payment flow opens a FaceSign session that verifies the cardholder is present, confirms the payment details through an AI conversation, and returns a result you can pass to your payment processor.
PSD3 and Strong Customer Authentication (SCA) -- FaceSign satisfies the two-factor requirement for SCA under PSD2/PSD3 by combining inherence (biometric liveness) with possession (email OTP) or knowledge (conversational verification). Consult your compliance team to confirm FaceSign meets your specific regulatory obligations.
How it works
- Your payment processor flags a transaction as high-risk (large amount, new card, unusual merchant, or risk model trigger).
- Instead of initiating a 3DS challenge, your backend creates a FaceSign session with liveness detection and a conversation node that reads the payment details to the user.
- You embed
clientSecret.urlas aniframesrcinside your checkout page — the verification runs inline on your domain, keeping the user in your store. 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 verifies the cardholder is a live person and confirms they intend to make this payment.
- Your webhook handler receives the result and sends the authorization decision to your payment processor.
Build the flow
Define the verification flow
Create a session with liveness detection and a conversation node that describes the payment details. For SCA compliance, add an email OTP as the second factor.
{
"clientReferenceId": "pay-order-9912",
"metadata": {
"orderId": "order-9912",
"amount": "249.99",
"currency": "EUR",
"merchantName": "TechStore EU",
"cardLast4": "4242",
"riskReason": "new_card_high_amount"
},
"providedData": {
"email": "cardholder@example.com"
},
"flow": [
{
"id": "start",
"type": "start",
"outcome": "greeting"
},
{
"id": "greeting",
"type": "conversation",
"prompt": "Say: Hi! I need to verify a payment on your card before we can complete the purchase. It only takes a few seconds — I'll take a quick look, then read the payment details back to you.",
"outcomes": [
{ "id": "next", "targetNodeId": "liveness", "condition": "" }
]
},
{
"id": "liveness",
"type": "liveness_detection",
"outcomes": {
"livenessDetected": "confirm_payment",
"deepfakeDetected": "closing",
"noFace": "closing"
}
},
{
"id": "confirm_payment",
"type": "conversation",
"prompt": "Say: I need to verify a payment on your card ending in 4242. You're authorizing a charge of 249.99 EUR to TechStore EU. Can you confirm this purchase is yours?",
"outcomes": [
{
"id": "confirmed",
"targetNodeId": "email_otp",
"condition": "The user explicitly confirmed the payment is theirs"
},
{
"id": "denied",
"targetNodeId": "closing",
"condition": "The user denied the payment, did not recognize it, 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
}Create the session from your payment backend
When your payment processor returns a high-risk flag, create a FaceSign session instead of (or alongside) a 3DS challenge.
async function authenticatePayment(payment, cardholder) {
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: `pay-${payment.orderId}`,
metadata: {
orderId: payment.orderId,
amount: payment.amount,
currency: payment.currency,
merchantName: payment.merchantName,
cardLast4: payment.cardLast4,
riskReason: payment.riskReason,
},
providedData: {
email: cardholder.email,
},
flow: [
{ id: 'start', type: 'start', outcome: 'greeting' },
{
id: 'greeting',
type: 'conversation',
prompt:
"Say: Hi! I need to verify a payment on your card before we can complete the purchase. It only takes a few seconds — I'll take a quick look, then read the payment details back to you.",
outcomes: [{ id: 'next', targetNodeId: 'liveness', condition: '' }],
},
{
id: 'liveness',
type: 'liveness_detection',
outcomes: {
livenessDetected: 'confirm_payment',
deepfakeDetected: 'closing',
noFace: 'closing',
},
},
{
id: 'confirm_payment',
type: 'conversation',
prompt: `Say: I need to verify a payment on your card ending in ${payment.cardLast4}. You're authorizing a charge of ${payment.amount} ${payment.currency} to ${payment.merchantName}. Can you confirm this purchase is yours?`,
outcomes: [
{
id: 'confirmed',
targetNodeId: 'email_otp',
condition: 'The user explicitly confirmed the payment is theirs',
},
{
id: 'denied',
targetNodeId: 'closing',
condition: 'The user denied the payment, did not recognize it, 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()
// Hold the payment until verification completes
await db.payments.update(payment.orderId, {
status: 'pending_authentication',
facesignSessionId: session.id,
})
return clientSecret.url
}import os
import requests
def authenticate_payment(payment, cardholder):
res = requests.post(
"https://api.facesign.ai/sessions",
headers={
"Authorization": f"Bearer {os.environ['FACESIGN_API_KEY']}",
"Content-Type": "application/json",
},
json={
"clientReferenceId": f"pay-{payment['order_id']}",
"metadata": {
"orderId": payment["order_id"],
"amount": payment["amount"],
"currency": payment["currency"],
"merchantName": payment["merchant_name"],
"cardLast4": payment["card_last4"],
"riskReason": payment["risk_reason"],
},
"providedData": {
"email": cardholder["email"],
},
"flow": [
{"id": "start", "type": "start", "outcome": "greeting"},
{
"id": "greeting",
"type": "conversation",
"prompt": (
"Say: Hi! I need to verify a payment on your card "
"before we can complete the purchase. It only takes a "
"few seconds — I'll take a quick look, then read the "
"payment details back to you."
),
"outcomes": [
{"id": "next", "targetNodeId": "liveness", "condition": ""}
],
},
{
"id": "liveness",
"type": "liveness_detection",
"outcomes": {
"livenessDetected": "confirm_payment",
"deepfakeDetected": "closing",
"noFace": "closing",
},
},
{
"id": "confirm_payment",
"type": "conversation",
"prompt": (
f"Say: I need to verify a payment on your card ending in "
f"{payment['card_last4']}. You're authorizing a charge of "
f"{payment['amount']} {payment['currency']} to "
f"{payment['merchant_name']}. Can you confirm this purchase "
f"is yours?"
),
"outcomes": [
{
"id": "confirmed",
"targetNodeId": "email_otp",
"condition": "The user explicitly confirmed the payment is theirs",
},
{
"id": "denied",
"targetNodeId": "closing",
"condition": "The user denied the payment, did not recognize it, 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"]
# Hold the payment until verification completes
db.payments.update(
payment["order_id"],
status="pending_authentication",
facesign_session_id=session["id"],
)
return client_secret["url"]Handle the webhook and authorize the payment
When the session completes, send the authorization decision to your payment processor.
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. GET /sessions/:id returns
// `{ session, clientSecret }` — unpack the inner session.
const sessionRes = await fetch(
`https://api.facesign.ai/sessions/${event.sessionId}`,
{
headers: {
Authorization: `Bearer ${process.env.FACESIGN_API_KEY}`,
},
}
)
const { session } = await sessionRes.json()
const payment = await db.payments.findBy({
facesignSessionId: event.sessionId,
})
if (!payment) {
return Response.json({ received: true })
}
// Check all verification steps
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) {
// Authorize the payment
await db.payments.update(payment.orderId, {
status: 'authenticated',
})
await paymentProcessor.authorize(payment.orderId, {
authenticationMethod: 'facesign',
sessionId: session.id,
})
} else {
// Decline the payment
await db.payments.update(payment.orderId, {
status: 'authentication_failed',
failureReason: !livenessPass ? 'liveness_failed' : 'otp_failed',
})
await paymentProcessor.decline(payment.orderId, {
reason: 'step_up_authentication_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. GET /sessions/:id returns
# {"session": ..., "clientSecret": ...} — unpack the inner session.
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"]
payment = db.payments.find_by(
facesign_session_id=event["sessionId"]
)
if not payment:
return jsonify({"received": True}), 200
# Check all verification steps
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:
# Authorize the payment
db.payments.update(
payment["order_id"],
status="authenticated",
)
payment_processor.authorize(
payment["order_id"],
authentication_method="facesign",
session_id=session["id"],
)
else:
# Decline the payment
db.payments.update(
payment["order_id"],
status="authentication_failed",
failure_reason="liveness_failed" if not liveness_pass else "otp_failed",
)
payment_processor.decline(
payment["order_id"],
reason="step_up_authentication_failed",
)
return jsonify({"received": True}), 200SCA compliance mapping
FaceSign satisfies Strong Customer Authentication by combining two of the three SCA factors:
| SCA factor | Category | FaceSign component |
|---|---|---|
| Inherence (something you are) | Biometric | Liveness detection confirms the cardholder is a real, present person |
| Possession (something you have) | Device/token | Email OTP sent to the cardholder's registered email |
| Knowledge (something you know) | Information | Conversation node confirms transaction details the cardholder should know |
A FaceSign session with liveness detection + email OTP satisfies the two-factor requirement. The conversation node provides additional assurance but is not strictly required for SCA compliance.
FaceSign vs. 3D Secure
| Dimension | 3D Secure | FaceSign |
|---|---|---|
| User experience | Redirect to bank's challenge page, often slow | In-page verification with AI avatar, typically under 60 seconds |
| Fraud detection | Static challenge (OTP or password) | Biometric liveness + deepfake detection + coercion analysis |
| Conversion impact | 10-25% drop at challenge step | Lower abandonment due to guided AI interaction |
| Implementation | Per-processor SDK integration | Single API -- works with any payment processor |
| SCA compliance | Yes (possession + knowledge) | Yes (inherence + possession) |
Payment processor integration
The integration point varies by processor:
| Processor | Where to integrate | Notes |
|---|---|---|
| Stripe | Before confirmPayment() | Create FaceSign session when paymentIntent.status is requires_action; call confirmPayment() after successful webhook |
| Adyen | Custom authentication action | Use Adyen's action.type: 'redirect' to send the user to FaceSign's hosted URL |
| Checkout.com | 3DS exemption flow | Request a 3DS exemption and use FaceSign as your alternative authentication |
| Braintree | Before transaction submission | Hold the transaction, run FaceSign, then submit with the authentication result |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Payment times out while waiting for verification | User never completed the FaceSign session | Set a timeout on the payment hold; sessions the user abandons come back as status: "incomplete" (the full set is created / inProgress / incomplete / complete) — treat those as authentication failures |
| User sees both 3DS and FaceSign challenges | Both are triggered in parallel | Disable 3DS for transactions where FaceSign is the authentication method |
| OTP email arrives after the user gives up | Email delivery latency | Use expirySeconds: 300 (5 min) to give adequate time; check your email provider's delivery speed |
Liveness returns deepfakeDetected for legitimate user | Unusual lighting or camera quality | This is rare but can happen with very low-quality cameras; route deepfakeDetected to manual review rather than immediate decline |
| Conversation routes incorrectly | Ambiguous condition text | Write conditions that are mutually exclusive: "confirmed the payment" vs. "denied the payment or did not recognize it" |