Agentic Pay Integration
This guide walks you through integrating Agentic Pay — Firmly’s Network Token Router for agentic commerce. Firmly handles the network-specific mechanics behind one abstract API.
Overview
There are two entry points — both converge on the same intent → order chain:
New card: Enroll
Send the cardholder’s PAN to Firmly. Firmly detects the network, enrolls the card, and returns verification challenges. Persist virtual_card_id, card_art, and masked for returning buyers.
Saved card: Select Card
For returning buyers, call /select-card with the stored virtual_card_id — no PAN re-entry. Returns the same shape as /enroll, so the rest of the flow is identical.
Create a payment intent
Create an intent with the transaction amount. A FIDO assertion proves cardholder presence.
Place the order
Send the flow_token and intent_id to the wallet-complete-order endpoint. Firmly retrieves network token credentials and completes the payment.
Step 1: Enroll a Card (New Card Only)
Send the cardholder’s PAN to Firmly. Firmly inspects the BIN and routes to the correct network automatically. For returning buyers with a stored card, skip to Select Card instead.
const enrollResponse = await fetch('https://api.firmly.work/api/v1/wallets/agentic-pay/enroll',{method: 'POST',headers: {'x-firmly-authorization': apiToken,'Content-Type': 'application/json'},body: JSON.stringify({pan: '4111111111111111',expiry_month: '12',expiry_year: '2030',cvv: '123',consumer: {email: 'cardholder@example.com',country_code: 'US'}})});const { flow_token, virtual_card_id, verification_methods } = await enrollResponse.json();
import requestsresponse = requests.post('https://api.firmly.work/api/v1/wallets/agentic-pay/enroll',headers={'x-firmly-authorization': api_token,'Content-Type': 'application/json'},json={'pan': '4111111111111111','expiry_month': '12','expiry_year': '2030','cvv': '123','consumer': {'email': 'cardholder@example.com','country_code': 'US'}})data = response.json()flow_token = data['flow_token']verification_methods = data['verification_methods']
curl -X POST https://api.firmly.work/api/v1/wallets/agentic-pay/enroll \-H "x-firmly-authorization: YOUR_API_TOKEN" \-H "Content-Type: application/json" \-d '{"pan": "4111111111111111","expiry_month": "12","expiry_year": "2030","cvv": "123","consumer": {"email": "cardholder@example.com","country_code": "US"}}'
The response includes verification_methods, card_art, and masked (last4, expiry, brand).
Check verification_methods for embed_iframe_handshake or embed_iframe to determine the next step.
Step 2: Iframe Handshake (If Required)
If the response includes a verification method of type embed_iframe_handshake, load the iframe URI in the cardholder’s browser and capture the secure_token via postMessage. Skip this step if no handshake method is returned.
const iframe = document.createElement('iframe');iframe.src = verification_methods[0].attributes.uri;document.body.appendChild(iframe);window.addEventListener('message', (event) => {// Always verify the sender's originif (event.origin !== new URL(iframe.src).origin) return;const data = event.data;if (data.type === 'AUTH_READY') {iframe.contentWindow.postMessage({requestID: data.requestID,type: 'CREATE_AUTH_SESSION',version: '1',client: { id: '__from-server__' },contentType: 'application/json'}, new URL(iframe.src).origin);}if (data.sessionContext?.secureToken) {const secureToken = data.sessionContext.secureToken;sendToServer({ secure_token: secureToken });}});
Step 3: Trigger Enrollment Verification
Call the trigger endpoint with the selected verification method. Include secure_token if captured from an iframe handshake, or callback_uri for iframe-based challenges without a handshake.
const triggerResponse = await fetch('https://api.firmly.work/api/v1/wallets/agentic-pay/enroll/trigger',{method: 'POST',headers: {'x-firmly-authorization': apiToken,'Content-Type': 'application/json'},body: JSON.stringify({flow_token: flowToken,method_id: 'OTP_SMS',secure_token: secureToken // omit if not applicable})});const { flow_token: updatedToken, challenge } = await triggerResponse.json();// challenge.type === 'otp' means an OTP was sent to the cardholder
curl -X POST https://api.firmly.work/api/v1/wallets/agentic-pay/enroll/trigger \-H "x-firmly-authorization: YOUR_API_TOKEN" \-H "Content-Type: application/json" \-d '{"flow_token": "eyJhbGci...","method_id": "OTP_SMS","secure_token": "ezAwMX06..."}'
Step 4: Verify Enrollment
Submit the OTP or iframe result. This step may need to be called more than once — for example, an OTP verification may be followed by a FIDO registration step.
const verifyResponse = await fetch('https://api.firmly.work/api/v1/wallets/agentic-pay/enroll/verify',{method: 'POST',headers: {'x-firmly-authorization': apiToken,'Content-Type': 'application/json'},body: JSON.stringify({flow_token: updatedToken,type: 'otp',value: '456789'})});const verifyResult = await verifyResponse.json();// If verifyResult.verification_methods exists, another verification step is needed// If verifyResult.virtual_card_id exists, enrollment is complete
curl -X POST https://api.firmly.work/api/v1/wallets/agentic-pay/enroll/verify \-H "x-firmly-authorization: YOUR_API_TOKEN" \-H "Content-Type: application/json" \-d '{"flow_token": "eyJhbGci...","type": "otp","value": "456789"}'
Step 5: Complete FIDO Registration
Load the FIDO iframe from the verification method’s uri, complete the ceremony, and submit the result.
const fidoVerifyResponse = await fetch('https://api.firmly.work/api/v1/wallets/agentic-pay/enroll/verify',{method: 'POST',headers: {'x-firmly-authorization': apiToken,'Content-Type': 'application/json'},body: JSON.stringify({flow_token: latestFlowToken,type: 'embed_iframe',iframe_result: {fidoBlob: capturedFidoBlob,assuranceData: capturedAssuranceData}})});const { flow_token: enrolledToken, virtual_card_id } = await fidoVerifyResponse.json();// virtual_card_id confirms the card is enrolled
Step 6: Create Payment Intent
With the card enrolled (via Step 1 or Select Card), create a payment intent when the cardholder is ready to pay.
const intentResponse = await fetch('https://api.firmly.work/api/v1/wallets/agentic-pay/intent',{method: 'POST',headers: {'x-firmly-authorization': apiToken,'Content-Type': 'application/json'},body: JSON.stringify({flow_token: enrolledToken,mandate: {amount: '585.49',currency_code: 'USD',merchant_name: 'My Store'}})});const { flow_token: intentToken, challenge, intent_id } = await intentResponse.json();// challenge.type === 'embed_iframe' — load the FIDO assertion iframe (Step 7)// intent_id present instead of challenge — no challenge needed, go straight to Step 8
curl -X POST https://api.firmly.work/api/v1/wallets/agentic-pay/intent \-H "x-firmly-authorization: YOUR_API_TOKEN" \-H "Content-Type: application/json" \-d '{"flow_token": "eyJhbGci...","mandate": {"amount": "585.49","currency_code": "USD","merchant_name": "My Store"}}'
Step 7: Complete Intent Challenge
Only when Step 6 returned a challenge. Discover — and Mastercard when the cardholder authenticated at enrollment moments earlier — return intent_id directly from /intent; in that case skip to Step 8.
Load the FIDO assertion iframe, capture the result, and submit it.
const challengeResponse = await fetch('https://api.firmly.work/api/v1/wallets/agentic-pay/intent/challenge',{method: 'POST',headers: {'x-firmly-authorization': apiToken,'Content-Type': 'application/json'},body: JSON.stringify({flow_token: intentToken,type: 'embed_iframe',iframe_result: {fidoBlob: capturedFidoBlob,assuranceData: capturedAssuranceData}})});const { flow_token: finalToken, intent_id } = await challengeResponse.json();
Step 8: Place the Order
With the flow_token and intent_id, call the wallet-complete-order endpoint using the cardholder’s device JWT.
const orderResponse = await fetch('https://cc.firmly.work/api/v2/payment/domains/staging.luma.gift/wallet-complete-order',{method: 'POST',headers: {'x-firmly-authorization': deviceJwt,'Content-Type': 'application/json'},body: JSON.stringify({wallet: 'agentic-pay',additional_data: {flow_token: finalToken,intent_id: intentId,transaction: {amount: '585.49',currency_code: 'USD',merchant_name: 'My Store'}},billing_info: {first_name: 'John',last_name: 'Smith',address1: '123 Main St',city: 'San Francisco',state_or_province: 'CA',country: 'US',postal_code: '94105',email: 'john@example.com',phone: '4155551212'}})});const order = await orderResponse.json();
curl -X POST https://cc.firmly.work/api/v2/payment/domains/staging.luma.gift/wallet-complete-order \-H "x-firmly-authorization: DEVICE_JWT" \-H "Content-Type: application/json" \-d '{"wallet": "agentic-pay","additional_data": {"flow_token": "eyJhbGci...","intent_id": "1-5C90F150...","transaction": {"amount": "585.49","currency_code": "USD"}},"billing_info": {"first_name": "John","last_name": "Smith","address1": "123 Main St","city": "San Francisco","state_or_province": "CA","country": "US","postal_code": "94105","email": "john@example.com","phone": "4155551212"}}'
Saved Card: Select Card (Alternative to Step 1)
For returning buyers with a stored virtual_card_id, use /select-card instead of /enroll. No PAN re-entry needed. It returns the same shape as /enroll, so the rest of the flow (handshake → intent → order) is identical.
const { flow_token, verification_methods } = await fetch('https://api.firmly.work/api/v1/wallets/agentic-pay/select-card',{method: 'POST',headers: { 'x-firmly-authorization': apiToken, 'Content-Type': 'application/json' },body: JSON.stringify({network: savedCard.brand,virtual_card_id: savedCard.virtual_card_id,consumer: { email: savedCard.email }})}).then(r => r.json());if (verification_methods.length > 0) {// Iframe handshake needed (Visa) — continue from Step 2} else {// No handshake (Mastercard) — skip to Step 6 (Create Payment Intent)}
curl -X POST https://api.firmly.work/api/v1/wallets/agentic-pay/select-card \-H "x-firmly-authorization: YOUR_API_TOKEN" \-H "Content-Type: application/json" \-d '{"network": "visa","virtual_card_id": "cf90be5c86363de702ed19beec46e102","consumer": { "email": "jane@example.com" }}'
Key differences from /enroll:
consumer.emailis required for Visa (device binding); optional for Mastercardcard_artandmaskedarenull— use the values persisted from the original/enroll- Visa returns
verification_methodswith an iframe handshake; Mastercard and Discover return an empty array (skip to Step 6) - If the card is revoked or unknown, the API returns
PaymentMethodNotAvailable— fall back to/enroll
Error Handling
PaymentMethodNotAvailable (409)
BadRequest (400) — secure_token required
verification_methods, capture the secure_token via postMessage, and include it in the trigger request.BadRequest (400) — Invalid flow_token format
flow_token is malformed or expired. Flow tokens must be used promptly and in sequence — each API call returns a fresh token for the next step.BadRequest (400)
BadRequest (400), not InvalidOtp — do not branch on InvalidOtp here. Prompt the cardholder to re-enter or request a new OTP via /enroll/trigger.CheckoutError (412)
Related
- Agentic Pay Concepts — Architecture and flow overview
- Headless SDK — Browser SDK that wraps this entire flow
- Enroll API Reference — Full endpoint documentation
- Wallet Complete Order — Order placement endpoint