INOPAY
Back to developers

HMAC SHA-256 webhooks

Inopay emits HMAC SHA-256 signed webhooks to notify you of key events. All webhooks are retried with exponential backoff up to 24 h, then sent to the dead letter queue.

Available events

Events emitted by Inopay. You pick the subscriptions when registering an endpoint via POST /v1/webhooks/endpoints.

EventDescription
order.createdOrder validated by Inopay (before SGI transmission).
order.routedOrder forwarded to the partner SGI.
order.executedOrder fully or partially executed on exchange.
order.failedExecution failure (SGI rejection, insufficient funds, suspended instrument).
kyc.attestedNew KYC attestation issued and signed.
kyc.revokedKYC attestation revoked (added to the CRL).
audit.snapshotDaily Merkle snapshot published and anchored.
webhook.testTest event manually triggered from the dashboard.

Payload format

All payloads share the same envelope: id, type, created_at, data, livemode. Sample for order.executed:

POST https://your-app.example.com/webhooks/inopay HTTP/1.1
Content-Type: application/json
X-Inopay-Signature: t=1764082380,v1=4f5a9b2c8e1d3f6a7b9c0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b
X-Inopay-Idempotency-Key: 6c4b0b6e-1c2a-4d09-9b3a-7d1f4e5a6c2d
User-Agent: Inopay-Webhooks/1.0

{
  "id":         "evt_2A9p3X7q",
  "type":       "order.executed",
  "created_at": "2026-04-25T14:32:14.001Z",
  "livemode":   true,
  "data": {
    "order_id":         "ord_9Pk2X",
    "rcpt_to":          "sgi_partner_001",
    "instrument":       "SNTS.BRVM",
    "side":             "buy",
    "filled_qty":       10,
    "average_fill_price_cents": 1248750,
    "executed_at":      "2026-04-25T14:32:13.880Z",
    "exchange_ref":     "BRVM-2026-04-25-XK4287"
  }
}

HMAC SHA-256 signature

Header X-Inopay-Signature: t=<timestamp>,v1=<hex>. Concatenate timestamp + '.' + raw_body, compute HMAC SHA-256 with your webhook secret, then compare in constant time.

Node.js
// Verify Inopay webhook signature (Node.js >= 20)
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyInopaySignature(
  rawBody: string,
  header: string,
  secret: string,
  toleranceSeconds = 300,
): boolean {
  // Header format: "t=<unix_ts>,v1=<hex>"
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=') as [string, string]),
  );
  const ts = Number(parts.t);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false;

  const expected = createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}
Python
# Verify Inopay webhook signature (Python 3.10+)
import hmac, hashlib, time

def verify_inopay_signature(
    raw_body: bytes,
    header: str,
    secret: str,
    tolerance_seconds: int = 300,
) -> bool:
    # Header format: "t=<unix_ts>,v1=<hex>"
    parts = dict(p.split("=", 1) for p in header.split(","))
    try:
        ts = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - ts) > tolerance_seconds:
        return False

    signed = f"{parts['t']}.{raw_body.decode()}".encode()
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
Go
// Verify Inopay webhook signature (Go 1.21+)
package webhooks

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strconv"
    "strings"
    "time"
)

func VerifyInopaySignature(rawBody []byte, header, secret string, tolerance time.Duration) bool {
    parts := map[string]string{}
    for _, kv := range strings.Split(header, ",") {
        if idx := strings.IndexByte(kv, '='); idx > 0 {
            parts[kv[:idx]] = kv[idx+1:]
        }
    }
    ts, err := strconv.ParseInt(parts["t"], 10, 64)
    if err != nil { return false }
    if d := time.Since(time.Unix(ts, 0)); d > tolerance || d < -tolerance {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(parts["t"] + "." + string(rawBody)))
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}

Retry policy

3 automatic retries: immediate, +30 s, +5 min. Beyond: new attempt at +30 min, +2 h, +6 h, +24 h. After 24 h with no 2xx: forwarded to the dead letter queue accessible from the dashboard.

Receiver-side idempotence

Inopay may resend the same event multiple times (network, retry). Store the payload id on your side and check before re-processing. The X-Inopay-Idempotency-Key header is also present.

Test mode

Manually trigger a test event from the dashboard or via the CLI:

# Trigger a test event from the dashboard or CLI
inopay-cli webhook test \
  --endpoint=ep_4Xk9R \
  --event=order.created \
  --livemode=false

# Or from the dashboard:
# Settings → Webhooks → endpoint → "Send test event"