AEVIONTrust · IP · Globus
DemoAuthQRightQSignBureauPlanetAwardsBankChessPricingAPI
← Fintech Docs

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 "", 200

Go (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:

  1. Generate a new 32-byte random secret on your side.
  2. POST /api/qpaynet/me/webhook with {"secret": "<new>"} — AEVION starts signing with new immediately.
  3. Deploy receiver code that accepts both new + old secrets.
  4. Wait 24h — verify logs show no secretIndex > 0 hits.
  5. Remove AEVION_WEBHOOK_SECRET_OLD and 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

EventModuleDescription
transfer.completedqpaynetP2P transfer settled successfully
transfer.failedqpaynetTransfer failed (insufficient balance etc.)
request.paidqpaynetPayment request fulfilled by payer
request.expiredqpaynetPayment request expired (time or max views)
deposit.completedqpaynetTop-up credited to wallet
merchant.charge.successqpaynetMerchant charge via API key succeeded
donation.receivedqgoodDonation credited to campaign
campaign.goal_reachedqgoodCampaign hit its target amount
mask.createdqmaskcardNew virtual mask issued
charge.authorizedqmaskcardMask charge authorized (idempotent)
charge.declinedqmaskcardMask charge declined (limit/lock/fraud)
score.updatedztideUser's reputation score changed
rank.promotedztideUser moved to a higher rank tier
proposal.passedqchaingovGovernance proposal reached quorum and passed
proposal.rejectedqchaingovProposal failed to reach quorum or majority
ledger.entry_addedveilnetxNew settlement entry appended to ledger
ledger.chain_verifiedveilnetxPeriodic chain integrity audit completed
SDK reference →Error codes →Troubleshooting →Manage webhooks →