Webhooks
Webhooks in ComplyAdvantage Mesh
ComplyAdvantage Mesh has a comprehensive webhook infrastructure. Setting up, viewing and changing the status of webhooks can be achieved through our webhook endpoints.
Source IP addresses
Webhooks will be delivered from the following IP addresses, depending on the region you are hosted in. If you are unsure of the region you are hosted in, please reach out to our support team who will be able to confirm.
| Server | IP addresses |
|---|---|
| https://api.eu.mesh.complyadvantage.com/ | 99.81.100.211, 99.81.116.122, 99.81.123.126 |
| https://api.eu3.mesh.complyadvantage.com/ | 34.159.166.146, 34.159.147.62, 34.89.250.171 |
| https://api.us.mesh.complyadvantage.com/ | 34.67.184.6, 35.225.126.158, 34.70.66.162 |
| https://api.us2.mesh.complyadvantage.com/ | 3.129.172.188, 3.134.73.120, 52.14.135.67 |
| https://api.ca.mesh.complyadvantage.com/ | 34.47.8.155, 34.47.17.48, 34.95.18.164 |
| https://api.au.mesh.complyadvantage.com/ | 34.151.112.133, 35.201.15.175, 35.201.23.82 |
Delivery Guarantees
Our webhooks operate on an "at least once" delivery guarantee. This means that when an event occurs that triggers a webhook, we ensure that the webhook is sent to your specified endpoint at least one time.
In practice, this guarantee implies that for each webhook, you will receive one or more requests from our systems. While we strive to send each webhook only once, there are scenarios where you may receive the same webhook more than once. This can happen due to various factors such as network issues, server redundancy, or our retry mechanisms.
It's important to note that receiving duplicate webhooks is a normal, though infrequent occurrence in an "at least once" delivery system. We prioritize ensuring that you don't miss webhooks over preventing occasional duplicates. Therefore, we recommend that your webhook handling logic be idempotent - capable of processing the same event multiple times without adverse effects.
Identifying a webhook
Every webhook we send carries a webhook-id header, example:
webhook-id: 6f8a1c2e-9b47-4d05-a3f1-7c2e5b840d19
The value identifies the webhook we are delivering and is the same on every attempt of delivering it, including retries. Use it as your idempotency key.
If your account has webhook signing enabled, webhook-id is also one of the inputs to the signature. See Webhook signature verification.
Webhooks retry mechanism
If a webhook is sent from our side and the endpoint on the other end is not available to receive it, there will be three retries with an exponential back-off between them:
- 1st retry after 1 second
- 2nd retry after 5 seconds
- 3rd retry after 10 seconds
Every attempt carries the same webhook-id as the original send, so you can recognise a retry of something you have already received. For signed webhooks the webhook-timestamp and webhook-signature are regenerated for each attempt, so those two headers differ between the original and its retries.
We wait up to 5 seconds for a response on each attempt. An attempt that has not completed by then is recorded as failed and retried, which means a slow endpoint sees duplicates just as an unavailable one does.
Processing webhooks
When a webhook arrives, we recommend:
- Verify the signature, if your account has signing enabled.
- Compare the
webhook-idagainst the ids you have already handled, and skip the next step if you have seen it before. - Write the payload somewhere durable so that the real work can happen outside the request, asynchronously.
- Return a
2xxresponse.
Webhook signature verification
If your account has webhook signing enabled, every webhook we send carries two further headers alongside webhook-id - webhook-timestamp and webhook-signature. These let you verify that the payload was sent by us and has not been altered in transit. We sign using HMAC-SHA256 over a canonical string that includes the message id, timestamp, and body. See below for the exact format.
Enabling webhook signatures
Webhook signing is opt-in per account. To enable it, please reach out to our support team. We generate a 256-bit signing secret on your behalf and share it with you securely.
If you ever need to rotate the secret, contact us. We support zero-downtime rotation: while both the old and new secret are active, every webhook is signed with both, and you can verify against whichever one you currently hold.
Headers we send
| Header | Format | Description |
|---|---|---|
webhook-id | UUID | Identifier for this webhook, unchanged across retries. |
webhook-timestamp | Unix epoch in seconds (not milliseconds) | When we sent the webhook. |
webhook-signature | v1,<base64-sig> | The signature(s) over the message contents. May contain multiple entries separated by spaces during a key rotation. |
Example:
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
During a key rotation you may temporarily see two signatures:
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= v1,p4yUrAEPP7n9TG8K3+ZxLyhKK6Lyw9k8oDZ4qf73wHQ=
A signature verifier should treat the header value as a list and accept the message if any entry matches.
The signature scheme
We compute the signature as:
signature = base64( HMAC-SHA256( secret, "{webhook-id}.{webhook-timestamp}.{body}" ) )
Where:
secretis the 32 raw bytes obtained by base64-decoding the secret value we shared with you.{webhook-id}and{webhook-timestamp}are the exact strings from the corresponding headers. Don't reformat them or strip whitespace.{body}is the raw bytes of the request body, exactly as delivered. Don't parse and re-serialize the JSON. Whitespace and key ordering matter.- The three components are joined by literal
.characters.
Secret format
The secret we share with you is a raw base64 string. For example:
CDed2VbFJcUjH4HxmPBkHTmWozoGgZrjAnWkrcKVKVE=
It uses the standard base64 alphabet (+, /, =), not URL-safe base64. Decoding it with a URL-safe decoder will give you garbled bytes and signatures will mismatch. After decoding you should have 32 bytes of raw key material.
We use the secret value as-is, without whsec_ or any other prefix.
Timestamp tolerance
A common practice is to reject messages whose webhook-timestamp is outside a ±5 minute window of the current time. This protects against replay attacks.
How to verify
- Capture the raw request body before any JSON parsing or middleware. Most web frameworks parse the body before your handler sees it, so you need to hook in earlier.
- Read the
webhook-id,webhook-timestamp, andwebhook-signatureheaders. - Reject the request if
webhook-timestampis outside ±5 minutes of "now". - Base64-decode the signing secret to get 32 raw bytes.
- Build the canonical string:
{webhook-id}.{webhook-timestamp}.{body}joined by literal dots. - Compute
HMAC-SHA256over the canonical string with the decoded secret as the key, and base64-encode the result. - Strip the
v1,prefix from each space-separated entry in thewebhook-signatureheader and compare your computed signature against each. Use a constant-time comparison to avoid timing attacks. Accept the message if any entry matches.
Common verification failures
Things that commonly cause verification failures:
- Hashing the parsed JSON instead of the raw body. Frameworks like Express, Spring, and FastAPI parse and re-serialize the JSON before your handler sees it. The re-serialized form differs in whitespace and key order, so the signature won't match. Always feed the verifier the raw bytes from the wire.
- Forgetting to base64-decode the secret. The secret value we share is base64-encoded. Use it as the HMAC key only after decoding to raw bytes.
- Signing only the body. The canonical string is
{webhook-id}.{webhook-timestamp}.{body}, joined by literal dots. Hashing just the body, ortimestamp.body, will not match. - Including the
v1,prefix in the signature you compare against. Strip it and compare only the base64 portion. - Using URL-safe base64 to decode the secret. Use the standard alphabet (
+,/,=).
Pre-built libraries
Our signing format is compatible with the Standard Webhooks v1 specification, so any Standard Webhooks library will work out of the box. They handle the signing scheme, multi-signature header parsing, and timestamp-window enforcement for you, in Python, JavaScript / TypeScript, Java / Kotlin, Go, Ruby, C# / .NET, PHP, Rust, and Elixir.
Updated 8 days ago