3DS Payer Authentication
Depuis 2026-09, le profile Cybersource Moko exige 3D Secure sur toute transaction carte. Sans authentification 3DS, le charge est automatiquement rejeté par Decision Manager (règle Non 3Ds Reject, reason_code: 481).
Hosted Checkout
Si vous utilisez Hosted Checkout, 3DS est automatique (UC SDK gère tout). Ce guide concerne uniquement Microform où vous devez orchestrer le 3DS explicitement.
Le flow en 5 étapes
1. Tokenize card via Microform SDK → transient_token
2. POST /api/v1/microform/pa-setup → reference_id + DDC url + access_token
3. Browser : iframe caché POST DDC url (~5s fingerprint device)
4. POST /api/v1/microform/pa-enroll → AUTHENTICATED ou CHALLENGE_REQUIRED
5a. AUTHENTICATED (frictionless) → aller à étape 6
5b. CHALLENGE_REQUIRED → iframe visible stepUpUrl (OTP customer)
6. POST /api/v1/microform/charge avec authentication_transaction_idLe backend Moko charge appelle automatiquement /risk/v1/authentication-results en interne pour committer le challenge avant l'auth. Vous n'avez pas besoin de valider vous-même.
Endpoint — POST /api/v1/microform/pa-setup
Auth : HMAC merchant
Prend le transient token, initie la collecte device fingerprint.
Body
{
"transient_token": "eyJraWQiOi...",
"transaction_uuid": "6f719977-efc6-458c-9dea-fefde7133e9c"
}Réponse
{
"reference_id": "12f3d71d-8916-44ce-baa4-a772b9345807",
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"device_data_collection_url": "https://centinelapi.cardinalcommerce.com/V1/Cruise/Collect"
}Device Data Collection (côté browser)
Iframe caché qui poste le JWT vers l'URL DDC. Cybersource collecte le fingerprint browser (~2-5 secondes).
function runDDC(url, accessToken, timeoutMs = 8000) {
return new Promise((resolve) => {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.name = 'cs-ddc-frame';
document.body.appendChild(iframe);
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
form.target = 'cs-ddc-frame';
form.innerHTML = `<input type="hidden" name="JWT" value="${accessToken}">`;
document.body.appendChild(form);
form.submit();
// Timeout de sécurité (CS a un fallback si le DDC échoue)
const t = setTimeout(() => { cleanup(); resolve(); }, timeoutMs);
// Attendre message postMessage de CS
function onMsg(ev) {
if (!ev.origin.includes('cardinalcommerce.com')) return;
try {
const data = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;
if (data.MessageType === 'profile.completed') {
clearTimeout(t);
cleanup();
resolve();
}
} catch {}
}
function cleanup() {
window.removeEventListener('message', onMsg);
iframe.remove();
form.remove();
}
window.addEventListener('message', onMsg);
});
}
// Usage
await runDDC(setup.device_data_collection_url, setup.access_token);Endpoint — POST /api/v1/microform/pa-enroll
Auth : HMAC merchant
Vérifie si un challenge est nécessaire, retourne soit AUTHENTICATED (skip challenge), soit CHALLENGE_REQUIRED avec URL de la page OTP issuer.
Body
{
"transient_token": "eyJraWQiOi...",
"transaction_uuid": "6f719977-efc6-458c-9dea-fefde7133e9c",
"reference_id": "12f3d71d-8916-44ce-baa4-a772b9345807",
"amount": 1.00,
"currency": "USD",
"bill_to": {
"first_name": "Jean", "last_name": "Kabala",
"email": "client@example.com",
"address_line1": "Av. Kasa-Vubu", "address_city": "Kinshasa",
"address_postal_code": "00000", "address_country": "CD"
},
"return_url": "https://checkout.your-shop.com/3ds-return"
}return_url = URL de votre propre page qui recevra le callback POST après challenge. Voir plus bas.
Réponse (frictionless — sans OTP)
{
"status": "AUTHENTICATED",
"authentication_transaction_id": "aeyEthCtnCswTcaZfju1",
"step_up_url": null,
"access_token": null,
"ecommerce_indicator": "vbv"
}→ Passez directement à /charge avec authentication_transaction_id.
Réponse (challenge — OTP requis)
{
"status": "CHALLENGE_REQUIRED",
"authentication_transaction_id": "aeyEthCtnCswTcaZfju1",
"step_up_url": "https://centinelapi.cardinalcommerce.com/V2/Cruise/StepUp",
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"ecommerce_indicator": null
}→ Rendre l'iframe visible avec le step_up_url et access_token, attendre callback sur return_url.
Réponse (échec authentification)
{
"status": "FAILED",
"reason_code": "476",
"message": "Authentication failed"
}→ Ne PAS appeler /charge. Afficher erreur user.
Challenge iframe (côté browser)
function runStepUpChallenge(stepUpUrl, accessToken) {
return new Promise((resolve, reject) => {
// Modal Nexera-branded avec iframe visible
const modal = document.createElement('div');
modal.className = 'your-3ds-modal';
modal.innerHTML = `
<div class="your-3ds-header">Validation bancaire</div>
<iframe name="cs-stepup-frame" style="width:100%;height:500px;border:0;"></iframe>
`;
document.body.appendChild(modal);
const form = document.createElement('form');
form.method = 'POST';
form.action = stepUpUrl;
form.target = 'cs-stepup-frame';
form.innerHTML = `<input type="hidden" name="JWT" value="${accessToken}">`;
document.body.appendChild(form);
form.submit();
// Attendre message postMessage depuis votre page /3ds-return
function onMsg(ev) {
if (ev.origin !== window.location.origin) return;
if (ev.data?.type === '3ds-complete') {
cleanup();
resolve();
}
}
function cleanup() {
window.removeEventListener('message', onMsg);
modal.remove();
form.remove();
}
window.addEventListener('message', onMsg);
// Timeout 5min
setTimeout(() => { cleanup(); reject(new Error('3DS timeout')); }, 5 * 60_000);
});
}Votre page /3ds-return
Cybersource POSTe sur return_url après le challenge (que ce soit un succès ou échec). Cette page doit :
- Accepter GET et POST (Cybersource POSTe)
- Avoir les headers
X-Frame-Options: SAMEORIGIN+CSP: frame-ancestors 'self' - Notifier le parent frame via postMessage
Exemple FastAPI :
from fastapi import APIRouter, Response
from fastapi.responses import HTMLResponse
router = APIRouter()
@router.api_route("/checkout/{tx_id}/3ds-return", methods=["GET", "POST"])
async def three_ds_return(tx_id: str):
html = """
<!DOCTYPE html>
<html>
<head><title>Validation terminée</title></head>
<body>
<p>Validation en cours, veuillez patienter…</p>
<script>
if (window.parent && window.parent !== window) {
window.parent.postMessage({type: '3ds-complete'}, window.location.origin);
}
</script>
</body>
</html>
"""
return HTMLResponse(
content=html,
headers={
"X-Frame-Options": "SAMEORIGIN",
"Content-Security-Policy": "frame-ancestors 'self'",
},
)Charge avec authentication_transaction_id
Une fois AUTHENTICATED OU après completion du challenge, appelez /charge avec le authentication_transaction_id. Le backend Moko :
- Appelle
/risk/v1/authentication-results(commit le 3DS chez CS) - Extrait
cavv,xid,ecide la réponse - Injecte tout dans le
POST /pts/v2/payments commerceIndicatordéfini automatiquement selon network (vbvVisa,spaMasterCard)
Voir Microform.
Debug
Si la charge échoue avec reason_code: 481 (Non 3Ds Reject), vérifiez dans nos logs :
[PA validate] cs_status=AUTHENTICATION_FAILED— le challenge n'a jamais été complété[PA validate] cs_status=AUTHENTICATION_SUCCESSFUL eci=05— 3DS OK, autre cause de reject
Cf. Response codes pour l'interprétation complète.
