Skip to content

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.

  • webhooks:manage

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.

Terminal window
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.

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:

Terminal window
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"

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 effect

If your queue is slow enough that even enqueuing might take >10s, the problem isn’t your handler — it’s your queue. Replace it.

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.

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:

Terminal window
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.

Terminal window
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.

  • The full event catalog: Webhook events.
  • The retry / backoff math: Webhooks → Retry policy.
  • For replication patterns specifically, subscribe to the matching *.created / *.updated / *.deleted triplet for each entity you mirror.