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).
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.
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— requiredcountry— required (ISO code, e.g.US)postalCode— required (ZIP / postal code)email— required for bank/ACH; recommended otherwiseline1,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:
{
"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=successappended.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'tomount()—v.mount('#vezmo-pay', { clientToken, theme: 'dark' }). - API (session default): pass an optional
themeon the create-session body (POST /merchant/secure-payments). Used when the SDKmount()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.
<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):
<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.
// 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
| Event | When it fires |
|---|---|
vezmo:secure-payment:ready | iframe finished loading and the Payment Element is mounted |
vezmo:secure-payment:processing | the charge has started confirming (after v.pay() or a wallet button) — keep your Pay button disabled / show a spinner |
vezmo:secure-payment:success | Vezmo confirmed; the Payment row is now CAPTURED. Payload includes paymentIntentId (the payment intent id) — it does not include a paymentId. |
vezmo:secure-payment:error | Vezmo declined or initialization failed; if a PaymentIntent existed, the Payment row is FAILED with metadata.failureReason |
vezmo:secure-payment:pending | ACH or bank debit — funds settle asynchronously (typically a few business days) |
vezmo:secure-payment:expired | the 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-paid | the 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:
| Instrument | Value | Result |
|---|---|---|
| Card | 4242 4242 4242 4242 | Succeeds (any future expiry, any CVC, any ZIP) |
| Card | 4000 0000 0000 0002 | Generic decline |
| Card | 4000 0000 0000 9995 | Decline — insufficient funds |
| Card (3D Secure) | 4000 0000 0000 3220 | Requires a 3D Secure authentication challenge — use to test the mandatory-3DS flow (the challenge popup appears automatically). |
| Card (3D Secure) | 4000 0000 0000 3055 | 3D Secure supported but not required (frictionless — authenticates without a challenge). |
| Card (dispute) | 4000 0000 0000 0259 | Succeeds, then is disputed as fraudulent — use to test the dispute / chargeback flow. |
| ACH (manual) | routing 110000000, account 000123456789 | Accepted → 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
ttlMinutesin 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-ancestorsdirective is resolved per token fromGET /secure-payments/:token/frame-ancestors. Add your site there before going live (see Trusted Origins below). - Successful and declined payments both land in your
Paymenttable taggedcategory = 'SECURE_PAYMENT'. Pending (ACH) settles asynchronously via Vezmo webhook.