Webhooks
AEVION delivers signed webhook events for all fintech modules. Each request carries an X-Aevion-Signature and X-Aevion-Timestamp — verify both before processing.
1 · Register your endpoint
Go to QPayNet → Settings → Webhooks and add your HTTPS endpoint URL. You can subscribe to individual event types or all events.
POST https://api.aevion.app/api/qpaynet/me/webhook
{
"url": "https://your-server.com/webhooks/aevion",
"secret": "your-random-32-byte-hex-secret",
"events": ["transfer.completed", "request.paid"]
// or omit events to subscribe to ALL
}2 · Verify the HMAC signature
Every delivery includes X-Aevion-Signature: sha256=<hex> and X-Aevion-Timestamp: <unix-seconds>. The signature is computed over ${timestamp}.${rawBody} using HMAC-SHA256 with your endpoint secret. Reject requests where the timestamp drifts by more than 5 minutes — this prevents replay of old captures.
Node.js / TypeScript (Express)
import crypto from "node:crypto";
import express from "express";
const TOLERANCE_SEC = 300;
const app = express();
app.post(
"/webhooks/aevion",
express.raw({ type: "*/*" }), // must receive raw body
(req, res) => {
const sigHeader = (req.headers["x-aevion-signature"] as string) || "";
const ts = Number(req.headers["x-aevion-timestamp"] || 0);
const raw = req.body as Buffer;
if (!ts || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SEC) {
return res.status(401).send("timestamp out of tolerance");
}
const expected = crypto
.createHmac("sha256", process.env.AEVION_WEBHOOK_SECRET!)
.update(`${ts}.${raw.toString("utf8")}`)
.digest("hex");
const provided = sigHeader.replace(/^sha256=/, "");
if (provided.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(raw.toString("utf8"));
// event.id → idempotency key (deduplicate retries)
// event.type → "transfer.completed" | "request.paid" | ...
// event.module → "qpaynet" | "qgood" | "qmaskcard" | ...
// event.sentAt → ISO timestamp
// event.data → module-specific payload
res.status(200).end(); // ack quickly; do heavy work async
}
);Python (Flask)
import hmac, hashlib, os, time
from flask import Flask, request, abort
TOLERANCE_SEC = 300
SECRET = os.environ["AEVION_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/webhooks/aevion")
def receive():
raw = request.get_data() # bytes — DO NOT use request.json before verify
sig = (request.headers.get("X-Aevion-Signature") or "").removeprefix("sha256=")
ts = int(request.headers.get("X-Aevion-Timestamp") or 0)
if not ts or abs(time.time() - ts) > TOLERANCE_SEC:
abort(401, "timestamp out of tolerance")
payload = f"{ts}.".encode() + raw
expected = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401, "bad signature")
event = request.get_json(force=True)
# event["id"], event["type"], event["module"], event["data"]
return "", 200Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const toleranceSec = 300
func handler(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
sig := strings.TrimPrefix(r.Header.Get("X-Aevion-Signature"), "sha256=")
ts, _ := strconv.ParseInt(r.Header.Get("X-Aevion-Timestamp"), 10, 64)
skew := time.Now().Unix() - ts
if skew < 0 { skew = -skew }
if ts == 0 || skew > toleranceSec {
http.Error(w, "timestamp out of tolerance", 401); return
}
payload := strconv.FormatInt(ts, 10) + "." + string(raw)
mac := hmac.New(sha256.New, []byte(os.Getenv("AEVION_WEBHOOK_SECRET")))
mac.Write([]byte(payload))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) {
http.Error(w, "bad signature", 401); return
}
// parse + handle event …
w.WriteHeader(200)
}3 · Event payload shape
{
"id": "evt_01JXYZ...", // unique event ID — use for deduplication
"type": "transfer.completed",
"module": "qpaynet",
"sentAt": "2026-05-12T09:41:00Z",
"data": {
"txId": "uuid",
"fromWallet": "uuid",
"toWallet": "uuid",
"amountKzt": 5000,
"feeKzt": 5,
"description": "Order #123 payment"
}
}4 · Idempotency & deduplication
Retries arrive with the same event.id. Store it on receipt and reject duplicates — that keeps your handler crash-safe under network blips:
-- Postgres dedup table CREATE TABLE webhook_seen ( event_id TEXT PRIMARY KEY, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- On handler entry, before doing real work: INSERT INTO webhook_seen (event_id) VALUES ($1) ON CONFLICT (event_id) DO NOTHING RETURNING 1; -- returns 0 rows on dup → ack 200 and skip
QMaskCard charge.authorized events expose the same partial-unique-index idempotency on (maskId, paymentRef) — a replay returns idempotent:true with the original charge id.
5 · Retry policy
AEVION retries failed deliveries with exponential backoff:
- Attempt 1 — immediately
- Attempt 2 — 5 minutes later
- Attempt 3 — 30 minutes later
- Attempt 4 — 2 hours later
- Attempt 5 — 8 hours later (final)
A delivery is failed if your endpoint returns non-2xx or times out (10s). After 5 failures the event is marked dead_letter. Manually retry from the Webhooks dashboard.
6 · Secret rotation
Rotate without downtime by accepting two secrets at once during a transition window. Run both for 24-48h, then drop the old one:
// Receiver: try the new secret first, fall back to old during rotation
const secrets = [
process.env.AEVION_WEBHOOK_SECRET_NEW!, // current
process.env.AEVION_WEBHOOK_SECRET_OLD, // accepted until cutover (optional)
].filter(Boolean) as string[];
const provided = sigHeader.replace(/^sha256=/, "");
const payload = `${ts}.${raw.toString("utf8")}`;
const matchedIndex = secrets.findIndex((secret) => {
const expected = crypto.createHmac("sha256", secret).update(payload).digest("hex");
return provided.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
});
if (matchedIndex === -1) return res.status(401).send("bad signature");
if (matchedIndex > 0) {
// log: still seeing old secret in flight — finish migration soon
console.warn("[webhook] verified with rotated secret index", matchedIndex);
}Recommended cutover:
- Generate a new 32-byte random secret on your side.
- POST
/api/qpaynet/me/webhookwith{"secret": "<new>"}— AEVION starts signing with new immediately. - Deploy receiver code that accepts both new + old secrets.
- Wait 24h — verify logs show no
secretIndex > 0hits. - Remove
AEVION_WEBHOOK_SECRET_OLDand redeploy.
7 · Local testing
Use ngrok (or any HTTPS tunnel) to receive on your laptop. To generate a fixture signature for unit tests:
// Mirrors AEVION's signer exactly
import crypto from "node:crypto";
function signFixture(body: object, secret: string) {
const ts = Math.floor(Date.now() / 1000);
const payload = `${ts}.${JSON.stringify(body)}`;
const sig = crypto.createHmac("sha256", secret).update(payload).digest("hex");
return {
"X-Aevion-Signature": `sha256=${sig}`,
"X-Aevion-Timestamp": String(ts),
};
}Event catalogue
| Event | Module | Description |
|---|---|---|
| transfer.completed | qpaynet | P2P transfer settled successfully |
| transfer.failed | qpaynet | Transfer failed (insufficient balance etc.) |
| request.paid | qpaynet | Payment request fulfilled by payer |
| request.expired | qpaynet | Payment request expired (time or max views) |
| deposit.completed | qpaynet | Top-up credited to wallet |
| merchant.charge.success | qpaynet | Merchant charge via API key succeeded |
| donation.received | qgood | Donation credited to campaign |
| campaign.goal_reached | qgood | Campaign hit its target amount |
| mask.created | qmaskcard | New virtual mask issued |
| charge.authorized | qmaskcard | Mask charge authorized (idempotent) |
| charge.declined | qmaskcard | Mask charge declined (limit/lock/fraud) |
| score.updated | ztide | User's reputation score changed |
| rank.promoted | ztide | User moved to a higher rank tier |
| proposal.passed | qchaingov | Governance proposal reached quorum and passed |
| proposal.rejected | qchaingov | Proposal failed to reach quorum or majority |
| ledger.entry_added | veilnetx | New settlement entry appended to ledger |
| ledger.chain_verified | veilnetx | Periodic chain integrity audit completed |