Vezmo App

Payments

Secure Payments (embed Vezmo's payment form on your site)

The Secure Payments API lets you collect a payment inside your own site via an iframe which is Vezmo-hosted. Each payment is recorded directly in your Payment table; no Paylink is created.

Deferred by design — no charge until the customer pays. Opening or rendering the checkout does not create a charge or a card authorization. The payment is created and confirmed only when the customer submits — so shoppers who browse or abandon the checkout create nothing to collect on. Any incomplete session is automatically expired, is hidden from your dashboard by default, and never counts toward your account standing or risk metrics (those are measured only on completed payments).

Three-step integration

1. Mint a clientToken on your server

Authentication is a two-step exchange, run entirely on your server. Your API key and secret never touch the browser.

Step 1a — Exchange your key + secret for a short-lived access token

POST /merchant/api-auth/login with your credentials in the x-api-key and x-api-secret headers. It returns a JWT at data.accessToken.token (valid 30 minutes; a refreshToken is also returned).

Step 1a
curl -X POST https://api.vezmo.com/api/v1/merchant/api-auth/login \
  -H "x-api-key: $VEZMO_API_KEY" \
  -H "x-api-secret: $VEZMO_API_SECRET"

# Response:
# {
#   "success": true,
#   "message": "Merchant login successfully",
#   "data": {
#     "accessToken": {
#       "token": "eyJ...",          // <- use this as the Bearer token in step 1b
#       "refreshToken": "eyJ..."
#     }
#   }
# }

Step 1b — Create the secure payment with that access token

Send the token from step 1a as Authorization: Bearer <token> to POST /merchant/secure-payments. The amount, currency, your customer's details (the client object), and any merchant-side display fields go in the body.

Step 1b
curl -X POST https://api.vezmo.com/api/v1/merchant/secure-payments \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1234" \
  -d '{
    "title": "Order #1234",
    "amount": 49.99,
    "currency": "USD",
    "theme": "dark",
    "client": {
      "name": "Jane Doe",
      "email": "jane@example.com",
      "phone": "+15551234567",
      "line1": "123 Market St",
      "line2": "Suite 400",
      "city": "San Francisco",
      "state": "CA",
      "postalCode": "94103",
      "country": "US"
    }
  }'

Required customer fields

Every payment must include the customer's name, country, and postal / ZIP code. An email is additionally required for bank (ACH) payments. Requests missing a required field are rejected with 400.

  • name — required
  • country — required (ISO code, e.g. US)
  • postalCode — required (ZIP / postal code)
  • email — required for bank/ACH; recommended otherwise
  • line1, city, state, phone — optional but recommended (a complete address improves fraud screening)

How we use it: for a normal card payment we forward only the customer's name and address to the card network for verification (AVS / fraud) — we do not share their email or phone. Email is forwarded only for bank (ACH), saved-card, and wallet payments (bank payments require it for the debit mandate). Every field you provide is kept on your VezmoPay payment record either way.

Provide your customer's details via client at session creation; the embedded checkout collects only the payment method. You collect the customer's details on your own checkout form and pass them here. The embed does not ask the buyer for their name, email, or address — it renders the payment method (card / bank / wallet) only.

Required fields. Your client object must include name, email, country, and postalCode — the request is rejected with 400 without them. We send the customer's name and address to the card network for verification (AVS / fraud), and email is required because bank (ACH) payments need it for the debit mandate.

For a normal card payment we deliberately forward only the name and address to the processor — the email and phone are kept on your Vezmo record only and are not shared with the card network. (Email is forwarded only for ACH, saved-card, and wallet payments.)

phone, line1, line2, city, and state are optional but recommended — a complete address improves fraud screening and is kept on the payment as your own customer record (returned as customer + billingAddress).

Send Idempotency-Key as an HTTP header (1–255 ASCII chars) to safely dedupe retries — not in the request body.

Response:

200 OK
{
  "success": true,
  "data": {
    "payment": {
      "id": "clt3k9a0x0001qf8w2h7d4m1b",   // opaque session id (cuid) — not "pay_…"
      "amount": 49.99,
      "currency": "USD",
      "status": "INITIATED",
      "category": "SECURE_PAYMENT",
      "title": "Order #1234"
    },
    "securePayment": {
      "clientToken": "eyJ...",
      "url": "https://api.vezmo.com/api/v1/secure-payments/eyJ...",
      "sdkUrl": "https://api.vezmo.com/api/v1/vezmo.js",
      "html": "<iframe src=\"...\" width=\"100%\" height=\"720\" style=\"border:0\" allow=\"payment\"></iframe>",
      "expiresAt": "2026-05-21T14:30:00.000Z"
    }
  }
}

