> ## Documentation Index
> Fetch the complete documentation index at: https://developers.pcibooking.net/llms.txt
> Use this file to discover all available pages before exploring further.

# Complete Example: Checkout Page with 3D Secure

> A complete checkout page and server: capture the card in the hosted card entry form, authenticate it with 3D Secure, and charge it through the Universal Payment Gateway.

This page puts the whole flow in one place: the cardholder enters a card on your checkout page, completes 3D Secure, and your server charges the card through any supported payment gateway. It has a complete HTML page and a complete server, in Node.js and C#.

To see the same flow running before you read the code, open the [card entry form demo](https://demo.pcibooking.net/demos/pci-shield-iframe.html). See [Live Demos](/start-here/live-demos) for what to try on it.

## How the Flow Works

```mermaid theme={null}
sequenceDiagram
    participant B as Cardholder's browser
    participant S as Your server
    participant P as PCI Booking
    participant G as Payment gateway
    B->>S: Open checkout page
    S->>P: Start a temporary session (API key)
    P-->>S: Session token
    S-->>B: Session token
    B->>P: Load the card entry form (iframe)
    Note over B,P: Cardholder enters the card and completes 3D Secure
    P-->>B: CardSubmitSuccess message with the card token
    B->>S: Card token
    S->>P: Charge the token (UPG, API key)
    P->>G: Charge with the real card and the 3D Secure data
    G-->>P: Result
    P-->>S: Result
    S-->>B: Booking confirmed or declined
```

Three things to note:

* **Your API key stays on your server.** The browser only gets a session token, which is valid for 5 minutes.
* **The 3D Secure result is stored on the token.** You do not pass it to the charge yourself. When the gateway supports 3D Secure data, PCI Booking adds it to the charge automatically. See [3D Secure and the UPG](/use-tokens/universal-payment-gateway#3d-secure-and-the-upg).
* **The charge is an API call from your server.** There is no payment page for the gateway, and the cardholder never leaves your page.

## Before You Start

You need:

* A PCI Booking [sandbox account](https://pcibooking.net/sandbox/) and its API key.
* Your PCI Booking username. The card entry form uses it as the `brand` parameter.
* For your first test, nothing else. The examples charge through `NULLSuccess`, PCI Booking's built-in test gateway, so you do not need a PSP account yet.

Use the [3D Secure test cards](/getting-started/testing-and-going-live#3d-secure-test-cards) with your **sandbox** API key. With a live API key, the same cards cannot complete 3D Secure. See [Troubleshooting](#troubleshooting) below.

## The Checkout Page

Save this as `public/checkout.html`. It loads the card entry form, uses your own **Pay** button to submit it, and sends the card token to your server.

```html checkout.html theme={null}
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Checkout</title>
  <style>
    #card-form { width: 100%; max-width: 480px; height: 360px; border: 0; }
    #message { margin-top: 12px; }
  </style>
</head>
<body>
  <h1>Pay for your booking</h1>
  <p>Total: 450.00 USD</p>

  <iframe id="card-form" title="Card details"></iframe>
  <button id="pay" disabled>Pay</button>
  <div id="message"></div>

  <script>
    const PCI_BOOKING = 'https://service.pcibooking.net';
    const BRAND = 'YOUR_PCI_BOOKING_USERNAME';

    const iframe = document.getElementById('card-form');
    const payButton = document.getElementById('pay');
    const message = document.getElementById('message');

    async function loadCardForm() {
      // 1. Get a session token from your own server. Never put your API key in the page.
      const response = await fetch('/api/card-session', { method: 'POST' });
      const { sessionToken } = await response.json();

      // 2. Build the card entry form URL.
      const params = new URLSearchParams({
        sessionToken: sessionToken,
        brand: BRAND,
        language: 'en',
        postMessageHost: window.location.origin,
        submitWithPostMessage: 'true',  // hide PCI Booking's button, use your own
        ThreeDS: 'true',                // authenticate the card with 3D Secure
        UnavailThreeDSAuth: 'Reject',   // no 3D Secure, no token
        amount: '45000',                // in minor units, shown in the 3D Secure screen
        currencyCode: 'USD',
        cvv: 'true',
        autoDetectCardType: 'true',
        failure: window.location.origin + '/card-failed.html'
      });

      // No success URL: the result arrives as a postMessage, so the form stays in place.
      iframe.src = PCI_BOOKING + '/api/payments/capturecard?' + params.toString();
    }

    // 3. Listen to the card entry form.
    window.addEventListener('message', async (event) => {
      if (event.origin !== PCI_BOOKING) return;
      const data = event.data;

      if (data === 'ready') {
        payButton.disabled = false;
        return;
      }

      if (typeof data === 'string' && data.startsWith('frameDimensionsChanged')) {
        const height = Math.ceil(parseFloat(data.split(':')[2]));
        if (height > 0) iframe.style.height = (height + 20) + 'px';
        return;
      }

      if (data && data.messageType === 'CardSubmitSuccess') {
        message.textContent = 'Processing your payment...';
        await charge(data.cardToken);
        return;
      }

      if (data === 'ThreeDsChallengeLoaded' || (data && data.messageType === 'OTPdisplayed')) {
        iframe.style.height = '600px'; // room for the bank's 3D Secure screen
        return;
      }

      if (data && data.messageType === 'CardSubmitFailure') {
        message.textContent = 'We could not accept this card: ' + data.reason;
        loadCardForm(); // the form now shows your failure page, so load a fresh one
      }
    });

    // 4. Your Pay button submits the card entry form. 3D Secure starts from here.
    payButton.addEventListener('click', () => {
      payButton.disabled = true;
      message.textContent = '';
      iframe.contentWindow.postMessage('submit', PCI_BOOKING);
    });

    // 5. Send the token to your server, which charges it.
    async function charge(cardToken) {
      const response = await fetch('/api/charge', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ cardToken: cardToken })
      });
      const result = await response.json();
      if (result.status === 'Success') {
        message.textContent = 'Payment approved. Your booking is confirmed.';
      } else if (result.status === 'Accepted') {
        message.textContent = 'Payment received. We will confirm your booking shortly.';
      } else {
        message.textContent = 'Payment declined: ' + result.reason;
        loadCardForm(); // let the guest try another card
      }
    }

    loadCardForm();
  </script>
</body>
</html>
```

Also create `public/card-failed.html`, a short page that says the card was not accepted. When the card is rejected, the card entry form loads this page inside the iframe, and your page also receives a `CardSubmitFailure` message. The example then loads a fresh form so the guest can try another card.

How the page works:

* `submitWithPostMessage=true` removes the form's own submit button. Your page submits the form by sending it the `submit` message.
* `postMessageHost` must be the exact origin of your page. Without it, the form sends no messages at all.
* Check `event.origin` before you act on a message, as the listener above does, and send the form only the documented `submit` and `validate` messages.
* The `CardSubmitSuccess` message carries the card token, the masked card number, the card type and the expiry date. It does not carry the 3D Secure result. The result is stored on the token.

The full list of form parameters is on [Request Card Entry Form](/api-reference/tokenize-cards/request-card-entry-form), and the full list of messages is on [postMessage Notifications](/capture-cards/postmessage-notifications).

## The Server

The server does two things: it starts a temporary session for the card entry form, and it charges the token. Your API key is read from an environment variable.

<CodeGroup>
  ```javascript server.js (Node.js 18+) theme={null}
  const express = require('express');

  const app = express();
  app.use(express.json());
  app.use(express.static('public'));

  const PCI_BOOKING = 'https://service.pcibooking.net/api';
  const API_KEY = process.env.PCI_BOOKING_API_KEY;
  const TOKEN_PREFIX = 'https://service.pcibooking.net/api/payments/paycard/';

  // Start a temporary session for the card entry form.
  app.post('/api/card-session', async (req, res) => {
    const response = await fetch(PCI_BOOKING + '/payments/paycard/tempsession', {
      method: 'POST',
      headers: { 'Authorization': 'APIKEY ' + API_KEY }
    });
    if (!response.ok) {
      return res.status(502).json({ error: 'Could not start a card session' });
    }
    const sessionToken = await response.json(); // the response is a JSON string
    res.json({ sessionToken });
  });

  // Charge the card token through the Universal Payment Gateway.
  app.post('/api/charge', async (req, res) => {
    const { cardToken } = req.body;
    if (typeof cardToken !== 'string' || !cardToken.startsWith(TOKEN_PREFIX)) {
      return res.status(400).json({ status: 'Rejected', reason: 'Invalid card token' });
    }

    // Take the amount from your own booking record, never from the browser.
    const booking = { reference: 'BK-10045', amount: 450.00, currency: 'USD' };

    const response = await fetch(PCI_BOOKING + '/paymentGateway', {
      method: 'POST',
      headers: {
        'Authorization': 'APIKEY ' + API_KEY,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        cardToken: cardToken,
        OperationType: 'Charge',
        Amount: booking.amount,
        Currency: booking.currency,
        myRef: booking.reference,
        PaymentGateway: { Name: 'NULLSuccess' },
        PayerDetails: { ClientIPAddress: req.ip }
      })
    });

    const result = await response.json();
    const status = result.OperationResultCode; // Success, Accepted, Rejected, TemporaryFailure or FatalFailure

    // Save result.GatewayReference with the booking: you need it for refunds and voids.
    res.json({
      status: status,
      reason: status === 'Success' ? null : (result.GatewayResultDescription || result.OperationResultDescription)
    });
  });

  app.listen(3000, () => console.log('Checkout running on http://localhost:3000/checkout.html'));
  ```

  ```csharp Program.cs (.NET 8) theme={null}
  using System.Net.Http.Json;
  using System.Text.Json;

  var builder = WebApplication.CreateBuilder(args);
  builder.Services.AddHttpClient("pcib", c =>
  {
      c.BaseAddress = new Uri("https://service.pcibooking.net/api/");
      c.DefaultRequestHeaders.Add("Authorization",
          "APIKEY " + builder.Configuration["PCI_BOOKING_API_KEY"]);
  });

  var app = builder.Build();
  app.UseDefaultFiles();
  app.UseStaticFiles(); // serves wwwroot/checkout.html and wwwroot/card-failed.html

  const string TokenPrefix = "https://service.pcibooking.net/api/payments/paycard/";

  // Start a temporary session for the card entry form.
  app.MapPost("/api/card-session", async (IHttpClientFactory factory) =>
  {
      var client = factory.CreateClient("pcib");
      var response = await client.PostAsync("payments/paycard/tempsession", null);
      if (!response.IsSuccessStatusCode)
          return Results.StatusCode(502);

      // The response is a JSON string, for example "33b245a015cf4d10a3ca885e7f4c5600".
      var sessionToken = await response.Content.ReadFromJsonAsync<string>();
      return Results.Ok(new { sessionToken });
  });

  // Charge the card token through the Universal Payment Gateway.
  app.MapPost("/api/charge", async (ChargeRequest request, HttpContext http, IHttpClientFactory factory) =>
  {
      if (string.IsNullOrEmpty(request.CardToken) || !request.CardToken.StartsWith(TokenPrefix))
          return Results.BadRequest(new { status = "Rejected", reason = "Invalid card token" });

      // Take the amount from your own booking record, never from the browser.
      var booking = new { Reference = "BK-10045", Amount = 450.00m, Currency = "USD" };

      var client = factory.CreateClient("pcib");
      var response = await client.PostAsJsonAsync("paymentGateway", new
      {
          cardToken = request.CardToken,
          OperationType = "Charge",
          Amount = booking.Amount,
          Currency = booking.Currency,
          myRef = booking.Reference,
          PaymentGateway = new { Name = "NULLSuccess" },
          PayerDetails = new { ClientIPAddress = http.Connection.RemoteIpAddress?.ToString() }
      });

      var result = await response.Content.ReadFromJsonAsync<JsonElement>();
      // Success, Accepted, Rejected, TemporaryFailure or FatalFailure
      var status = result.TryGetProperty("OperationResultCode", out var c) ? c.GetString() : null;

      // Save GatewayReference with the booking: you need it for refunds and voids.
      string? reason = null;
      if (status != "Success")
      {
          reason = result.TryGetProperty("GatewayResultDescription", out var g) && g.ValueKind == JsonValueKind.String
              ? g.GetString()
              : result.TryGetProperty("OperationResultDescription", out var d) ? d.GetString() : "Declined";
      }
      return Results.Ok(new { status, reason });
  });

  app.Run();

  record ChargeRequest(string CardToken);
  ```
</CodeGroup>

To run the Node.js version, put `checkout.html` and `card-failed.html` in a `public` folder, install Express with `npm install express`, set `PCI_BOOKING_API_KEY` to your sandbox API key, and run `node server.js`. Because `postMessageHost` must match your page's origin, open the page through the server, not as a local file.

## Moving From the Test Gateway to Your PSP

When the flow works with `NULLSuccess`, switch to your own PSP:

1. Store your PSP credentials in PCI Booking, see [Gateway Credentials](/account-setup/gateway-credentials).
2. In the charge request, remove the `PaymentGateway` object and add `?credentialsId=YOUR_CREDENTIALS_ID` to the URL.
3. Read the [gateway-specific guidance](/use-tokens/gateway-guidance) for your PSP. Some PSPs need extra fields, such as more `PayerDetails`.

Use your PSP's test credentials with your PCI Booking sandbox account, and your PSP's live credentials with your PCI Booking live account. If they do not match, the PSP rejects the request.

## Making Sure the Card Has 3D Secure

This example sets `UnavailThreeDSAuth=Reject`. If the card cannot complete 3D Secure, for example because the card is not enrolled, it is not tokenized: the cardholder sees your failure page and your page gets `CardSubmitFailure`. With the default, `Accept`, the card is stored **without** 3D Secure data, and a PSP that requires 3D Secure will then decline the charge.

A card that fails the 3D Secure challenge is always rejected, whatever the value of `UnavailThreeDSAuth`.

## Troubleshooting

| What you see | Most likely cause |
| - | - |
| `CardSubmitFailure` with the reason `CardNotEnrolled`, or another 3D Secure failure, when you use a 3D Secure test card | You are using a live API key. The 3D Secure test cards work only with a sandbox account. Use your sandbox API key, or a real card with your live key. |
| Your page receives no messages from the form | `postMessageHost` is missing or does not match your page's origin exactly, or the page is opened as a local file. |
| The form does not load, or 3D Secure fails straight away | The session token has expired. It is valid for 5 minutes, so get a new one each time you load the form. |
| The charge is declined by your PSP | Read `GatewayResultDescription` in the response, check the [gateway-specific guidance](/use-tokens/gateway-guidance), and check that the PSP credentials match the account type (test or live). |

## Related

* [Live Demos](/start-here/live-demos). The same flow running on a demo checkout page.
* [Hosted Card Entry Form](/capture-cards/hosted-card-entry-form). All the ways to use the form.
* [PSD2 and 3D Secure](/use-cases/comply-with-psd2). How PCI Booking handles 3D Secure.
* [Universal Payment Gateway](/use-tokens/universal-payment-gateway). Charging, authorizations, refunds and fallback gateways.
* [Testing and Going Live](/getting-started/testing-and-going-live). Test cards and the go-live checklist.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.