Skip to content

Outbound Webhooks

Outbound webhooks let your systems react to events as they happen in AdvocateLoop. Instead of polling our API on a schedule, you give us a URL and we POST a JSON payload to it whenever something relevant occurs. Each request is signed with a shared secret so you can verify it came from us.

Typical uses:

  • Sync conversions to your data warehouse or analytics tool
  • Trigger a CRM update when a claim is created
  • Send a Slack/Discord notification when a conversion completes
  • Process refunds in a downstream billing system
  • Power a custom dashboard or internal tool

If you’re sending events into AdvocateLoop from an external system (Zapier, Make, a CRM), you want the Connecting Zapier, Make, and Custom Backends page instead. This page covers the opposite direction: receiving events from AdvocateLoop.

How it works at a glance

  1. You register a webhook endpoint in the AdvocateLoop dashboard, providing a URL and choosing which event types you want.
  2. We give you a signing secret. Save it. It’s shown once and never displayed again.
  3. When a matching event happens, we send a POST request to your URL with a JSON body and a signature header.
  4. Your server verifies the signature, processes the event, and returns a 2xx response within 30 seconds.
  5. If you return a non-2xx status or time out, we retry with exponential backoff. After enough sustained failures, your endpoint is automatically disabled and you’re notified.

Available events

Each webhook delivery has an event field identifying what happened. The currently available types:

EventWhen it fires
claim.createdA new claim is recorded against a referral code
conversion.createdA conversion (purchase or other tracked outcome) is recorded
conversion.refundedAn existing conversion is partially or fully refunded

When subscribing, you choose any subset of these. Subscribing to all events from one endpoint is fine.

Request format

Every webhook delivery is a POST to your endpoint URL with this shape:

Headers

HeaderDescription
Content-TypeAlways application/json
X-AL-EventThe event type, e.g. conversion.created
X-AL-Event-IDA stable unique ID for this event. Use it for idempotency (see below).
X-AL-TimestampUNIX timestamp (seconds) of when we generated the event. Used in signature verification.
X-AL-SignatureHMAC-SHA256 signature of the request body, hex-encoded. See Verifying signatures.
User-AgentAdvocateLoop-Webhooks/1.0

Body

A JSON envelope with metadata plus the event payload:

{
"id": "evt_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "conversion.created",
"created_at": "2025-03-15T18:23:45.123Z",
"data": {
"conversion_id": "cnv_xyz123",
"claim_id": "clm_abc456",
"referral_code": "V2AVMRDJ",
"amount": 89.50,
"currency": "USD",
"customer_email": "customer@example.com",
"occurred_at": "2025-03-15T18:23:42.000Z"
}
}

The exact fields inside data vary by event type. We never remove fields without notice; new fields may be added over time, so write your parser to ignore unknown fields.

Verifying signatures

Every webhook is signed. Always verify the signature before trusting the payload — without verification, anyone who guesses your endpoint URL could send fake events.

The signature is HMAC-SHA256 of the raw request body (the bytes you receive on the wire, before any JSON parsing), keyed by your endpoint’s signing secret. The signature is sent in the X-AL-Signature header as a lowercase hex string.

Verification recipe

