Vezmo App

Accept payments

Secure Payments

Embed Vezmo's payment form on your own site. The Secure Payments API creates a payment and gives you a token; the Vezmo-hosted iframe collects the payment inside your page. 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"
    }
  }'

Customer fields. Send the customer in a client object. When you do, name, email, country (ISO code, e.g. US) and postalCode are required; a request missing one is rejected with 400. line1, city, state and phone are optional, but a complete address improves fraud screening.

For a card payment we forward only the customer's name and address to the card network for verification (AVS and fraud checks), never their email or phone. Email is forwarded only for bank (ACH), saved-card and wallet payments; bank payments need it for the debit mandate. Every field you send is kept on your payment record.

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.

The customer you set at creation stays on that checkout: a later POST /secure-payments/:token/client with a different email gets 409, and the route never edits your existing client record. It only adds details to this payment's billing record.

Required fields. When you send a client object, it must include name, email, country and postalCode, or the request is rejected with 400. 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. The same key with a different body returns 422. The same key on a session that already settled (CAPTURED, REFUNDED, PARTIALLY_REFUNDED) returns 409: that order is paid. Treat a 409 as “already paid” and reconcile the order — never retry it under a new key, or your customer gets a second chargeable session for an order they have already paid for.

Response:

201 Created
{
  "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 with “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. A bank payment (ACH) settles over days, so it redirects here as soon as the debit is submitted, with status=pending instead — the money is committed but not yet cleared. Treat that order as awaiting settlement and wait for the payment.success webhook.
  • cancelUrl — the customer is sent here on failure / cancellation.

Both URLs should be on your own site and must use https; anything else is refused with 400. They don't need to be in your Trusted origins. When you omit them, behavior is unchanged: nothing redirects and you handle the outcome via the success / error postMessage events.

Transaction label (optional)

Pass descriptor (≤64 characters) on the create-session body to name the integration that created the session — a storefront, a backend service. It appears on that transaction in your VezmoPay dashboard as Origin, so an account taking payments from several sites can tell them apart at a glance.

It is yours alone: never sent to the card networks, never shown to the customer, and it does not affect the customer's bank statement. Omit it and the transaction falls back to the session title.

Mobile app (optional)

When the session is paid inside your iPhone or Android app, it can carry your app's return link:

Create-session body
"app": { "returnUrl": "acme://vezmo" }
  • With Checkout for apps it is required: the hosted page sends the buyer back to it, after the payment and after 3D Secure, a bank app or a pay-later provider such as Klarna.
  • With the iOS, Android, React Native or Flutter SDK it is optional: the SDK sends the link configured in the app (Android needs none).
  • It must be a link your business registered under Developers → Mobile apps, or a path under it (acme://vezmo/orders/1042). Any other link is refused with 400, code return_url_not_registered: "This return link isn't registered for your business. Add the app under Developers → Mobile apps." The SDKs report it as a config error, never a decline.
  • Payments made through the native SDKs, and a subscription's first payment, show iOS app or Android app as their source in your dashboard.

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.

Wallet buttons (optional)

Apple Pay and Google Pay appear at the top of the embed for eligible customers when you have them enabled. They behave differently from every other method, and the difference matters if your checkout creates an order of its own: the wallet sheet charges as soon as the customer approves it. That happens inside the iframe, on the customer's own tap — v.pay() is never involved.

If you finalize on the success event, this is fine and you need do nothing. If instead you create your order before calling v.pay() and only treat a charge as real once your own button ran, a wallet payment would arrive with no order to attach it to. Two options for that integration.

Hold the approval and decide (recommended). Mount with wallets: 'authorize' and the wallet stops charging on approval. Instead your handler runs, with the sheet still open and nothing charged, and the payment goes through only if you return true:

Create your order before the wallet charges
v.mount('#vezmo-pay', { clientToken: '<from step 1>', wallets: 'authorize' });

v.onWalletAuthorized(async ({ authorizationId, wallet, payerName, payerEmail }) => {
  const order = await createMyOrder({ payerName, payerEmail });
  if (!order.ok) {
    // Tell the customer why. A bare false cancels too, but shows a generic message.
    return { proceed: false, message: 'Check the highlighted fields.' };
  }
  return true;                // or { proceed: true }
});

// Optional: grey out the wallet buttons until your own form is complete.
v.setCheckoutReady(false);

Answer within about twenty seconds — the wallet sheet is open and waiting. A handler that throws, returns anything but true or { proceed: true }, or never answers cancels the payment; nothing is charged and no payment is started. The wallet does not supply a shipping address, so your own fields still have to be filled — setCheckoutReady(false) is how you say they are not yet.

Or hide the buttons. Pass wallets: false and they are not rendered at all:

Suppress the wallet buttons on this mount
v.mount('#vezmo-pay', { clientToken: '<from step 1>', wallets: false });

Either value affects that mount only, and neither can add a payment method — your account-level settings still decide what is offered at all. Card and bank are unaffected. Omit the option and wallets charge on approval, which is the right default when a bare success is enough to finalize.

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 (a card form you place in your own page). 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 (https://user.vezmo.com), NOT the
    // API origin. Prefer the vezmo.js SDK, which validates by message source.
    if (e.origin !== 'https://user.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, transactionId, 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 timestamp = req.get('X-Webhook-Timestamp') || '';
    const signature = req.get('X-Webhook-Signature-V2') || '';

    const expected = crypto
      .createHmac('sha256', ENDPOINT_SECRET)
      .update(`${timestamp}.`) // the timestamp, a dot, then the raw bytes
      .update(rawBody)
      .digest('hex');

    const sigBuf = Buffer.from(signature, 'hex');
    const expBuf = Buffer.from(expected, 'hex');
    const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
    const valid =
      fresh &&
      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-V2 header — hmac_sha256(endpointSecret, timestamp + "." + rawBody) as hex — and the X-Webhook-Timestamp it covers; reject a delivery more than 5 minutes old. See Verify the signature. 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 checkout form 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 paymentId and transactionId (Vezmo's ids, the ones your webhooks and the API use).
vezmo:secure-payment:errorVezmo declined or initialization failed; if a payment attempt was made, the payment's state is failed and its failureReason says why. The buyer can retry in the same checkout: if a later attempt succeeds, the payment becomes succeeded and failureReason goes back to null
vezmo:secure-payment:pendingACH or bank debit — funds settle asynchronously (typically a few business days)
vezmo:secure-payment:requires_action
(v.on('action-required'))
the buyer has one more step in a secure window the checkout opens itself: reason: 'redirect' with method (klarna, affirm, afterpayClearpay, amazonPay) when they chose one of those, reason: 'verification' when a security check can't run inside the frame, reason: 'card_check' when the buyer confirms their card inside the frame (a small temporary hold with a code from their banking app) before it pays: a card a risk check blocked, or one whose bank refused 3D Secure (inside the frame that refusal first comes as verification, and the code step runs in the secure window; see card check), or reason: 'saved_card' when a returning buyer pays with a card saved at your store and types the code we emailed them inside the frame. Not final — success, pending or error follows. Keep your Pay button enabled: the buyer may come back and pay another way. With card_check and saved_card the payment goes ahead from inside the frame once the code is right; you don't need to call pay() again.
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. Bank transfer (ACH), Instant ACH, Cash App Pay, Klarna, Affirm, Afterpay and Amazon Pay appear when you turn them on and the payment qualifies (all seven are USD-only). UPI appears when you turn it on and the payer chooses to pay in Indian rupees: they approve in their UPI app (a code on a computer), and the embed sends the usual success or error event. UPI needs the payer's name and address, so send them in the session's client (name, line1, …) for the embed. Pix works the same way for a payer paying in Brazilian reais (they scan the code or paste the Pix code in their bank app); the payer types their CPF in the embed.

ACH / US bank transfer

The Bank transfer option only appears when the payment is in USD and bank transfer (ACH) 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). The code is for that checkout only and is used up by the payment.

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.

Klarna, Affirm, Afterpay and Amazon Pay

When you turn them on (see Payment methods) they appear beside the card on USD payments whose amount fits the method. The buyer finishes on the method's own page:

  • Hosted checkout (the url from the session, or a redirect to it): the buyer goes to the method and comes straight back to the checkout, which then sends them to your successUrl like any other payment.
  • Embed (iframe): these sites can't load inside a frame, so the form shows a Continue to Klarna button that opens the checkout in a secure window on that method. The buyer finishes there; the embed notices on its own and fires success (or pending) as usual. If the buyer presses your Pay button with one of these selected, nothing is charged: the embed fires action-required and points them at its button.
Embed: the extra step
v.on('action-required', (e) => {
  // e.reason === 'redirect' → e.method is 'klarna' | 'affirm' | 'afterpayClearpay' | 'amazonPay'
  payButton.disabled = false;            // nothing was charged; they may pay another way
  hint.textContent = 'Finish in the window that opened.';
});
v.on('success', () => showThankYou());   // fires when they finish, as for a card

Each of these has its own price per payment. As always, fulfil on the payment.success webhook, not on the browser event alone.

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 VezmoPay → Developers → 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 uses account.read / account.update. New keys have them; a key created before they existed can add them under VezmoPay → Developers. 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 above).
  • Klarna, Affirm, Afterpay and Amazon Pay finish outside the frame: in the embed they open in a secure window from the buyer's own click (so pop-up blockers let it through); with Vezmo Elements they need a returnUrl.
  • Successful and declined payments both appear in your payments list and the Payments API. A pending bank (ACH) payment settles later; a webhook tells you when.