Skip to main content
Your endpoint is reachable by anyone who learns the URL, and the payload tells your system about real records. Treat an unverified request as untrusted input: check the signature before you read the body, and reject anything that fails.

The header

Every delivery carries:
The timestamp is signed along with the body. That is what stops a captured payload being replayed at you later: an attacker can resend the bytes, but the timestamp inside them ages out.

Verifying

1

Take the raw body

The exact bytes, before any JSON parsing. Re-serializing a parsed object does not reproduce them, and the signature will not match.
2

Parse the header

Split on ,, then on =, to get t and v1. A header you cannot parse is a rejection.
3

Check the timestamp

Reject if |now - t| is greater than your tolerance. The default is 300 seconds, which is also what the SDK uses.
4

Recompute and compare

HMAC-SHA256(secret, t + "." + rawBody), hex encoded, compared in constant time. A plain === on hex leaks a prefix-match oracle.
The raw body is not optional. In Express, mount express.raw({type: "application/json"}) on the webhook route only, so the rest of your app keeps its JSON parser. The SDK throws a named error (invalid_payload) telling you exactly this when it is handed a parsed object.

The signing secret

Each endpoint gets its own secret, whsec_ followed by 48 hex characters.
  • It is returned once, when the endpoint is created or when you rotate it. Nothing can retrieve it afterwards.
  • It never appears in a list response, and it is never logged.
  • Store it the way you store any other credential, and never in client-side code.

Rotating

Rotating mints a new secret and returns it once.
Rotation takes effect immediately, and there is no overlap window where both secrets verify. The next delivery is signed with the new secret only, so deploy the new value first, or accept a short gap where deliveries fail their signature check and are retried.
A safe order, if you cannot tolerate that gap: register a second endpoint alongside the first, cut traffic over once it is verifying, then delete the old one.

What Custral will and will not call

A subscription URL is checked when it is created and again on every delivery attempt, so editing it later to something internal does not get past the guard. Before each attempt, Custral resolves the hostname, checks every address it gets back, and then connects only to the addresses it checked. The name is not looked up a second time, so a DNS record changed between the check and the connection cannot send the delivery somewhere else. A redirect is never followed either. Custral does not request the Location, so your endpoint cannot bounce a delivery to an address the check did not approve. See Redirects are not followed.
A refused URL is not retried, since waiting does not make it safe. Fix the URL or its DNS record, and the next event is checked from scratch.
There is no published set of source IP addresses to allowlist. The signature is the authentication mechanism; use it rather than the origin address.

Handling a failure

Return a 4xx on a signature failure and do no work. Custral treats a non-2xx as a failed delivery and retries it, which is the correct outcome for a transient problem and harmless for a forged request.