VisionScope Checkout API

A server-to-server API that lets merchants accept card payments by redirecting shoppers to a hosted VisionScope checkout page. Results are delivered asynchronously via signed webhooks.

1. Overview

Three things the merchant must build:

  1. A backend call to POST /api/checkout/create
  2. A redirect from the merchant site to the returned checkout_url
  3. A webhook endpoint that verifies signatures and updates the merchant database
VisionScope checkout integration flow — six steps between merchant backend, VisionScope API, user's browser, and pay.visionscopeai.com

2. Base URL & Authentication

EnvironmentBase URL
Developmenthttps://api-dev.visionscopeai.com
Productioncontact VisionScope

Every request must include your API key:

X-API-Key: vsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Keep this key server-side only. Never expose it in browser code or commit it to a public repository.

3. Create a Checkout Session

POST/api/checkout/create

Request headers

Content-Type: application/json
X-API-Key: vsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Request body

FieldTypeRequiredDescription
amountnumberyesMajor units (e.g. 10.0 = €10.00)
currencystringyes3-letter ISO code (EUR, USD, GBP…)
webhook_urlstringyesPublic HTTPS URL that will receive payment events
webhook_secretstringyesShared secret to sign webhooks. Use ≥ 32 random bytes
return_urlstringyesURL the user is redirected to when checkout completes

Example

curl -X POST https://api-dev.visionscopeai.com/api/checkout/create \
  -H "Content-Type: application/json" \
  -H "X-API-Key: vsk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -d '{
    "amount": 10.00,
    "currency": "EUR",
    "webhook_url": "https://yourmerchant.com/webhook",
    "webhook_secret": "a1b2c3d4e5f6...",
    "return_url": "https://yourmerchant.com/return"
  }'

Successful response (HTTP 200)

{
  "success": true,
  "data": {
    "session_id": "cs_mo8ly3a8_67d5e72898f11888",
    "checkout_url": "https://pay.visionscopeai.com/checkout/cs_mo8ly3a8_67d5e72898f11888"
  }
}

Errors

HTTPBodyMeaning
400{ "error": "amount and currency are required" }Missing required fields
401{ "error": "Invalid API key" }X-API-Key missing or invalid
502{ "error": "Upstream checkout API unreachable" }Transient — retry with backoff

4. Show the Checkout to the User

Once you have checkout_url, there are three ways to present the VisionScope-hosted checkout form (SSL, PCI-compliant) to your shopper. Pick whichever fits your UX best — they all resolve the payment the same way.

Store session_id in your database before redirecting or embedding so you can correlate the webhook back to the order.

Option A — server-side redirect

Simplest — user is redirected from your backend straight to VisionScope's hosted page.

res.redirect(303, data.checkout_url);

Option B — client-side via sessionStorage

Keeps the checkout token out of the URL bar, nginx access logs, and browser history. Pairs well with Option C below.

// on your site, after receiving checkout_url from your backend:
sessionStorage.setItem('checkout_url', data.checkout_url);
window.location.href = '/checkout.html';

// on /checkout.html:
const url = sessionStorage.getItem('checkout_url');
sessionStorage.removeItem('checkout_url');  // single-use
document.getElementById('iframe').src = url;

Option C — embed as an <iframe> (recommended for branded UX)

Keeps the shopper on your domain. You control the page chrome (header, footer, back button) around the payment form. The checkout_url can be loaded into any iframe on any origin — VisionScope's checkout page sets permissive framing headers specifically for this integration.

<!-- checkout.html on your site -->
<!doctype html>
<html>
  <body style="margin:0">
    <iframe
      id="checkoutFrame"
      title="Secure Checkout"
      sandbox="allow-scripts allow-forms allow-same-origin allow-top-navigation allow-popups"
      referrerpolicy="no-referrer-when-downgrade"
      style="width:100%; height:100vh; border:0; display:block;"
    ></iframe>
    <script>
      const url = sessionStorage.getItem('checkout_url');
      sessionStorage.removeItem('checkout_url');
      if (url) document.getElementById('checkoutFrame').src = url;
      else location.href = '/';   // no token — send them home
    </script>
  </body>