The clientToken is a short-lived JWT (default 30 min, max 24 h). The merchant API key never leaves your server.

Your account must be approved before you can collect payments

Creating a live secure-payment session (a live API key) requires your VezmoPay account to be approved and charges-enabled. Until then, this endpoint returns 403 — this business isn't accepting payments yet (this is an account-status response, not an API-key or scope error). A test API key is NOT gated — the sandbox works before approval, so you can build and verify your whole integration while review is pending.

Redirects (optional)

Pass two optional URLs in the create-session body to have the hosted checkout redirect the customer after the payment resolves:

  • successUrl — after a server-confirmed payment, the hosted checkout redirects the customer here with ?paymentId=<id>&status=success appended.
  • cancelUrl — the customer is sent here on failure / cancellation.

Both URLs must be on one of your Trusted Origins (open-redirect protection rejects any other origin, as well as javascript: / data: schemes). When you omit them, behavior is unchanged: nothing redirects and you handle the outcome via the success / error postMessage events.

Theme (optional)

The embedded checkout supports a light and a dark appearance so it can match your site. Pick it in either of two ways (the SDK choice wins when both are set):

  • SDK (per-mount): pass theme: 'light' | 'dark' | 'auto' to mount() — v.mount('#vezmo-pay', { clientToken, theme: 'dark' }).
  • API (session default): pass an optional theme on the create-session body (POST /merchant/secure-payments). Used when the SDK mount() didn't pass one.

Values are light (default), dark, and auto. auto follows the customer's device prefers-color-scheme and reacts live to OS changes. Omit it everywhere and the checkout stays light, exactly as before.

2. Mount the iframe on your page

Two integration paths — pick the one that fits your stack.

SDK path (recommended): the embed renders the payment form only — you supply the Pay button and call v.pay() to trigger the charge (Elements-style card form). This keeps your checkout button styled and controlled by you, and lets you decide the next action on success/failure.

SDK embed
<script src="https://api.vezmo.com/api/v1/vezmo.js"></script>
<div id="vezmo-pay"></div>
<button id="pay-btn">Pay now</button>
<script>
  const v = Vezmo();
  // theme: 'light' | 'dark' | 'auto' (optional, default 'light').
  // 'auto' follows the customer's device color scheme.
  v.mount('#vezmo-pay', { clientToken: '<from step 1>', theme: 'dark' });

  const btn = document.getElementById('pay-btn');
  btn.addEventListener('click', () => {
    btn.disabled = true;            // YOUR button triggers the charge
    v.pay();
  });

  v.on('processing', () => { btn.disabled = true; /* show a spinner */ })
   .on('success', ({ transactionId, paymentId }) => {
      // Payment is CAPTURED. transactionId + paymentId are Vezmo references you
      // can reconcile against. Redirect to your thank-you page.
   })
   .on('pending', ({ transactionId, paymentId }) => { /* ACH/bank transfer settling — funds clear later */ })
   .on('error',   ({ message }) => { btn.disabled = false; /* show error; user can retry */ })
   .on('expired', () => { /* link expired — prompt the buyer to request a fresh one */ })
   .on('already-paid', () => { /* this payment was already completed */ });
</script>

Apple Pay / Google Pay appear as their own express buttons inside the embed — they charge directly and fire the same events, so no extra Pay button is needed for them.

Risky/challenged payments may open a secure window — this is expected

When the card needs extra verification (fraud check / 3-D Secure), that step can't complete inside an embedded (third-party) iframe, so the embed may briefly open a top-level secure window for the buyer to finish. Your integration doesn't change: the normal success / pending / error event still fires afterwards exactly as above — handle the outcome there as usual. The window opens on the buyer's click, so popup blockers don't interfere.

Always load vezmo.js from https://api.vezmo.com/api/v1/vezmo.js (don't copy/self-host it). It's versionless and served by VezmoPay, so improvements and fixes — like the secure-window handling above — apply to your checkout automatically with no code change on your side.

