Webhooks

Subscribe to user lifecycle, auth, and billing events — delivered as signed HTTP POSTs with retry and backoff.

Webhooks push Signward events into your backend in real time. Every delivery is signed with your tenant's shared secret (HMAC-SHA256) so you can verify authenticity without trusting the network.

Create a subscription

From the Portal (/Settings/Webhooks) or via API:

POST /api/webhooks
Authorization: Bearer <admin-access-token>
Content-Type: application/json

{
  "url": "https://myapp.com/webhooks/signward",
  "events": ["user.created", "user.login", "invoice.paid"],
  "description": "Prod integration"
}

Response:

{
  "id": "7c9b...",
  "url": "https://myapp.com/webhooks/signward",
  "secret": "whsec_...",
  "events": ["user.created", "user.login", "invoice.paid"],
  "active": true,
  "createdAt": "2026-04-22T10:00:00Z"
}

The secret is shown only at creation. Store it in your backend config — you'll need it to verify signatures.

Delivery shape

POST /webhooks/signward HTTP/1.1
Host: myapp.com
Content-Type: application/json
Signward-Event: user.login
Signward-Delivery-Id: 3f9a12e0-b1c2-4d3e-8f9a-1234567890ab
Signward-Signature: t=1776852416,v1=8e7d6c5b4a39...
User-Agent: Signward-Webhooks/1.0

{
  "event": "user.login",
  "id": "evt_01H...",
  "occurredAt": "2026-04-22T10:02:17Z",
  "tenantId": "dde1872d-6898-4380-ac7f-9f26dedb29a8",
  "data": {
    "userId": "be6d0985-45ea-47a6-8d42-70c708a31cbf",
    "email": "alice@acme.com",
    "ipAddress": "203.0.113.42",
    "userAgent": "Mozilla/5.0 ..."
  }
}

Respond with 2xx within 10 seconds to mark the delivery successful. Any other status, or a timeout, triggers a retry.

Verify the signature

The Signward-Signature header carries t=<unix-timestamp>,v1=<hex-hmac>. Recompute it on your side:

using System.Security.Cryptography;
using System.Text;

bool Verify(string signatureHeader, string rawBody, string secret, int toleranceSeconds = 300)
{
    var parts = signatureHeader.Split(',');
    var t = long.Parse(parts[0].Split('=')[1]);
    var v1 = parts[1].Split('=')[1];

    var ageSeconds = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t);
    if (ageSeconds > toleranceSeconds) return false;   // replay defense

    var payload = $"{t}.{rawBody}";
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant();

    return CryptographicOperations.FixedTimeEquals(
        Encoding.ASCII.GetBytes(v1),
        Encoding.ASCII.GetBytes(expected));
}

Python:

import hmac, hashlib, time

def verify(signature_header: str, raw_body: bytes, secret: str, tolerance_s: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    t = int(parts["t"])
    v1 = parts["v1"]

    if abs(time.time() - t) > tolerance_s:
        return False

    payload = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(v1, expected)

Points that matter:

  • Use the raw request body, not a re-serialized JSON — whitespace and key order matter for HMAC.
  • Constant-time comparison (FixedTimeEquals / compare_digest) defeats timing attacks.
  • Reject old timestamps (5-minute tolerance default) — this blocks replay attacks even if an attacker captures a valid delivery.

Idempotency

The Signward-Delivery-Id header is unique per delivery attempt. If a retry reaches you, the same id (evt_...) in the body is sent, but Signward-Delivery-Id changes. Dedupe on the body id — not on the delivery header.

Retry policy

Failed deliveries are retried with exponential backoff:

  • Attempt 1 — immediate
  • Attempt 2 — 30 seconds later
  • Attempt 3 — 2 minutes
  • Attempt 4 — 10 minutes
  • Attempt 5 — 1 hour
  • Attempt 6 — 6 hours

After 6 failed attempts the delivery is marked failed. The subscription is not auto-disabled; check /api/webhooks/{id}/deliveries periodically for health, or filter on status in the Portal Webhooks log.

Event catalog

Full list in the Reference. Summary:

  • User lifecycleuser.created, user.updated, user.deleted, user.login, user.login_failed, user.password_reset_requested, user.mfa_enabled, user.mfa_disabled
  • Billingsubscription.created, subscription.updated, subscription.canceled, invoice.paid, invoice.payment_failed
  • Tenanttenant.locked, tenant.unlocked, tenant.deletion_requested, tenant.deletion_completed

Testing locally

Expose your localhost via ngrok or Cloudflare Tunnel and point a webhook at the public URL. Portal → Webhooks → Send test event lets you trigger a sample delivery without waiting for a real one.

Next steps