</html>

Sandbox attribute — what each token does and why it matters

TokenRequired?Why
allow-scriptsyesLets the checkout page's JavaScript run (form validation, API calls)
allow-formsyesLets the user submit the card form
allow-same-originyesNeeded so the checkout page can read its own cookies / localStorage
allow-top-navigationyesCritical for 3DS. Bank 3DS challenge pages refuse to load inside iframes — VisionScope breaks out to top-level for 3DS, and this token permits that navigation
allow-popupsyesSome gateways open a popup for their challenge flow
Don't drop allow-top-navigation or allow-popups. Without them, 3DS-required cards (most EU / UK cards) will silently fail inside your iframe.

Iframe height & layout

The checkout form is long (customer details + billing address + card). Give the iframe full viewport height (height: 100vh) or use a scroll container. The checkout page itself is responsive and renders cleanly from ~320px wide up to desktop widths.

Trade-offs

Option A: redirectOption B: sessionStorage + redirectOption C: iframe
User stays on your domain
Checkout URL in address barVisibleHiddenHidden
Branded header/footer
3DS compatibility✅ (requires sandbox tokens)
Setup complexityLowestLowLow
Return URL behaviorNormal redirectNormal redirectIframe navigates to return URL; use allow-top-navigation to break out
Return URL & iframe. When the payment completes, VisionScope redirects the iframe to your return_url. To break out of the iframe (so the user sees the return page full-screen), either (a) rely on allow-top-navigation and have the checkout page break out automatically, or (b) listen for a postMessage event and navigate the top window yourself. See the reference implementation for details.

5. Webhooks

After the user attempts payment, VisionScope sends a signed POST to your webhook_url. You must verify the signature before trusting the payload.

5.1 Headers

HeaderValue
Content-Typeapplication/json
X-SignatureHex HMAC-SHA256 of the raw body, keyed by webhook_secret
X-TimestampISO-8601 time when the webhook was signed

5.2 Event payloads

payment.success

{
  "event": "payment.success",
  "session_id": "cs_mo8ly3a8_67d5e72898f11888",
  "status": "paid",
  "amount": 10,
  "currency": "EUR",
  "order_id": "VS-1776767335185-54D482EA",
  "paid_at": "2026-04-21T13:18:14.650Z"
}

payment.failed

{
  "event": "payment.failed",
  "session_id": "cs_mo8ly3a8_67d5e72898f11888",
  "status": "failed",
  "amount": 10,
  "currency": "EUR",
  "order_id": "VS-1776767335185-54D482EA",
  "reason": "Payment declined after 3DS",
  "failed_at": "2026-04-21T13:18:14.650Z"
}

5.3 Signature verification (Node.js)

Use the raw request body — not a parsed JSON object. Parsing reorders keys and breaks HMAC.
import express from 'express';
import crypto from 'crypto';

const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('X-Signature');
    const rawBody   = req.body;  // Buffer

    if (!signature) return res.status(400).json({ error: 'missing signature' });

    const expected = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(rawBody)
      .digest('hex');

    const sigBuf = Buffer.from(signature, 'hex');
    const expBuf = Buffer.from(expected, 'hex');

    if (sigBuf.length !== expBuf.length ||
        !crypto.timingSafeEqual(sigBuf, expBuf)) {
      return res.status(401).json({ error: 'invalid signature' });
    }

    const payload = JSON.parse(rawBody.toString('utf8'));
    // ── update your database here ──
    res.status(200).json({ received: true });
  });

5.4 Response expectations

  • Return HTTP 2xx within 10 seconds to acknowledge.
  • Any non-2xx or timeout triggers a retry with exponential backoff.
  • Process idempotently — the same event may arrive more than once. Dedupe on (session_id, event).

5.5 Timing

Payment typeWebhook timing
Non-3DS, approved~2 seconds after charge
3DS-requiredAfter user completes 3DS (may be minutes)
Declined~10 seconds
Gateway timeoutReconciled by background sync every 10 seconds

