Payload and delivery
What a delivery looks like, which headers it carries, and how Complyance retries when your endpoint does not answer. Read this before you write the receiver.
Each delivery is a POST with a JSON body and a small set of headers.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-Webhook-Event | The event type, for example purchase.invoice.stored |
X-Webhook-ID | The event’s ID. The same on every retry of the same event. |
X-Webhook-Timestamp | When this delivery attempt was sent |
X-Webhook-Signature | HMAC of the body, when signing is on. See Verify the signature. |
Body
{
"eventId": "purchase:01JABCDEFGHIJKLMNOPQRSTUV",
"data": {
"id": "purchase:01JABCDEFGHIJKLMNOPQRSTUV",
"type": "purchase.invoice.stored",
"timestamp": "2026-09-18T07:04:01.550Z",
"data": {
"documentId": "01JABCDEFGHIJKLMNOPQRSTUV",
"documentNumber": "INV-2026-0142",
"documentType": "tax_invoice",
"country": "AE",
"totalAmount": 12600.0,
"currency": "AED",
"sellerName": "Acme Trading LLC",
"issueDate": "2026-09-17",
"submittedAt": "2026-09-18T07:04:01.550Z"
},
"metadata": {
"requestId": "01JABCDEFGHIJKLMNOPQRSTUV"
}
}
}| Field | Meaning |
|---|---|
eventId | The event’s ID. Also in data.id and the X-Webhook-ID header. Use it to spot duplicates. |
data.type | The event type |
data.timestamp | When the event happened. Unlike X-Webhook-Timestamp, it does not change on retries. |
data.data | The event’s own fields. Each event type documents its own; see Purchase invoice events. |
data.metadata.requestId | An ID you can quote to support |
Treat eventId as an opaque string. Do not read meaning into its format.
Verify the signature
When signing is on, every delivery carries an X-Webhook-Signature header: the HMAC of the request body, using your secret and the algorithm you chose, written as lowercase hex. Check it before you do anything with the payload.
Compute the HMAC over the raw request body exactly as received. Parsing and serialising the JSON again can change the bytes and make the check fail.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, secret, signature = '') {
if (!/^[0-9a-f]{64}$/.test(signature)) return false;
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
return timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
}import hmac, hashlib
def verify(raw_body: bytes, secret: str, signature: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode('ascii'), signature.encode('utf-8'))import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HexFormat;
static boolean verify(byte[] rawBody, String secret, String signature) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes("UTF-8"), "HmacSHA256"));
String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
return MessageDigest.isEqual(expected.getBytes(), signature.getBytes());
}import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func verify(rawBody []byte, secret, signature string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}function verify(string $rawBody, string $secret, string $signature): bool {
$expected = hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expected, $signature);
}using System.Security.Cryptography;
using System.Text;
static bool Verify(byte[] rawBody, string secret, string signature)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(rawBody)).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature));
}These examples use SHA-256. For SHA-512, change the algorithm and, in the Node.js example, the expected signature length from 64 to 128 hex characters. Pass an empty string when the signature header is missing. Reject the request with a 401 when the check fails, and watch for those rejections: a run of them usually means a misconfigured secret or unwanted traffic.
Retries
Your endpoint has to answer with a 2xx status. Anything else, or no answer in time, counts as a failure:
- A
4xxresponse is treated as a permanent problem with the request, so it is not retried. Fix the endpoint before sending more events. Use the Purchases API to retrieve purchase invoices you missed. - Any other failure is retried: after 1 minute, then 5 minutes, then 15 minutes, then 15 minutes. That is up to five attempts, with 36 minutes of retry delays in total. Request time and delivery delays can make the elapsed time longer. If all attempts fail, delivery stops.
Every retry carries the same eventId. Use it to make your processing idempotent: receiving the same event again must not create another invoice or repeat an action.
Write the receiver
- Read the raw body.
- Verify the signature.
- Save the event durably with a unique constraint on
eventId. If it is already saved, respond200and stop. - Arrange for your worker to process saved events. Make its invoice updates safe to repeat too.
- Respond
200.
Do the slow work after you have responded, not before. A receiver that takes too long looks like a failure and gets retried.
Next: Purchase invoice events.
Last updated