import hmac
import hashlib
def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(
secret.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
const crypto = require('crypto');
function verifyWebhook(rawBody, signatureHeader, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Use timingSafeEqual to prevent timing attacks
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signatureHeader, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
<?php
function verify_webhook($raw_body, $signature_header, $secret) {
$expected = hash_hmac('sha256', $raw_body, $secret);
return hash_equals($expected, $signature_header);
}

Important verification rules

  • Use the raw request body, not a re-serialized version. If your framework parses JSON automatically, you’ll need to access the raw bytes separately (e.g. Express needs a verify callback on express.json(), Django needs request.body not request.POST).
  • Use a constant-time comparison (hmac.compare_digest, crypto.timingSafeEqual, hash_equals). Plain == is vulnerable to timing attacks.
  • Reject the request if verification fails. Return a 401 Unauthorized and do nothing else.

Idempotency

We may deliver the same event more than once. This happens if:

  • Your endpoint returned an error and we retried
  • Your endpoint accepted the event but the connection was lost before we received the response
  • A network condition caused a duplicate delivery

To handle this safely, use X-AL-Event-ID as an idempotency key. The simplest pattern: record event IDs you’ve already processed (e.g. in a database table with a unique index) and skip any duplicates:

async function handleWebhook(req, res) {
// ... verify signature first ...
const eventId = req.headers['x-al-event-id'];
const existing = await db.processedEvents.findUnique({ where: { id: eventId } });
if (existing) {
// Already processed; acknowledge and move on
return res.status(200).send('already processed');
}
// Do your processing
await processEvent(req.body);
// Mark as processed
await db.processedEvents.create({ data: { id: eventId } });
res.status(200).send('ok');
}

The same event ID is shared across all endpoints that subscribe to that event, so if you have multiple endpoints for redundancy, dedupe by event ID across all of them.

Responding to webhooks

Return status codes

StatusMeaning to us
200299Success. We mark the delivery complete.
Any other status, or timeoutFailure. We retry.

A simple 200 OK with an empty body is the recommended success response.

Respond quickly

You have 30 seconds to respond before we time out and consider the delivery failed.

Best practice: acknowledge immediately, process asynchronously. If your processing takes longer than a couple seconds (sending emails, updating a CRM, syncing to a warehouse), put the event on a queue and process it in a background worker. Your webhook handler should be:

async function handleWebhook(req, res) {
// 1. Verify signature
if (!verifyWebhook(req.rawBody, req.headers['x-al-signature'], SECRET)) {
return res.status(401).send('invalid signature');
}
// 2. Idempotency check (quick — just a DB lookup)
const eventId = req.headers['x-al-event-id'];
if (await alreadyProcessed(eventId)) {
return res.status(200).send('already processed');
}
// 3. Enqueue for background processing
await queue.add('process-webhook', { event: req.body, eventId });
// 4. Respond immediately
res.status(200).send('ok');
}

This pattern is more resilient — slow downstream systems (a flaky CRM, a high-latency analytics endpoint) can’t take down your webhook handler.

Retries

If we don’t get a 2xx response, we retry on a schedule. Each retry is spaced further apart to give your system time to recover from temporary issues:

The first few retries happen within minutes; later ones span hours. By the final retry, well over a day has passed since the original event. If all retries fail, the delivery is marked as permanently failed.

Endpoints that fail repeatedly across many events are automatically disabled to prevent the system from continuing to hammer a broken URL. You’ll be notified when this happens and can re-enable the endpoint from the dashboard after fixing whatever broke.

Testing your endpoint

The fastest way to verify your handler works end-to-end:

  1. Create a test webhook endpoint pointing at a service like webhook.site or ngrok tunneling to your local dev server.
  2. Subscribe to a low-volume event type (e.g. conversion.created).
  3. Trigger a test event in AdvocateLoop — manually record a conversion through the dashboard, or use the test trigger if available.
  4. Inspect the delivery in your tool, then in the AdvocateLoop dashboard under the endpoint’s delivery log.

For local development, ngrok is the standard choice — it gives you a public HTTPS URL that tunnels to localhost, which lets you receive real webhook traffic on your dev machine.

Security checklist

Before going live with a production webhook handler:

  • Endpoint URL uses https://, never http://
  • Signature verification implemented and tested
  • Verification uses a constant-time comparison function
  • Verification reads the raw request body, not a re-serialized version
  • Failed verifications return 401 Unauthorized and do not process the payload
  • Signing secret stored in environment variables / secrets manager, never in source control
  • Idempotency keyed on X-AL-Event-ID
  • Handler responds within 30 seconds (use a queue if needed)
  • Errors during processing don’t return non-2xx if the event itself was valid (you’ll get retries you don’t need)

Common issues

“Signatures don’t match.” Almost always one of: (a) you’re hashing the parsed/re-serialized JSON instead of the raw body, (b) extra whitespace was added by a middleware, or (c) the wrong secret is being used. Log the raw body bytes you’re hashing and compare lengths against the Content-Length header — if they differ, something is mutating the body before you see it.

“Webhooks stopped arriving.” Check the dashboard for your endpoint status. Auto-disabled endpoints stop receiving events until manually re-enabled. The delivery log will show the failures that led to disabling.

“I’m getting duplicates.” Expected occasionally — use X-AL-Event-ID for idempotency. If it’s happening on every event (not just occasional retries), it usually means your endpoint isn’t returning 2xx quickly enough and we’re treating successful deliveries as failed.

“My local handler works but production doesn’t.” Most commonly: a reverse proxy or framework middleware in production is consuming or modifying the body before your handler sees it. Verify by logging Content-Length vs the bytes you actually receive.