> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vestrapay.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive payment.completed on your server, check the signature, then verify.

Register a HTTPS endpoint in the dashboard (**Settings → Developers → Webhooks**), or create one with a merchant session. We POST JSON when a payment finishes, fails, or is abandoned.

Up to two endpoints per environment. The signing secret is shown once at create time.

## Body

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "event": "payment.completed",
  "data": {
    "reference": "VPY_TXN_ACME001",
    "amount": "100.00",
    "currency": "NGN",
    "status": "success",
    "channel": "bank_transfer",
    "paidAt": "2026-05-10T14:30:00.000Z",
    "fees": "1.00"
  },
  "timestamp": "2026-05-10T14:30:01.000Z"
}
```

Headers:

| Header                  | Value                                              |
| ----------------------- | -------------------------------------------------- |
| `X-VestraPay-Event`     | Same as `event` in the body                        |
| `X-VestraPay-Signature` | `sha256=` plus hex HMAC-SHA256 of the **raw body** |
| `Content-Type`          | `application/json`                                 |

## Events

| Event               | When                                 |
| ------------------- | ------------------------------------ |
| `payment.completed` | Customer paid. Fulfill after verify. |
| `payment.failed`    | Charge or transfer failed.           |
| `payment.abandoned` | Customer left the session.           |

## Verify the signature

Use the webhook secret from the dashboard. HMAC-SHA256 the **exact raw bytes** of the request body (before JSON parse), hex-encode, prefix `sha256=`, and compare to `X-VestraPay-Signature` with a constant-time equals.

<CodeGroup>
  ```javascript Node theme={"theme":{"light":"github-light","dark":"github-dark"}}
  import crypto from "node:crypto";

  function verifyVestrapaySignature(rawBody, signatureHeader, secret) {
    const expected =
      "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
    const a = Buffer.from(signatureHeader ?? "", "utf8");
    const b = Buffer.from(expected, "utf8");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  // Express: app.use("/webhooks/vestrapay", express.raw({ type: "application/json" }))
  app.post("/webhooks/vestrapay", (req, res) => {
    const rawBody = req.body; // Buffer
    if (!verifyVestrapaySignature(rawBody, req.get("X-VestraPay-Signature"), process.env.VESTRAPAY_WEBHOOK_SECRET)) {
      return res.status(401).end();
    }
    const payload = JSON.parse(rawBody.toString("utf8"));
    res.status(200).end();
    // enqueue: verify payload.data.reference, then fulfill once
  });
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}}
  import hashlib, hmac, os
  from flask import Flask, request

  app = Flask(__name__)

  def verify_signature(raw: bytes, header: str | None, secret: str) -> bool:
      expected = "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
      return hmac.compare_digest(header or "", expected)

  @app.post("/webhooks/vestrapay")
  def webhook():
      raw = request.get_data()
      if not verify_signature(raw, request.headers.get("X-VestraPay-Signature"), os.environ["VESTRAPAY_WEBHOOK_SECRET"]):
          return ("", 401)
      payload = request.get_json(force=True)
      return ("", 200)
  ```

  ```php PHP theme={"theme":{"light":"github-light","dark":"github-dark"}}
  $raw = file_get_contents("php://input");
  $header = $_SERVER["HTTP_X_VESTRAPAY_SIGNATURE"] ?? "";
  $expected = "sha256=" . hash_hmac("sha256", $raw, getenv("VESTRAPAY_WEBHOOK_SECRET"));
  if (!hash_equals($expected, $header)) {
    http_response_code(401);
    exit;
  }
  $payload = json_decode($raw, true);
  http_response_code(200);
  ```
</CodeGroup>

If you parse JSON first and re-stringify, whitespace will not match and the signature will fail. Disable body parsers on this route, or keep a copy of the raw buffer.

## Respond quickly

Return HTTP 2xx once the signature is valid and the event is queued. Timeout is 10 seconds. Failed deliveries retry up to five times with exponential backoff from 5 seconds.

After `payment.completed`, [verify](/payments/verify) and fulfill once per `data.reference`.

HTTPS only. Localhost and link-local addresses are rejected.
