Skip to main content

Receiving webhooks

The request​

Each delivery is an HTTPS POST to your subscription url:

POST /agencymax/webhooks HTTP/1.1
Host: integrations.example.com
Content-Type: application/json
User-Agent: AgencyMax/1.0
X-Webhook-Id: 7a1d2c9e-3b4f-4c5d-8e6f-9a0b1c2d3e4f
X-Webhook-Timestamp: 1790262131
X-Webhook-Signature: sha256=5d41402abc4b2a76b9719d911017c592ae2f9e1c4b0c5a1f7e3b2d6c8a9f0e1d
X-Integration: crm

{
"id": "b3c9e1f2-6a7d-4e8f-9b0a-1c2d3e4f5a6b",
"event": "AgencyMax.AgencyManagement.Events.AgentUpdated",
"source": "/agencymanagement/eventpublisher",
"timestamp": "2026-09-24T15:02:11+00:00",
"version": "1.0",
"data": {
"agency": "3f2c8a8e-5b7d-4b8e-9d9a-0f6c1e2a4b11",
"agentCode": "A1051",
"firstName": "Jordan",
"lastName": "Rivera",
"status": "Active"
}
}

(The data above is shortened. See Event types for the full shapes.)

HeaderDescription
X-Webhook-Signaturesha256= followed by the lowercase hex HMAC-SHA256 of the raw request body.
X-Webhook-TimestampWhen this attempt was sent, in Unix seconds. It is not part of the signature.
X-Webhook-IdThe delivery record id. On the first attempt it is currently 00000000-0000-0000-0000-000000000000, so don't rely on it for deduplication. Use the body id.
User-AgentAgencyMax/1.0
customAny headers set on the subscription.
Body propertyDescription
idThe event id. It stays the same across retries, so use it for deduplication.
eventThe event type.
sourceThe service that raised the event.
timestampWhen the event happened (UTC).
versionThe payload envelope version (1.0).
dataEvent-specific data.

Responding​

  • Return any 2xx status to acknowledge the delivery. Any other final status counts as a failure. Redirects are followed.
  • Respond within 30 seconds, or the attempt times out and fails.
  • Acknowledge quickly and process later. Put the event on a queue, return 200, and do the work in the background.

Retries​

Failed deliveries (a non-2xx response, a timeout or a connection error) are retried automatically. Retries use an increasing back-off delay, for up to 10 retries spread over several hours. Every attempt increases attemptNumber on the delivery record, which you can see in the delivery history.

note

The subscription's maxRetries and retryDelayMinutes settings are accepted and stored, but they don't yet change the retry behavior.

Verifying signatures​

  1. Optionally, reject the delivery if X-Webhook-Timestamp is more than 5 minutes from your current time. The timestamp isn't signed, so combine this check with event id deduplication to defend against replayed requests.

  2. Read the raw request body as bytes, before any JSON parsing.

  3. Compute HMAC-SHA256 over it, using the UTF-8 bytes of the subscription secret as the key.

  4. Hex-encode the result in lowercase and add the sha256= prefix.

  5. Compare it with X-Webhook-Signature using a constant-time comparison.

  6. Reject the delivery if X-Webhook-Timestamp is more than 5 minutes from your current time. This defends against replayed requests.

note

The key is the secret string's UTF-8 bytes, exactly as the API returned it. Don't base64-decode it first.

C# (ASP.NET Core)​

app.MapPost("/agencymax/webhooks", async (HttpRequest request, IConfiguration config) => {
var secret = config["AgencyMax:WebhookSecret"]!;

using var ms = new MemoryStream();
await request.Body.CopyToAsync(ms);
var body = ms.ToArray();

// Reject stale or replayed deliveries
if (!long.TryParse(request.Headers["X-Webhook-Timestamp"], out var ts) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > 300) {
return Results.Unauthorized();
}

var expected = "sha256=" + Convert.ToHexStringLower(
HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), body));
var actual = request.Headers["X-Webhook-Signature"].ToString();

if (!CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(actual))) {
return Results.Unauthorized();
}

var payload = JsonSerializer.Deserialize<JsonElement>(body);
// enqueue payload for background processing, deduplicating on payload.id
return Results.Ok();
});

Node.js (Express)​

import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/agencymax/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.AGENCYMAX_WEBHOOK_SECRET;

const ts = Number(req.header('X-Webhook-Timestamp'));
if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(401);

const expected = 'sha256=' + crypto.createHmac('sha256', Buffer.from(secret, 'utf8'))
.update(req.body)
.digest('hex');
const actual = req.header('X-Webhook-Signature') ?? '';

const a = Buffer.from(expected);
const b = Buffer.from(actual);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

const event = JSON.parse(req.body.toString('utf8'));
// enqueue event for background processing, deduplicating on event.id
res.sendStatus(200);
});

Handling events reliably​

  • Deduplicate on the event id. The same event can be delivered more than once, for example when a retry follows a slow success.
  • Don't rely on ordering. Deliveries can arrive out of order. Compare the envelope timestamp, or fetch the current state from the API.
  • Fetch the full record when you need it. The agent snapshot has only the core fields, so call GET /agents/{agentCode} for everything else.
  • Reconcile now and then. Run a periodic modifiedSince sync to catch anything missed during a long outage on your side.