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.)
| Header | Description |
|---|---|
X-Webhook-Signature | sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body. |
X-Webhook-Timestamp | When this attempt was sent, in Unix seconds. It is not part of the signature. |
X-Webhook-Id | The 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-Agent | AgencyMax/1.0 |
| custom | Any headers set on the subscription. |
| Body property | Description |
|---|---|
id | The event id. It stays the same across retries, so use it for deduplication. |
event | The event type. |
source | The service that raised the event. |
timestamp | When the event happened (UTC). |
version | The payload envelope version (1.0). |
data | Event-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.
The subscription's maxRetries and retryDelayMinutes settings are accepted and stored, but they don't yet change the retry behavior.
Verifying signatures
-
Optionally, reject the delivery if
X-Webhook-Timestampis more than 5 minutes from your current time. The timestamp isn't signed, so combine this check with eventiddeduplication to defend against replayed requests. -
Read the raw request body as bytes, before any JSON parsing.
-
Compute HMAC-SHA256 over it, using the UTF-8 bytes of the subscription
secretas the key. -
Hex-encode the result in lowercase and add the
sha256=prefix. -
Compare it with
X-Webhook-Signatureusing a constant-time comparison. -
Reject the delivery if
X-Webhook-Timestampis more than 5 minutes from your current time. This defends against replayed requests.
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
modifiedSincesync to catch anything missed during a long outage on your side.