After the payment
Webhooks
Webhooks let VezmoPay push events to your server as they happen — a payment is captured, a refund is processed, a dispute is opened, a payout settles. Instead of polling, you register an HTTPS endpoint once and we POST a signed JSON event to it every time something relevant occurs on your account.
1. Register an endpoint
In the dashboard go to VezmoPay → Developers → Webhooks → Add endpoint. Enter your HTTPS URL and select the events you want to receive. The URL must be https:// in every mode, sandbox included (test through an https tunnel); addresses that are private or resolve to private ranges are refused, and redirects are not followed. On creation we show your signing secret (a whsec_… value) exactly once — copy it then and store it on your server. If you lose it, delete the endpoint and create a new one (a fresh secret is shown again).
An endpoint must be active and subscribed to the specific event for that event to be delivered. Subscribing to payment.success does not implicitly subscribe you to payment.failed — select each event you want.
Endpoints belong to your business, not to the teammate who added them: they keep receiving events after that person leaves your team. When you remove a teammate who added endpoints, the account holder gets an email listing them, so you can delete any that aren't yours.
2. Events
| Event | Fires when |
|---|---|
payment.awaiting_verification | A manually-entered bank (ACH) payment was submitted but must first be verified by the buyer via micro-deposits (the “verify your bank” step). The payment is real and pending — record it as pending in real time. It completes (→ payment.processing → payment.success) once the buyer verifies, or expires after ~10 days if they don't. Distinct from an abandoned checkout (the buyer did submit). The payload carries state: "awaiting_verification", requiresVerification: true, method: "bank", and a verification object (type, url — the branded verify page you can share —, deadlineAt, attempts, emailSentTo). You can also complete it yourself: verify from your own app. If it lapses or the bank is locked after too many wrong attempts, payment.failed follows with declineCode verification_expired or verification_attempts_exceeded. |
payment.processing | A payment goes in-flight — e.g. an ACH bank transfer is accepted and settling. Fires at initiation; payment.success follows at settlement (bank transfers take 1–5 business days). Subscribe to this to record ACH payments as pending in real time, instead of polling. |
payment.success | A payment is captured: card or wallet, a settled bank (ACH) payment, Instant ACH, Cash App Pay, UPI, Pix, Klarna, Affirm, Afterpay or Amazon Pay. The payload's method says which. |
payment.failed | A payment attempt fails or is declined. |
payment.canceled | An in-flight bank (ACH) payment was canceled before the debit was submitted — by you (dashboard or POST /merchant/payment/:id/cancel) or at the payments partner. Terminal: no money moves and the buyer is emailed. Distinct from payment.failed (a decline or bank return). Payload carries state: "canceled", reason, canceledBy (merchant | api | provider) and canceledAt. |
payment.refunded | A full or partial refund is processed. |
payment.refund_failed | A refund could not be completed (UPI, Pix and the pay-later methods refund after the call returns). The amount is back in your balance; refund the customer another way. Sent on both the Connect and merchant-of-record tracks; each failed refund is its own event. |
payment_method.saved | A customer saves a card for reuse (the Vezmo Elements or checkout saveCard flow). Payload carries savedCardId, brand, last4, expiry and a masked email — no card number or processor ids. |
dispute.created / dispute.updated / dispute.closed | A dispute (chargeback or card-issuer inquiry) is opened, changes status (e.g. evidence submitted), or reaches a final status. One event per status change; the payload carries the payment ids, reason, network reason code and the evidenceDueBy deadline. See Disputes. |
payout.paid / payout.failed | A payout to your bank settles or fails. A bank can return a payout days after it was paid: the same payout (same id) then sends payout.failed after payout.paid, and the money is back in your balance. Never the other way round. Keep the latest status. |
subscription.trial_started | A free trial started. Nothing is charged yet. |
subscription.activated | The first payment succeeded; the subscription is active. |
subscription.renewed | A renewal payment succeeded. |
subscription.payment_failed | A subscription charge failed. A retry is scheduled, or the customer has to act. |
subscription.updated | The quantity or the plan changed. |
subscription.paused / subscription.resumed | Billing was paused, or started again. |
subscription.cancel_scheduled | A cancel was booked for the end of the period; the subscription is still active. |
subscription.canceled | The subscription ended (see cancellationReason). |
subscription.refunded | A subscription payment was fully or partly refunded. |
subscription.disputed | A subscription payment was disputed, or its dispute was won or lost. See Subscriptions. |
agreement.sent | An agreement was sent for signature. See Agreements. |
agreement.viewed | A recipient opened it for the first time. Fires once per recipient. |
agreement.signed | One recipient signed. Fires once per recipient; the last one is followed by agreement.completed. |
agreement.completed | Everyone signed (with the ID check on, after you approved each signer and completed it) and the signed PDF is ready. |
agreement.declined | A recipient declined. |
agreement.voided | You (dashboard or API) or Vezmo support voided it. |
agreement.expired | The deadline passed with someone still to sign. |
agreement.paid | Its payment link was paid, whether or not the signed copy was held for payment. |
agreement.identity_checked | A signer's ID check passed, needs your review, was approved (by you or automatically), asked again, or the signer was rejected (dashboard or API), or it failed for the last time. recipient.identityCheck says which. |
agreement.awaiting_approval | Everyone has signed an agreement with the ID check on, and it waits for you to approve the signers before anyone gets the final copy. See Approve and complete. |
Each agreement.* event fires whatever caused it: the dashboard, the API, a signer, the deadline or support. Payloads are on the Agreements page.
Invoice and proposal events are in the endpoint editor too: invoice.created, invoice.sent, invoice.paid (the client paid at checkout, or a bank payment settled), invoice.cancelled, proposal.sent, proposal.accepted and proposal.rejected. A partial refund is its own payment.refunded event, with its own id.
The proposal events carry this data, plus merchantId:
| Event | data |
|---|---|
proposal.sent | { id, userId, companyId, title, status: "SENT", livemode } |
proposal.accepted | { id, userId, companyId, clientId, title, status: "ACCEPTED", clientProjectId, invoiceId, acceptedBy, livemode }. clientProjectId is the client project the acceptance started. invoiceId is null for an HOURLY proposal (no invoice is raised). acceptedBy is "client" when your client accepted from the link, and "business" when you used Mark accepted. |
proposal.rejected | { id, userId, companyId, clientId, title, status: "REJECTED", rejectedBy, livemode }. rejectedBy: "client" or "business" (Mark rejected). |
A payment.* event for a sale the buyer paid in their own currency reports your price in amount and currency, and what the buyer paid in charged. See Local Currency.
3. Delivery format
Every event is an HTTP POST with a JSON body and these headers:
| Header | Value |
|---|---|
X-Webhook-Signature-V2 | HMAC-SHA256(endpointSecret, timestamp + "." + rawBody) as lowercase hex, where timestamp is the exact X-Webhook-Timestamp string. This is the header to verify. |
X-Webhook-Signature | HMAC-SHA256(rawBody, endpointSecret) as lowercase hex, over the body only. Kept for integrations that already check it; new integrations should verify V2. |
X-Webhook-Event | The event name (e.g. payment.success). |
X-Webhook-Timestamp | Unix seconds when we sent this attempt (fresh on every retry). It is covered by X-Webhook-Signature-V2: reject a delivery whose timestamp is more than 5 minutes from your clock, and still dedupe on the event id. |
Content-Type | application/json |
The body is always this envelope:
{
"id": "evt_…", // unique per event (the same across that event's retries)
"event": "payment.success",
"data": { … } // event-specific payload (see below)
}For payment.success / payment.failed the data object is:
{
"id": "clt…", // the Vezmo Payment id
"paymentId": "clt…", // same Payment id (use this to reconcile)
"transactionId": "VT34J5UP", // the reference on the receipt and in the dashboard (null until paid)
"userId": "…",
"companyId": "…",
"amount": 49.99,
"currency": "USD",
"type": "secure-payment",
"status": "CAPTURED", // or FAILED
"livemode": true // false for TEST-mode payments
}amount is always in major units, as a JSON number (49.99 = $49.99) — in every event.
On payment.failed the payload also carries the (white-label) decline details so you can tell whose issue it was and whether to retry:
{
// …the common fields above, plus:
"reason": "Your card was declined.", // white-label message (no processor name)
"declineCode": "insufficient_funds", // machine code (decline_code / code)
"decline": {
"message": "Payment declined by your bank (issuer) — insufficient funds.",
"reason": "Your card was declined.",
"code": "insufficient_funds",
"category": "insufficient_funds",
"source": "issuer", // issuer | gateway | card | processor | network
"networkDeclineCode":"51",
"networkStatus": "declined_by_network",
"adviceCode": "try_again_later", // issuer's advice on retrying
"outcomeType": "issuer_declined", // issuer_declined | blocked | invalid
"outcomeReason": null, // why it was blocked, when blocked
"riskLevel": "normal", // normal | elevated | highest | not_assessed
"blockedByRule": false, // true = a fraud rule stopped it
"retriable": true // false = hard decline (lost/stolen/fraud)
}
}For payment.awaiting_verification the data object carries the buyer-facing verification block, so you can show or share the link the moment the payment is submitted:
{
"id": "clt…", // the Vezmo Payment id
"paymentId": "clt…", // the same id — use it to reconcile and to call verify-bank
"companyId": "…",
"merchantId": "VZM…", // your VezmoPay merchant id
"amount": 54.90,
"currency": "USD",
"type": "secure_payment", // secure_payment | paylink | invoice | order
"method": "bank",
"status": "AWAITING_VERIFICATION",
"state": "awaiting_verification",
"requiresVerification": true,
"livemode": true,
"verification": {
"type": "descriptor_code", // or "amounts"
"url": "https://user.vezmo.com/verify-bank/bv_…", // the branded verify page — share it
"deadlineAt": "2026-09-24T09:12:44.000Z",
"attempts": 0,
"emailSentTo": "buyer@example.com", // null when the checkout had no email
"emailSentAt": "2026-09-14T09:12:44.000Z",
"reminderSentAt": null
}
}When it lapses or the bank is locked after too many wrong attempts, the payment.failed that follows carries declineCode: "verification_expired" or "verification_attempts_exceeded" and a reason sentence you can show as-is.
decline.message is the one to show a human. It is a complete, ready-to-display sentence written by us — never the processor's wording — so it is safe to put straight in front of a buyer or in your own dashboard. Examples: "Payment declined by your bank (issuer).", "Payment blocked by a fraud-protection rule.", "The card has expired. Please use a different card." Branch your code on decline.category / decline.source, not on the message text.
Hard fraud declines (lost / stolen / pickup / fraudulent card) are deliberately collapsed into one neutral sentence. The specific reason stays in decline.code for your logs — never show it to the payer.
decline.source tells you who declined it: issuer = the cardholder's bank (e.g. insufficient funds — not your or our fault; the buyer should contact their bank or try another card); gateway = VezmoPay's risk/fraud check blocked it; card = bad card details (expired / wrong CVC); processor = a temporary processing error (safe to retry). retriable: false marks a hard decline (lost/stolen/fraud) — don't auto-retry.
When our risk checks stop a payment you get source: "gateway", outcomeType: "blocked" and retriable: false. outcomeReason narrows it further (highest_risk_level, elevated_risk_level, rule, merchant_blacklist), and blockedByRule: true means a specific fraud rule fired. The rule itself and the numeric risk score are internal to our risk engine and are never sent — riskLevel is the signal you get.
A failed 3D Secure / card-authentication check (the buyer didn't complete authentication) comes through as source: "card", category: "authentication_failed", retriable: true — ask the buyer to retry or use a different card.
Every payment.* event carries method: how the payment was paid (card, bank, instantAch, cashapp, upi, pix, klarna, affirm, afterpayClearpay or amazonPay; the same values as the Payments API).
payment.refunded adds fullyRefunded + transactionId; dispute.* carries paymentId, transactionId, paymentMethod (card | ach | instantAch | cashapp | upi | klarna | affirm | afterpayClearpay | amazonPay), amount, currency, reason, reasonCode, status, evidenceDueBy, livemode and createdAt (full example on Disputes); payout.* carries the payout amount, status, arrivalDate.
4. Verify the signature (required)
The things that break verification
- Computing the HMAC over a re-serialized body instead of the raw bytes we sent. Parsing the JSON and re-stringifying changes whitespace/key order and the signature will never match. Capture the raw body.
- Reading the wrong header. The signature to verify is in
X-Webhook-Signature-V2(HTTP headers are case-insensitive). If your guard returns "missing signature" while this header is present, it is reading a different header name. - For V2, signing anything other than the timestamp string exactly as received, then
., then the raw body. - Returning a non-2xx status (we then retry — see below).
Compute HMAC-SHA256 over the timestamp, a dot and the raw request body using your endpoint secret, hex-encode it, compare it to X-Webhook-Signature-V2 with a constant-time comparison, and check the timestamp is within 5 minutes:
const crypto = require('crypto');
const ENDPOINT_SECRET = process.env.VEZMO_WEBHOOK_SECRET; // whsec_…
// IMPORTANT: express.raw — NOT express.json — so req.body is the exact bytes.
app.post(
'/your/webhook/path',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body; // Buffer (the signed bytes)
const timestamp = req.get('X-Webhook-Timestamp') || '';
const received = req.get('X-Webhook-Signature-V2') || '';
const expected = crypto
.createHmac('sha256', ENDPOINT_SECRET)
.update(`${timestamp}.`) // the timestamp, a dot,
.update(rawBody) // then the raw bytes
.digest('hex');
const a = Buffer.from(received, 'hex');
const b = Buffer.from(expected, 'hex');
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
const valid = fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
if (!valid) return res.sendStatus(401);
const { event, data } = JSON.parse(rawBody.toString('utf8'));
if (!data.livemode) {
// TEST-mode event — do NOT fulfill real orders.
} else if (event === 'payment.success') {
fulfillOrder(data.paymentId);
}
res.sendStatus(200); // ACK so we don't retry
},
);5. Respond + retries
Return any 2xx status within 5 seconds to acknowledge. The 5 seconds cover connecting and your reply; we read only the first 2 KB of the response body. Anything else (a non-2xx, a timeout or a redirect) is a failed delivery, and we retry it: 4 attempts in all, immediately, then +6h, +12h and +24h. Fix a receiver that rejects deliveries (a signature bug, say) and the queued retries land on their next attempt.
If every delivery to an endpoint fails for at least 3 days and 50 attempts, we switch the endpoint off and email you; its pending retries are dropped. Turn it back on once the receiver is fixed.
The same event can arrive more than once, so dedupe on the event id. Each status change of a dispute is its own event with its own id. So is each subscription.payment_failed attempt, each subscription.refunded refund, each subscription.disputed status, and each agreement.* occurrence (each signer's agreement.signed, or a second agreement.expired after a reminder revived the agreement).
6. Test mode
Webhooks fire for TEST-mode payments exactly like live — there is no separate test-mode webhook configuration. Test deliveries carry data.livemode = false; always branch on it so a sandbox event never triggers real fulfillment. Use the same endpoint + secret for both modes.
Agreement events are always live (livemode: true): agreements have no test mode. A test key's paylinks, invoices and proposals are sandbox ones: their payment events, the invoice.* events and the proposal.* events carry livemode: false.
7. Delivery logs
Every delivery attempt — success or failure — is recorded with the HTTP response code and timing under VezmoPay → Developers → your endpoint → delivery log. If you believe an event didn't arrive, check there first: a logged 4xx/5xx means we delivered and your endpoint rejected it (almost always signature verification — see §4); no log entry means the endpoint isn't registered/subscribed for that event. Everyone on your team with webhook access sees the log for all of your business's endpoints, whoever added them.