The SDK validates every event by its source — it only invokes your callbacks for messages posted by the exact iframe it created. (It does not check a fixed origin, because the embed 302-redirects from the API origin to the Vezmo-hosted checkout origin.) With the SDK you don't need a manual origin check; with a raw iframe you must validate e.origin yourself.

Raw-iframe fallback (no JS framework / no SDK):

Raw iframe
<iframe
  src="<securePayment.url>"
  width="100%"
  height="720"
  style="border: 0"
  allow="payment"
></iframe>
<script>
  window.addEventListener('message', (e) => {
    // REQUIRED: verify the origin yourself. Without this, hostile iframes could
    // spoof events. The embed 302-redirects API -> the Vezmo-hosted checkout,
    // so e.origin is the CHECKOUT origin (e.g. https://app.vezmo.com), NOT the
    // API origin. Prefer the vezmo.js SDK, which validates by message source.
    if (e.origin !== 'https://app.vezmo.com') return;
    if (e.data?.type === 'vezmo:secure-payment:success') {
      // payment is CAPTURED — e.data also has transactionId + paymentId
    }
  });
</script>

3. Verify the Payment server-side (optional but recommended)

The payment.success and payment.failed webhooks fire on every state transition. Subscribe in your webhook settings to be notified. The delivered body is { id, event, data } and the event name is also sent in the X-Webhook-Event header. For a secure payment, event is payment.success / payment.failed and data contains { id, paymentId, userId, companyId, amount, currency, type: 'secure-payment', status, livemode }. livemode is false for sandbox (test-key) payments — webhooks fire for test events too, so you can verify your handler end-to-end before going live; always branch on it before fulfilling.

Webhook handler
// Express example — verify the signature over the RAW body, then handle.
const crypto = require('crypto');
const ENDPOINT_SECRET = process.env.VEZMO_WEBHOOK_SECRET; // whsec_…

// Use express.raw so req.body is the exact bytes that were signed:
app.post(
  '/webhooks/vezmo',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body; // Buffer
    const signature = req.get('X-Webhook-Signature') || '';

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

    const sigBuf = Buffer.from(signature, 'hex');
    const expBuf = Buffer.from(expected, 'hex');
    const valid =
      sigBuf.length === expBuf.length &&
      crypto.timingSafeEqual(sigBuf, expBuf);

    if (!valid) return res.sendStatus(401); // reject on mismatch

    const { event, data } = JSON.parse(rawBody.toString('utf8'));
    if (event === 'payment.success') {
      // data.paymentId is the Vezmo Payment id
      fulfillOrder(data.paymentId);
    }
    res.sendStatus(200);
  },
);

Webhooks are signed. Every delivery includes an X-Webhook-Signature header — hmac_sha256(rawBody, endpointSecret) as hex — plus an X-Webhook-Timestamp header. The endpointSecret is the whsec_… value shown once when you create the webhook endpoint. Recompute the HMAC over the raw request body (not the parsed JSON) with your endpoint secret and compare it to the header using a constant-time check (crypto.timingSafeEqual); reject the delivery on mismatch. As an extra safeguard, confirm state by re-fetching the Payment (by data.paymentId) before fulfilling.

Event reference

EventWhen it fires
vezmo:secure-payment:readyiframe finished loading and the Payment Element is mounted
vezmo:secure-payment:processingthe charge has started confirming (after v.pay() or a wallet button) — keep your Pay button disabled / show a spinner
vezmo:secure-payment:successVezmo confirmed; the Payment row is now CAPTURED. Payload includes paymentIntentId (the payment intent id) — it does not include a paymentId.
vezmo:secure-payment:errorVezmo declined or initialization failed; if a PaymentIntent existed, the Payment row is FAILED with metadata.failureReason
vezmo:secure-payment:pendingACH or bank debit — funds settle asynchronously (typically a few business days)
vezmo:secure-payment:expiredthe clientToken has expired. The embed shows an in-frame "link expired" state and posts this event so you can prompt the buyer to request a fresh link.
vezmo:secure-payment:already-paidthe Payment is already in a terminal (already-paid) state. The embed shows an in-frame message and posts this event so you can avoid charging the buyer again.

Payment methods

Card is always available. Apple Pay / Google Pay appear automatically as express buttons inside the embed when the payer's device/browser supports them.

