FaceSign
Use Cases

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.


How it works

  1. Your payment processor flags a transaction as high-risk (large amount, new card, unusual merchant, or risk model trigger).
  2. 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.
  3. You embed clientSecret.url as an iframe src inside 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).
  4. FaceSign verifies the cardholder is a live person and confirms they intend to make this payment.
  5. 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.

Payment Authentication Flow
{
  "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.

services/payment-auth.js
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
}
services/payment_auth.py
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.

api/webhooks/facesign.js
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 })
}
api/webhooks/facesign.py
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}), 200

SCA compliance mapping

FaceSign satisfies Strong Customer Authentication by combining two of the three SCA factors:

SCA factorCategoryFaceSign component
Inherence (something you are)BiometricLiveness detection confirms the cardholder is a real, present person
Possession (something you have)Device/tokenEmail OTP sent to the cardholder's registered email
Knowledge (something you know)InformationConversation 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

Dimension3D SecureFaceSign
User experienceRedirect to bank's challenge page, often slowIn-page verification with AI avatar, typically under 60 seconds
Fraud detectionStatic challenge (OTP or password)Biometric liveness + deepfake detection + coercion analysis
Conversion impact10-25% drop at challenge stepLower abandonment due to guided AI interaction
ImplementationPer-processor SDK integrationSingle API -- works with any payment processor
SCA complianceYes (possession + knowledge)Yes (inherence + possession)

Payment processor integration

The integration point varies by processor:

ProcessorWhere to integrateNotes
StripeBefore confirmPayment()Create FaceSign session when paymentIntent.status is requires_action; call confirmPayment() after successful webhook
AdyenCustom authentication actionUse Adyen's action.type: 'redirect' to send the user to FaceSign's hosted URL
Checkout.com3DS exemption flowRequest a 3DS exemption and use FaceSign as your alternative authentication
BraintreeBefore transaction submissionHold the transaction, run FaceSign, then submit with the authentication result

Troubleshooting

SymptomCauseFix
Payment times out while waiting for verificationUser never completed the FaceSign sessionSet 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 challengesBoth are triggered in parallelDisable 3DS for transactions where FaceSign is the authentication method
OTP email arrives after the user gives upEmail delivery latencyUse expirySeconds: 300 (5 min) to give adequate time; check your email provider's delivery speed
Liveness returns deepfakeDetected for legitimate userUnusual lighting or camera qualityThis is rare but can happen with very low-quality cameras; route deepfakeDetected to manual review rather than immediate decline
Conversation routes incorrectlyAmbiguous condition textWrite conditions that are mutually exclusive: "confirmed the payment" vs. "denied the payment or did not recognize it"

Next Steps

On this page