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 lifecycle —
user.created,user.updated,user.deleted,user.login,user.login_failed,user.password_reset_requested,user.mfa_enabled,user.mfa_disabled - Billing —
subscription.created,subscription.updated,subscription.canceled,invoice.paid,invoice.payment_failed - Tenant —
tenant.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
- Reference — webhook events — the full catalog with schemas
- Protect a Web API — secure the handler endpoint itself