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

# Verify signatures

# Verify webhook signatures

We sign every webhook with:

```
X-Webhook-Signature: t=<unix_ts>,v1=<base64sig>
```

* **Signature** = a tamper-proof stamp made with **your secret**. If the body or timestamp changes, the stamp won’t match.
* **t (timestamp)** = when we created the event (seconds since epoch).
* **v1** = the signature itself (base64 string).

Algorithm (v1): `base64(HMAC_SHA256(secret, "${ts}.${raw_body}"))` with a ±5 minute tolerance.

> We sign the **exact raw bytes** you receive (no reformatting). Verify against the raw body, not a re-serialized JSON object.

***

## What to validate (in order)

1. **Read raw body**\
   Use the raw bytes from the request. Don’t JSON stringify/pretty-print before verifying.

2. **Parse the header**\
   Expect `t=<unix_ts>,v1=<base64sig>`.

3. **Freshness (clock skew)**\
   Make sure the request is recent: `abs(now - t) ≤ 300s (5 min)`.\
   *Why:* prevents old/replayed requests from being accepted.

4. **Recompute the signature**\
   Rebuild it yourself with your secret:\
   `sig = base64(HMAC_SHA256(secret, ts + "." + raw_body))`.

5. **Secure compare**\
   Compare your `sig` with the header’s `v1` using a **constant-time** function (a “secure equals”).\
   *Why:* avoids tiny timing differences that could leak info.

6. **Replay protection (nice to have)**\
   Keep a short-lived cache of `X-Webhook-Id` (or the `(t,v1)` pair). If you see the same one again within \~10 minutes, **reject it**.\
   *Why:* blocks attackers from re-sending a previously valid request.

7. **Then parse JSON & ack fast**\
   s   Once verified, parse JSON and return **2xx** quickly. Do heavy work async; we retry on non-2xx.

**You’ll also get headers**

* `X-Webhook-Event`: e.g. `order.submitted`, `order.pending`, `order.settled`, `order.failed`, `order.refunded`
* `X-Webhook-Id`: unique request id (use for replay protection)
* `X-Webhook-Signature`: the header above

**Headers you’ll get**

* `X-Webhook-Event`: e.g. `order.submitted`, `order.processing`, `order.settled`, `order.failed`, `order.refunded`
* `X-Webhook-Id`: unique id for idempotency
* `X-Webhook-Signature`: the signature header above

***

## Minimal receivers (drop-in)

### Node (Express)

```js theme={null}
import express from "express";
import crypto from "crypto";

const app = express();
// capture raw body
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; }
}));

function verifySignature(header, rawBody, secret) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map(x => x.trim().split("=")));
  const ts = parseInt(parts.t, 10);
  if (!ts || Math.abs(Date.now()/1000 - ts) > 300) return false;
  const mac = crypto.createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("base64");
  const v1 = parts.v1 || "";
  return crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(v1));
}

app.post("/webhooks/elementpay", (req, res) => {
  const ok = verifySignature(req.header("X-Webhook-Signature"), req.rawBody, process.env.WEBHOOK_SECRET);
  if (!ok) return res.status(401).json({ status: "error", message: "Invalid webhook signature", data: null });

  const event = req.header("X-Webhook-Event");
  const id = req.header("X-Webhook-Id");
  const payload = req.body;

  // TODO: check replay via cache on `id`
  // TODO: enqueue payload for async processing

  return res.status(200).json({ status: "success", message: "ok" });
});

app.listen(3000);
```

### Python (FastAPI)

```python theme={null}
import time, hmac, hashlib, base64
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

def verify_signature(header: str, raw: bytes, secret: str) -> bool:
  if not header: return False
  parts = dict(p.split("=",1) for p in header.split(",") if "=" in p)
  try:
    ts = int(parts.get("t","0"))
  except ValueError:
    return False
  if abs(int(time.time()) - ts) > 300:
    return False
  mac = hmac.new(secret.encode("utf-8"), f"{ts}.".encode("utf-8") + raw, hashlib.sha256).digest()
  sig = base64.b64encode(mac).decode("utf-8")
  return hmac.compare_digest(sig, parts.get("v1",""))

@app.post("/webhooks/elementpay")
async def handle(request: Request):
  raw = await request.body()
  header = request.headers.get("X-Webhook-Signature")
  if not verify_signature(header, raw, secret=YOUR_SECRET):
    raise HTTPException(status_code=401, detail="Invalid webhook signature")

  event = request.headers.get("X-Webhook-Event")
  req_id = request.headers.get("X-Webhook-Id")
  payload = await request.json()

  # TODO: reject replays using req_id cache
  # TODO: enqueue payload for async processing

  return {"status": "success", "message": "ok"}
```

***

## Sample payload (event body)

```json theme={null}
{
  "order_id": "ord_01J9TS1Q8ZQ7M3E6W9F3Z3YB2G",
  "invoice_id": "inv_123",
  "file_id": "file_456",
  "status": "settled",
  "reason": null,
  "amount_fiat": 1750,
  "currency": "KES",
  "amount_crypto": "12.34",
  "exchange_rate": 142.1234,
  "token": "USDC",
  "wallet_address": "0xA1b2...c3D4",
  "order_type": "OFFRAMP",
  "provider_id": "mpesa",
  "phone_number": "+2547XXXXXXX",
  "till_number": null,
  "paybill_number": null,
  "account_number": null,
  "creation_transaction_hash": "0xabc123...",
  "settlement_transaction_hash": "0xdef456...",
  "refund_transaction_hash": null,
  "transaction_hash": "0xdef456...",
  "mpesa_transaction_id": "OE123...",
  "mpesa_receipt_number": "QWE123...",
  "receiver_name": "John Doe",
  "transaction_time": "2025-08-15T12:34:56Z",
  "created_at": "2025-08-15T12:00:00Z",
  "updated_at": "2025-08-15T12:35:00Z"
}
```

***

## Local testing

* **Best:** use your own signer to hit your local receiver.
  * Node: compute `v1` with `crypto.createHmac("sha256", secret).update(`${ts}.${body}`).digest("base64")`.
  * Python: compute `v1` with `base64(hmac_sha256(secret, f"{ts}.{raw_body}"))`.
* Or call our **`POST /webhooks/test`** to validate your signature logic (no secrets in the docs UI).

***

## Common errors

| Error                                          | Why                              | Fix                                                                   |
| ---------------------------------------------- | -------------------------------- | --------------------------------------------------------------------- |
| `Invalid webhook signature`                    | Computed signature doesn’t match | Use **raw bytes**; ensure `ts+"."+raw_body` and base64 of HMAC-SHA256 |
| `Signature timestamp outside tolerance window` | Clock skew > 5 min               | Sync server time; regenerate `t`                                      |
| `Malformed signature header`                   | Missing `t` or `v1`              | Format: `t=<unix_ts>,v1=<base64sig>`                                  |
| Works locally but fails in prod                | Body re-serialized               | Don’t JSON.stringify()/re-encode before verifying                     |
