Listen for events
Polling is wasteful — when a tenant doesn’t change, every minute of
polling is a minute of wasted budget and a minute of latency before you
notice. Webhooks invert that: the platform pushes when something
changes, your endpoint reacts. This recipe walks the full subscription
loop, from registration to a verified handler returning 200 inside the
delivery timeout.
The reference for catalog and delivery shape is Webhooks and Webhook events.
Required scopes
Section titled “Required scopes”webhooks:manage
1. Stand up an HTTPS endpoint
Section titled “1. Stand up an HTTPS endpoint”Pick the URL you’ll receive deliveries on. Two requirements:
- HTTPS only. Plain HTTP is rejected at registration.
- Reachable from the platform’s outbound IP range. If your endpoint sits behind a VPN, expose it via a public webhook gateway.
For a local prototype, ngrok or Cloudflare Tunnel gives you a public HTTPS URL that proxies to a process on your laptop.
2. Register the endpoint
Section titled “2. Register the endpoint”curl -sS -X POST \ -H "X-Api-Key: $AURA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "warehouse-replicator", "url": "https://hooks.example.com/aura", "eventTypes": ["contact.created","contact.updated","order.created","order_payment.created"], "description": "Replicates contacts and donations into our warehouse." }' \ "https://api.auradonors.com/api/tenants/$TENANT_ID/webhooks"The response carries the plaintext signing secret exactly once:
{ "id": "8a4f1c7b-3d6e-4f12-9a01-8b8d2c3e4f56", "name": "warehouse-replicator", "url": "https://hooks.example.com/aura", "eventTypes": ["contact.created","contact.updated","order.created","order_payment.created"], "secret": "whsec_..."}Store secret in your secret manager before you do anything else.
The server stores only its hash; subsequent reads will not return it.
3. Verify the signature on every delivery
Section titled “3. Verify the signature on every delivery”The platform signs each delivery with HMAC-SHA256 over
{X-Webhook-Timestamp}.{request-body}. Read the raw body bytes before
any framework JSON middleware mutates them — the signature is over the
exact wire bytes.
using System.Security.Cryptography;using System.Text;
public sealed class WebhookHandler{ private static readonly string Secret = Environment.GetEnvironmentVariable("AURA_WEBHOOK_SECRET")!;
public static async Task<IResult> Handle(HttpContext ctx) { // 1. Read raw body — must match the signed bytes exactly. ctx.Request.EnableBuffering(); using var reader = new StreamReader(ctx.Request.Body, Encoding.UTF8, leaveOpen: true); var body = await reader.ReadToEndAsync(); ctx.Request.Body.Position = 0;
// 2. Pull the headers. var sig = ctx.Request.Headers["X-Webhook-Signature"].FirstOrDefault(); var timestamp = ctx.Request.Headers["X-Webhook-Timestamp"].FirstOrDefault(); var deliveryId = ctx.Request.Headers["X-Webhook-Delivery-Id"].FirstOrDefault();
if (sig is null || timestamp is null || deliveryId is null) return Results.BadRequest();
// 3. Verify replay window (your choice — 5 minutes is a reasonable default). if (!long.TryParse(timestamp, out var unix) || DateTimeOffset.FromUnixTimeSeconds(unix) < DateTimeOffset.UtcNow.AddMinutes(-5)) return Results.Unauthorized();
// 4. Recompute and compare. var expected = sig.StartsWith("sha256=") ? sig["sha256=".Length..] : sig; var message = $"{timestamp}.{body}"; var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(Secret), Encoding.UTF8.GetBytes(message)); var actual = Convert.ToHexStringLower(hash);
if (!CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(actual), Encoding.UTF8.GetBytes(expected))) return Results.Unauthorized();
// 5. Idempotency — bail if we've already processed this delivery. if (await DeliveryAlreadyProcessed(deliveryId)) return Results.Ok();
// 6. Queue side-effect work; respond inside the 10s budget. await EnqueueAsync(body, deliveryId); return Results.Ok(); }}The same flow in bash + openssl, for smoke-testing:
TIMESTAMP="$(headers | get X-Webhook-Timestamp)"SIG="$(headers | get X-Webhook-Signature | cut -d= -f2)"EXPECTED=$(printf '%s.%s' "$TIMESTAMP" "$BODY" \ | openssl dgst -sha256 -hmac "$SECRET" -hex \ | awk '{print $2}')
[ "$SIG" = "$EXPECTED" ] && echo "valid" || echo "REJECT"4. Stay inside the 10-second timeout
Section titled “4. Stay inside the 10-second timeout”Each delivery times out after 10 seconds on the platform side. Your
handler should return 200 as soon as the delivery is durably
queued for processing — not after the side effect completes. If the
side effect runs too long inside the handler, the delivery times out, is
retried, and you double-process.
The shape that works:
receive → verify signature → durably queue (Postgres / SQS / Redis Streams) → return 200 ↓ background worker runs the side effectIf your queue is slow enough that even enqueuing might take >10s, the problem isn’t your handler — it’s your queue. Replace it.
5. Handle replay safely
Section titled “5. Handle replay safely”Network partitions, transient failures, and timeouts all cause the platform to retry. Two-attempts-on-the-same-event is the rule, not the exception.
X-Webhook-Delivery-Id is unique per attempt — but your worker
needs idempotency on the event id (the id field in the body) to
avoid double-applying the same change:
// In the worker:if (await EventAlreadyApplied(payload.Id)) return; // skip; we've already replicated this one.
await ApplyAsync(payload);await MarkEventApplied(payload.Id);The combination of “did we already store this event id?” plus a constant-time signature compare means a malicious replay (signed bytes copied from a real delivery) gets recorded once and ignored thereafter.
6. Watch delivery health
Section titled “6. Watch delivery health”If your endpoint is briefly down, deliveries retry up to five times total with exponential backoff (see Webhooks → Retry policy). Beyond five failures the delivery is abandoned.
Inspect the recent delivery history for an endpoint:
curl -sS \ -H "X-Api-Key: $AURA_API_KEY" \ "https://api.auradonors.com/api/tenants/$TENANT_ID/webhooks/$WEBHOOK_ID/deliveries?page=1&pageSize=50"The response includes per-attempt status code, attempt number, and sanitized error text. Wire this to your monitoring — a sudden burst of non-2xx responses is an early warning that your handler regressed.
7. Test before turning it loose
Section titled “7. Test before turning it loose”curl -sS -X POST \ -H "X-Api-Key: $AURA_API_KEY" \ "https://api.auradonors.com/api/tenants/$TENANT_ID/webhooks/$WEBHOOK_ID/test"Fires a synthetic event so you can validate the full receive → verify → enqueue → respond loop without waiting for a real tenant event.
What’s next
Section titled “What’s next”- The full event catalog: Webhook events.
- The retry / backoff math: Webhooks → Retry policy.
- For replication patterns specifically, subscribe to the matching
*.created/*.updated/*.deletedtriplet for each entity you mirror.