Skip to content
API DocsDocs

End-to-End Tutorial: Your First Payment

Build a complete card payment flow from API keys to a confirmed transaction in 15 minutes

3 min readUpdated Mar 26, 2026

This tutorial walks through a complete card payment integration — from authentication to a confirmed payment with webhook handling. By the end you'll have working code covering the full lifecycle.

Time: ~15 minutes Environment: Sandbox Prerequisites: Sandbox credentials from your Exirom account manager


#What You'll Build

End-to-End Payment Flow


#Step 1 — Authenticate

Call POST /api/v1/auth with your merchantKey and merchantSecret. You get back a JWT token valid for 30 days — cache it and reuse it across all requests as Authorization: Bearer {token}.

For token caching and 401 retry patterns, see Authentication Flow.


#Step 2 — Initiate a Card Payment

Call POST /api/v1/payments/card with your amount, card details, billing info, and callback URLs. Use sandbox test card 4111111111111111 for a simple success (no 3DS). See the API reference for the full field list.

The response tells you what happens next:

// Successful (no 3DS)
{
  "transactionId": "733609401625775730",
  "transactionStatus": "SUCCEED",
  "declineCode": null
}
// 3DS challenge required
{
  "transactionId": "733609401625775731",
  "transactionStatus": "CUSTOMER_VERIFICATION",
  "challengeUrl": "https://3ds.provider.com/challenge/xyz",
  "challengeUrlIframe": null
}

requestId must be unique per transaction. It's your idempotency key — retrying with the same requestId returns the original result rather than creating a duplicate. See Idempotency.


#Step 3 — Handle 3DS (if required)

If transactionStatus is CUSTOMER_VERIFICATION, redirect the customer:

const result = await initiatePayment(payload);
 
if (result.transactionStatus === 'CUSTOMER_VERIFICATION') {
  // Redirect to 3DS challenge page
  res.redirect(result.challengeUrl);
} else if (result.transactionStatus === 'SUCCEED') {
  // Synchronous success — fulfill the order
  await fulfillOrder(result.transactionId);
} else {
  // FAILED — show error to customer
  res.redirect('/payment/failed?code=' + result.declineCode);
}

After the customer completes the 3DS challenge, they land on your successRedirectUrl or failureRedirectUrl. The authoritative result arrives via webhook.


#Step 4 — Receive the Webhook

Exirom POSTs the final transaction status to your callbackUrl. Respond with HTTP 200 immediately, then process asynchronously.

app.post('/webhooks/payment', express.json(), async (req, res) => {
  // 1. Always respond 200 immediately
  res.status(200).send('OK');
 
  const { transactionId, requestId, transactionStatus, declineCode } = req.body;
 
  // 2. Deduplicate
  const key = `${transactionId}:${transactionStatus}`;
  if (await db.webhookLog.exists(key)) return;
  await db.webhookLog.create(key);
 
  // 3. Handle result
  if (transactionStatus === 'SUCCEED') {
    await db.orders.update({ requestId, status: 'paid', transactionId });
    await notifyCustomer(requestId);
  } else if (transactionStatus === 'FAILED') {
    await db.orders.update({ requestId, status: 'failed', declineCode });
  }
});

Webhook URL must be public HTTPS. During development use ngrok or webhook.site to inspect callbacks.


#Step 5 — Verify Status (optional fallback)

If the webhook doesn't arrive within a reasonable window, call GET /v1/payments/card/status/{id} to poll for the current status.


#What's Next

  • Test 3DS: Use card 4000000000003220 to trigger a 3DS challenge
  • Test decline: Use any card number not listed in Sandbox Test Data
  • Add idempotency: Ensure your requestId is unique and persisted before the API call
  • Harden webhooks: Add HMAC verification, deduplication, and a polling fallback
  • Go live: Follow the Production Guide checklist

#See Also

Was this helpful?