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 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)
-
Read raw body
Use the raw bytes from the request. Don’t JSON stringify/pretty-print before verifying.
-
Parse the header
Expect t=<unix_ts>,v1=<base64sig>.
-
Freshness (clock skew)
Make sure the request is recent: abs(now - t) ≤ 300s (5 min).
Why: prevents old/replayed requests from being accepted.
-
Recompute the signature
Rebuild it yourself with your secret:
sig = base64(HMAC_SHA256(secret, ts + "." + raw_body)).
-
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.
-
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.
-
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)
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)
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)
{
"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.).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 |