Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.paybridgenp.com/llms.txt

Use this file to discover all available pages before exploring further.

PayBridgeNP signs every webhook delivery with an HMAC-SHA256 signature. You should always verify this signature before processing an event - otherwise anyone could send fake payment notifications to your endpoint.

Signature format

Every webhook request includes an X-PayBridge-Signature header:
X-PayBridge-Signature: t=1711234567,v1=3b2d4f...
PartDescription
tUnix timestamp of when the webhook was sent
v1HMAC-SHA256 of {timestamp}.{body} using your signing secret

Verifying with the SDK

The easiest way is PayBridge.webhooks.constructEvent() - it handles signature parsing, HMAC comparison, and replay attack protection (rejects events older than 5 minutes).
import express from "express";
import { PayBridge } from "@paybridge-np/sdk";

const app = express();

// Must use raw body - do NOT use express.json() for this route
app.post(
  "/webhooks/paybridge",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const sig = req.headers["x-paybridge-signature"] as string;
    const body = req.body.toString();

    let event;
    try {
      event = await PayBridge.webhooks.constructEvent(
        body,
        sig,
        process.env.PAYBRIDGE_WEBHOOK_SECRET!,
      );
    } catch (err) {
      console.error("Webhook verification failed:", err.message);
      return res.status(400).send(`Webhook error: ${err.message}`);
    }

    switch (event.type) {
      case "payment.succeeded":
        await handleSuccess(event.data);
        break;
      case "payment.failed":
        await handleFailure(event.data);
        break;
    }

    res.json({ received: true });
  },
);

Verifying manually (without the SDK)

If you’re not using the TypeScript SDK:
Python
import hmac
import hashlib
import time

def verify_paybridge_webhook(body: str, signature_header: str, secret: str) -> dict:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    timestamp = parts.get("t")
    v1 = parts.get("v1")

    if not timestamp or not v1:
        raise ValueError("Malformed signature header")

    # Replay attack protection - reject if older than 5 minutes
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError("Timestamp too old")

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.{body}".encode(),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, v1):
        raise ValueError("Signature mismatch")

    import json
    return json.loads(body)
PHP
function verifyPaybridgeWebhook(string $body, string $signatureHeader, string $secret): array {
    $parts = [];
    foreach (explode(',', $signatureHeader) as $part) {
        [$key, $value] = explode('=', $part, 2);
        $parts[$key] = $value;
    }

    if (empty($parts['t']) || empty($parts['v1'])) {
        throw new \Exception('Malformed signature header');
    }

    // Replay attack protection
    if (abs(time() - (int)$parts['t']) > 300) {
        throw new \Exception('Timestamp too old');
    }

    $expected = hash_hmac('sha256', $parts['t'] . '.' . $body, $secret);

    if (!hash_equals($expected, $parts['v1'])) {
        throw new \Exception('Signature mismatch');
    }

    return json_decode($body, true);
}

Important: use the raw request body

The HMAC is computed over the raw request body string. If you parse the JSON first (e.g. with express.json() middleware) and then re-serialize it, the string may differ and signature verification will fail. Always read the body as a string before passing it to constructEvent.

Webhook event payload

{
  "id": "evt_2Je91NlWKuXkdXUJOK9gaHNW",
  "type": "payment.succeeded",
  "created": 1711234567,
  "livemode": true,
  "data": {
    "id": "pay_6f2jHn2I6F6XLZY8AN698Gax",
    "amount": 10000,
    "currency": "NPR",
    "provider": "khalti",
    "provider_ref": "KHALTI-TXN-REF",
    "session_id": "cs_2Je91NlWKuXkdXUJOK9gaHNW",
    "customer_address": {
      "line1": "Thamel Road",
      "line2": null,
      "city": "Kathmandu",
      "state": "Bagmati",
      "postalCode": "44600",
      "country": "Nepal"
    }
  }
}
customer_address is present when the checkout session or payment link had collectAddress: true. It is null otherwise. livemode is true when the event was generated by a live (sk_live_) key and false for sandbox (sk_test_). Branch on it in your handler to avoid processing a sandbox event as a real payment, especially when the same handler URL serves both modes.

Event types

TypeWhen
payment.succeededPayment was verified successfully
payment.failedPayment attempt failed or was declined
payment.refundedA payment was fully refunded
payment_link.paidA customer paid through a payment link

Retry schedule

If your endpoint returns a non-2xx status or times out, PayBridgeNP retries with exponential backoff:
AttemptDelay after previous
130 seconds
25 minutes
330 minutes
42 hours
58 hours
After 5 failed attempts the delivery is marked as permanently failed. You can see all delivery attempts and their response codes in the Webhooks → delivery log in your dashboard.

Testing webhooks locally

Your webhook endpoint must be publicly reachable. For local development, use a tunnel:
# ngrok
ngrok http 3000

# Cloudflare Tunnel (free, no account required for temporary tunnels)
cloudflared tunnel --url http://localhost:3000
Use the public URL as your webhook endpoint in the dashboard during development.

Inspecting payloads in the browser

Want to sanity-check your HMAC implementation without running any server code? Use the Webhook Debugger - paste your signing secret, the raw request body, and the X-PayBridge-Signature header, and it tells you whether the signature would verify and why.