Skip to content

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.

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.

Terminal window
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_..."
}

The url must be an absolute https:// address with a public host name. The API refuses, with a validation error on url:

  • an IP address as the host (https://203.0.113.5/hook);
  • localhost, a single-label name (https://intranet/hook), or a name under .local, .localhost, .internal, .lan, .home.arpa, .intranet or .corp;
  • a user name or password in the address.

At delivery time the host name is resolved and the request goes out only if every address it resolves to is on the public internet — no loopback, private (RFC 1918), shared (100.64/10), link-local, documentation, benchmarking, multicast or IPv6 unique-local address. A delivery refused this way records the error “The address isn’t on the public internet, so nothing was sent.” and retries like any other failure. Redirects are not followed: a 3xx response is recorded as a failed attempt.

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.

A background service ticks every 30 seconds. On each tick, for each active endpoint:

  1. Enqueue. Up to 500 new audit-log events since the endpoint’s last processed timestamp are matched against the endpoint’s eventTypes filter and turned into pending webhook_deliveries rows.
  2. 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.

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:

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=20"

Each row carries the attempted timestamp, the HTTP status code, the attempt number, and a sanitized error message when the call failed.

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));
}
Terminal window
# 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-Signature header is missing.
  • The recomputed HMAC doesn’t match (constant-time compare).
  • The X-Webhook-Timestamp is more than a few minutes off your wall clock (replay protection — the platform doesn’t enforce this on its side, but you should).

Best-practice handler shape:

  1. Read the body as raw bytes / raw string before any framework JSON middleware mutates it. The signature is over the exact bytes sent.
  2. Verify the signature.
  3. Check X-Webhook-Delivery-Id against your “already processed” cache; if seen, return 200 immediately without re-running side effects.
  4. Run your side effect.
  5. Return 200 OK within ten seconds (the delivery service’s per-attempt timeout). If you can’t, queue the work to a background processor and return 200 immediately — long-running handlers risk timing out and getting retried.

To verify connectivity without waiting for a real event:

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"

The platform fires a synthetic test.ping event against the endpoint’s URL and returns the delivery row so you can inspect status code and timing. It is signed exactly as a real delivery is — X-Webhook-Signature: sha256={hex} — and goes through the same address checks.

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.

  • 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 restored event delivery. The catalog enumerates created, updated, and deleted only.
  • 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.