ACH / US bank transfer

The Bank transfer option only appears when the payment is in USD and ACH (us_bank_account) is enabled on your account. When eligible, the payer gets two sub-options:

  • Connect bank instantly — an instant bank connection verifies the account in the moment. (The bank-picker screen is provided by our payment processor and shows their wordmark; this is required and not removable.)
  • Enter details manually — fully white-label routing / account number entry in Vezmo's own form (no third-party modal), with a compliant Nacha mandate. A manually-entered account is unverified, so it goes through a one-time bank verification (micro-deposit) before the debit can run — see the dedicated Bank payments (ACH) & verification guide. After a buyer verifies once, they can save the bank and pay with a one-time email code next time (the VezmoPay saved-bank network).

ACH settles asynchronously and, for manual entry, only after the buyer verifies their bank. In all cases the embed fires vezmo:secure-payment:pending (not success) at submit and the final payment.success / payment.failed arrives via webhook once the debit clears. Never fulfil on pending. The complete bank lifecycle — instant vs manual, micro-deposit verification, the saved-bank network, order handling and sandbox test values — is documented in Bank payments (ACH) & verification.

Trusted Origins (required before going live)

The embed is cross-origin, so it will not load on your site until you add your site's origin (e.g. https://yourstore.com) under Settings → Developer Settings → Trusted Origins. Your listed origins drive both the CSP frame-ancestors allowlist (which sites may embed the iframe) and the set of origins the checkout will postMessage events to. Until your origin is trusted, the iframe is blocked and no events are delivered.

Test mode (sandbox)

Use a test API key (created under VezmoPay → Developers → API keys) against the same production base URL https://api.vezmo.com. Test keys run your entire integration — sessions, checkout, webhooks, refunds — against an isolated sandbox: no real money ever moves, and sandbox activity never appears in your live dashboard (flip the Test mode toggle in the VezmoPay console to see it). The sandbox works even while your account is still under review, so you can integrate before activation. When you're ready to go live, swap the test key for your live key — nothing else changes.

Seeing your test data: flip the orange Test mode toggle in the VezmoPay console — Overview, Transactions, Balance, Disputes, Reports and Customers all switch to the sandbox world. Sandbox webhooks deliver with livemode: false; sandbox refunds work the same as live ones (a test key can only refund test payments).

Not simulated in sandbox: payouts (moving money to a bank is live-only) and instant payouts. Everything else — sessions, checkout, cards, 3D Secure, ACH, refunds, disputes, webhooks — behaves like live.

Use test card credentials in sandbox checkouts. Common test values:

InstrumentValueResult
Card4242 4242 4242 4242Succeeds (any future expiry, any CVC, any ZIP)
Card4000 0000 0000 0002Generic decline
Card4000 0000 0000 9995Decline — insufficient funds
Card (3D Secure)4000 0000 0000 3220Requires a 3D Secure authentication challenge — use to test the mandatory-3DS flow (the challenge popup appears automatically).
Card (3D Secure)4000 0000 0000 30553D Secure supported but not required (frictionless — authenticates without a challenge).
Card (dispute)4000 0000 0000 0259Succeeds, then is disputed as fraudulent — use to test the dispute / chargeback flow.
ACH (manual)routing 110000000, account 000123456789Accepted → goes to bank verification (micro-deposit). In sandbox, verify with amounts 32 & 45. See Bank payments.

Permissions

Creating a secure payment requires the secure-payment.create scope, which is granted to every API key by default — so the embed checkout works out of the box with a new key, no extra setup.

The optional 3D Secure control API is the exception: account.read / account.update are not granted by default and must be added to your key under Settings → Developer Settings. You only need them if you call /merchant/account/3d-secure; the checkout itself does not.

Limitations

  • The clientToken expires after 30 minutes by default (max 24 hours via ttlMinutes in the request body, range 5–1440).
  • The iframe is gated by a per-merchant CSP allowlist. The checkout only loads inside (and only posts events to) origins you have added under Trusted Origins in your settings — the frame-ancestors directive is resolved per token from GET /secure-payments/:token/frame-ancestors. Add your site there before going live (see Trusted Origins below).
  • Successful and declined payments both land in your Payment table tagged category = 'SECURE_PAYMENT'. Pending (ACH) settles asynchronously via Vezmo webhook.