6. Return URL

After the user finishes (success or failure), VisionScope redirects their browser to your return_url with these query params:

ParamValues
session_idthe session you created
statuspaid | failed
order_idVS-prefixed order ID (same as in the webhook)
reasononly on failure — human-readable message
https://yourmerchant.com/return?session_id=cs_mo8ly3a8_67d5e72898f11888&status=paid&order_id=VS-1776767335185-54D482EA
The return URL is a UX convenience, not a source of truth. The user may close the browser before the redirect. Always rely on the webhook for authoritative state.

7. End-to-End Example

From the reference caller-project:

7.1 Backend — proxy the create call

// server.js
import 'dotenv/config';
import express from 'express';

const {
  PORT = 5000,
  VISIONSCOPE_API_URL,    // https://api-dev.visionscopeai.com/api/checkout
  VISIONSCOPE_API_KEY,    // vsk_live_...
  WEBHOOK_PUBLIC_URL,     // https://yourmerchant.com/webhook
  WEBHOOK_SECRET,         // 64 hex chars
  RETURN_URL,             // https://yourmerchant.com/return
} = process.env;

const app = express();

app.post('/api/checkout', express.json(), async (req, res) => {
  const { amount, currency } = req.body;
  if (!amount || !currency) {
    return res.status(400).json({ error: 'amount and currency required' });
  }

  const r = await fetch(`${VISIONSCOPE_API_URL}/create`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': VISIONSCOPE_API_KEY,
    },
    body: JSON.stringify({
      amount,
      currency,
      webhook_url: WEBHOOK_PUBLIC_URL,
      webhook_secret: WEBHOOK_SECRET,
      return_url: RETURN_URL,
    }),
  });

  const data = await r.json();
  if (!r.ok || !data.success) return res.status(r.status).json(data);

  // persist data.data.session_id in your DB here
  res.json(data.data);
});

7.2 Frontend — redirect and return page

<!-- index.html -->
<button id="buy">Pay €10.00</button>
<script>
  document.getElementById('buy').onclick = async () => {
    const r = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ amount: 10.0, currency: 'EUR' }),
    });
    const data = await r.json();
    sessionStorage.setItem('checkout_url', data.checkout_url);
    location.href = '/checkout.html';
  };
</script>
<!-- return.html -->
<h1 id="status"></h1>
<script>
  const p = new URLSearchParams(location.search);
  document.getElementById('status').textContent =
    p.get('status') === 'paid'
      ? 'Thank you!'
      : `Payment failed: ${p.get('reason') || ''}`;
</script>

8. Security Checklist

  • HTTPS only for webhook_url and return_url.
  • API key stored server-side only — never in frontend code or git history.
  • webhook_secret is ≥ 32 random bytes (openssl rand -hex 32).
  • Verify X-Signature on every webhook using crypto.timingSafeEqual (not ===).
  • Reject webhooks with bad signatures with HTTP 401.
  • Idempotent handler — deduplicate on (session_id, event).
  • Reply under 10 seconds — return 200 fast; do heavy work in a background queue.
  • Treat return-URL query params as untrusted — verify final state via your DB (fed by webhooks).
  • Rotate webhook_secret periodically.
  • Rate-limit your /webhook endpoint.

9. Troubleshooting

SymptomLikely causeFix
401 Invalid signature on webhookHMAC computed over parsed JSON, not raw bodyUse raw Buffer (see 5.3)
Payload mismatch vs signatureWrong webhook_secret on your sideCreate a new session with the correct secret
No webhook ever arriveswebhook_url not reachable from the internetUse a public HTTPS URL (or a tunnel like ngrok in dev)
Redirect arrives, no webhookUser closed browser before gateway respondedTrust the webhook, not the redirect
Webhook fires twiceRetry or 3DS raceMake the handler idempotent
502 on createTransient upstreamRetry with exponential backoff (max 3)

10. Support

For integration questions or incident reports:

nabeeltahirdeveloper@gmail.com