Vezmo App

Payments

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. 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.

2. Events

EventFires when
payment.awaiting_verificationA 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: "ach", 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.processingA 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.successA payment is captured (card, wallet, or settled ACH).
payment.failedA payment attempt fails or is declined.
payment.canceledAn in-flight bank (ACH) payment was canceled before the debit was submitted — by you (dashboard or POST /v1/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.refundedA full or partial refund is processed.
payment_method.savedA customer saves a card for reuse (the Elements/checkout saveCard flow). Payload carries savedCardId, brand, last4, expiry and a masked email — no card number or processor ids.
dispute.created / dispute.updated / dispute.closedA chargeback/dispute is opened, changes status, or closes.
payout.paid / payout.failedA payout to your bank settles or fails.

Invoice and proposal lifecycle events (invoice.paid, proposal.accepted, etc.) are also available in the endpoint editor.

3. Delivery format

Every event is an HTTP POST with a JSON body and these headers:

HeaderValue
X-Webhook-SignatureHMAC-SHA256(rawBody, endpointSecret) as lowercase hex. This is the header you verify.
X-Webhook-EventThe event name (e.g. payment.success).
X-Webhook-TimestampUnix seconds when we sent the delivery (replay window).
Content-Typeapplication/json

The body is always this envelope:

Envelope
{
  "id": "evt_…",          // unique delivery id
  "event": "payment.success",
  "data": { … }            // event-specific payload (see below)
}

For payment.success / payment.failed the data object is:

data — payment.success / payment.failed
{
  "id":        "clt…",          // the Vezmo Payment id
  "paymentId": "clt…",          // same Payment id (use this to reconcile)
  "userId":    "…",
  "companyId": "…",
  "amount":    49.99,
  "currency":  "USD",
  "type":      "secure-payment",
  "status":    "CAPTURED",      // or FAILED
  "livemode":  true             // false for TEST-mode payments
}

On payment.failed the payload also carries the (white-label) decline details so you can tell whose issue it was and whether to retry:

data — decline details
{
  // …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:

data — payment.awaiting_verification
{
  "id":        "pi_…",                        // the payment reference at initiation
  "paymentId": "clt…",                        // the Vezmo Payment id — use this to reconcile and to call verify-bank
  "companyId": "…",
  "merchantId": "VZM…",                       // your VezmoPay merchant id
  "amount":    5490,
  "currency":  "USD",
  "type":      "secure_payment",              // secure_payment | paylink | invoice | order
  "method":    "ach",
  "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.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.

payment.refunded adds fullyRefunded + transactionId; dispute.* carries paymentId, reason, status, amount; payout.* carries the payout amount, status, arrivalDate.

4. Verify the signature (required)

The three things that break verification

  1. 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.
  2. Reading the wrong header. The signature is in X-Webhook-Signature (HTTP headers are case-insensitive). If your guard returns "missing signature" while this header is present, it is reading a different header name.
  3. Returning a non-2xx status (we then retry — see below).

Compute HMAC-SHA256 of the raw request body using your endpoint secret, hex-encode it, and compare it to X-Webhook-Signature with a constant-time comparison:

Signature verification
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 received = req.get('X-Webhook-Signature') || '';

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

    const a = Buffer.from(received, 'hex');
    const b = Buffer.from(expected, 'hex');
    const valid = 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 to acknowledge. Any non-2xx (or a timeout — we wait up to 5 seconds) is treated as a failed delivery and retried. We make up to 4 attempts with increasing back-off: immediately, then +6h, +12h, +24h. So a delivery your endpoint rejected (e.g. a signature bug) will keep retrying — fix the receiver and the queued events will land on their next attempt. Deliveries are idempotent on the event id; dedupe on it. We do not follow redirects (a 30x is a failed delivery).

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.

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.