Webhooks
When something interesting happens on a tenant — a donation lands, a recurring schedule pauses, a shipment goes out — the platform can POST a JSON event to an HTTPS endpoint you operate. This page describes the delivery model end-to-end. The full event catalog lives at Webhooks → Events.
Prerequisites
Section titled “Prerequisites”The tenant needs the webhooks:enabled feature flag turned on. Without
it, the delivery service skips the tenant entirely — registered endpoints
still exist in the configuration, but no events fire.
The API key that registers and manages endpoints needs the
webhooks:manage scope.
Register an endpoint
Section titled “Register an endpoint”curl -sS -X POST \ -H "X-Api-Key: $AURA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "production-replication", "url": "https://hooks.example.com/aura", "eventTypes": ["contact.created","order.created","order_payment.created"], "description": "Replicates contact + donation events into our warehouse." }' \ "https://api.auradonors.com/api/tenants/$TENANT_ID/webhooks"The response is 201 Created and carries the plaintext signing secret
exactly once:
{ "id": "8a4f1c7b-3d6e-4f12-9a01-8b8d2c3e4f56", "name": "production-replication", "url": "https://hooks.example.com/aura", "eventTypes": ["contact.created", "order.created", "order_payment.created"], "secret": "whsec_..."}Store secret in your secret manager immediately. The configuration GET
endpoints will not return it again. To rotate the secret, register a new
endpoint and revoke the old one — the existing endpoint’s secret is not
re-issuable in place.
Delivery mechanics
Section titled “Delivery mechanics”A background service ticks every 30 seconds. On each tick, for each active endpoint:
- Enqueue. Up to 500 new audit-log events since the endpoint’s last
processed timestamp are matched against the endpoint’s
eventTypesfilter and turned into pendingwebhook_deliveriesrows. - Deliver. Up to 100 pending deliveries (across all endpoints) are
POSTed with a 10-second timeout. Successful deliveries are marked
success = true; failures are flagged for retry.
Every delivery POST carries:
| Header | Meaning |
|---|---|
Content-Type | Always application/json. |
X-Webhook-Event | The event type (e.g., order.created). |
X-Webhook-Delivery-Id | Unique Guid per attempt. Use this for idempotent receivers. |
X-Webhook-Timestamp | Unix epoch seconds when the request was signed. Used in the signature — required to recompute. |
X-Webhook-Signature | sha256={hex-hmac} — see Signature verification. |
Body shape:
{ "id": "9c4a7b2d-...", "event": "order.created", "timestamp": "2026-05-06T13:42:01.1234567Z", "data": { "entity_type": "Order", "entity_id": "00000000-0000-0000-0000-000000000123", "action": "Created", "changes": null, "user_id": "api-key:7c..." }}The body’s data block is the audit-log row that produced the event. The
changes field is non-null on updated events and contains a JSON
description of which columns changed.
To resolve the entity itself, follow up with the matching read endpoint
(GET /api/tenants/{tid}/orders/{entity_id} for an Order, etc.). The
delivery does not embed the full entity — that keeps the wire payload
small and means receivers always see a fresh view.
Retry policy
Section titled “Retry policy”A delivery is retried up to 5 attempts. The retry delay is exponential on the attempt number:
| Attempt # | Delay before next try | Cumulative wait at next attempt |
|---|---|---|
| 1 → 2 | 1 minute | ~1 minute |
| 2 → 3 | 2 minutes | ~3 minutes |
| 3 → 4 | 4 minutes | ~7 minutes |
| 4 → 5 | 8 minutes | ~15 minutes |
Anything 2xx is treated as success. Anything else (including 4xx — the
delivery is a server-driven push, so 4xx isn’t terminal) plus network
errors and timeouts are treated as failures and queued for retry. After
the fifth failed attempt the delivery is abandoned. There is no manual
re-delivery API today — fix the receiver and the next event flows; for a
backfill, contact support with the affected entity_ids.
You can inspect every delivery (success or fail) 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=20"Each row carries the attempted timestamp, the HTTP status code, the attempt number, and a sanitized error message when the call failed.
Signature verification
Section titled “Signature verification”Every delivery is signed with the endpoint’s secret using HMAC-SHA256.
The signed message is {timestamp}.{body} (not just the body) — this
prevents replay of an old payload against a newer timestamp.
Recompute and compare:
public static bool IsValidSignature(string body, string timestamp, string headerValue, string secret){ // headerValue arrives as "sha256={hex}" var expected = headerValue.StartsWith("sha256=") ? headerValue["sha256=".Length..] : headerValue;
var message = $"{timestamp}.{body}"; var keyBytes = Encoding.UTF8.GetBytes(secret); var msgBytes = Encoding.UTF8.GetBytes(message); var hash = HMACSHA256.HashData(keyBytes, msgBytes); var actual = Convert.ToHexStringLower(hash);
// Constant-time compare to prevent timing attacks. return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(actual), Encoding.UTF8.GetBytes(expected));}# Recreate the signature with bash + openssl as a smoke test.TIMESTAMP="$(curl -sI ... | grep -i x-webhook-timestamp | awk '{print $2}' | tr -d '\r')"BODY="$(cat received-body.json)"SECRET="whsec_..."EXPECTED=$(printf '%s.%s' "$TIMESTAMP" "$BODY" \ | openssl dgst -sha256 -hmac "$SECRET" -hex \ | awk '{print $2}')echo "$EXPECTED" # compare to the X-Webhook-Signature header value (after the sha256= prefix)Reject any delivery where:
- The
X-Webhook-Signatureheader is missing. - The recomputed HMAC doesn’t match (constant-time compare).
- The
X-Webhook-Timestampis more than a few minutes off your wall clock (replay protection — the platform doesn’t enforce this on its side, but you should).
Receiving deliveries
Section titled “Receiving deliveries”Best-practice handler shape:
- Read the body as raw bytes / raw string before any framework JSON middleware mutates it. The signature is over the exact bytes sent.
- Verify the signature.
- Check
X-Webhook-Delivery-Idagainst your “already processed” cache; if seen, return200immediately without re-running side effects. - Run your side effect.
- Return
200 OKwithin ten seconds (the delivery service’s per-attempt timeout). If you can’t, queue the work to a background processor and return200immediately — long-running handlers risk timing out and getting retried.
Test deliveries
Section titled “Test deliveries”To verify connectivity without waiting for a real event:
curl -sS -X POST \ -H "X-Api-Key: $AURA_API_KEY" \ "https://api.auradonors.com/api/tenants/$TENANT_ID/webhooks/$WEBHOOK_ID/test"The platform fires a synthetic event against the endpoint’s URL and returns the delivery row so you can inspect status code and timing.
Inbound webhooks (separate)
Section titled “Inbound webhooks (separate)”The platform also receives webhooks from payment processors at
/api/webhooks/{provider}/{tenantSlug} (Stripe, Authorize.Net). Those are
platform-inbound and aren’t part of the integrator-facing direction
described above — they’re documented for completeness in the
Live API explorer but you don’t subscribe to them.
Deliberate non-goals
Section titled “Deliberate non-goals”- No per-event payload customization. The body shape is fixed.
- No filtering beyond event type. You can’t subscribe to “only orders over $1000.”
- No
restoredevent delivery. The catalog enumeratescreated,updated, anddeletedonly. - No manual re-delivery API. Five attempts is the budget. Fix the receiver first; coordinate a backfill with support if you need historical events.
- No webhook secret rotation in place. Rotate by creating a new endpoint and revoking the old.
- No batched delivery. One event per HTTP POST.
- No HTTP/3 or websocket transport. HTTPS POST only.