> ## Documentation Index
> Fetch the complete documentation index at: https://docs.custral.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verifying signatures

> Your webhook URL is a public endpoint. The signature is what proves a delivery came from Custral.

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:

```
X-Custral-Signature: t=1789412334,v1=9f2c4b1ae7d3…
```

| Part | Meaning                                                                             |
| ---- | ----------------------------------------------------------------------------------- |
| `t`  | Unix timestamp, in **seconds**, of this attempt                                     |
| `v1` | Lowercase hex `HMAC-SHA256` over `"<t>.<raw body>"`, keyed with your signing secret |

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

<Steps>
  <Step title="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.
  </Step>

  <Step title="Parse the header">
    Split on `,`, then on `=`, to get `t` and `v1`. A header you cannot parse is
    a rejection.
  </Step>

  <Step title="Check the timestamp">
    Reject if `|now - t|` is greater than your tolerance. The default is **300
    seconds**, which is also what the SDK uses.
  </Step>

  <Step title="Recompute and compare">
    `HMAC-SHA256(secret, t + "." + rawBody)`, hex encoded, compared in
    **constant time**. A plain `===` on hex leaks a prefix-match oracle.
  </Step>
</Steps>

<CodeGroup>
  ```ts SDK theme={null}
  import {Custral} from "@custral/sdk";

  const custral = new Custral({webhookSecret: process.env.CUSTRAL_WEBHOOK_SECRET!});

  // Throws CustralSignatureVerificationError on a bad signature, a stale
  // timestamp, a missing header, or a body that is not valid JSON.
  const event = custral.webhooks.constructEvent(rawBody, signatureHeader);
  ```

  ```ts Node theme={null}
  import crypto from "node:crypto";

  export function verifyCustralWebhook(rawBody: string, header: string, secret: string): boolean {
    let timestamp: number | null = null;
    let signature: string | null = null;
    for (const part of header.split(",")) {
      const eq = part.indexOf("=");
      if (eq === -1) continue;
      const key = part.slice(0, eq).trim();
      const value = part.slice(eq + 1).trim();
      if (key === "t") timestamp = Number(value);
      if (key === "v1") signature = value;
    }
    if (timestamp === null || Number.isNaN(timestamp) || !signature) return false;

    if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false;

    const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
    const a = Buffer.from(expected);
    const b = Buffer.from(signature);
    if (a.length !== b.length) return false;
    return crypto.timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hashlib, hmac, time

  def verify_custral_webhook(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
      parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
      try:
          timestamp = int(parts["t"])
          signature = parts["v1"]
      except (KeyError, ValueError):
          return False

      if abs(int(time.time()) - timestamp) > tolerance:
          return False

      signed = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, signature)
  ```

  ```ruby Ruby theme={null}
  require "openssl"

  def verify_custral_webhook(raw_body, header, secret, tolerance = 300)
    parts = header.split(",").map { |p| p.split("=", 2) }.to_h
    timestamp = Integer(parts["t"], exception: false)
    signature = parts["v1"]
    return false if timestamp.nil? || signature.nil?
    return false if (Time.now.to_i - timestamp).abs > tolerance

    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
    OpenSSL.secure_compare(expected, signature)
  end
  ```
</CodeGroup>

<Warning>
  **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.
</Warning>

## 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.

<Warning>
  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.
</Warning>

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.

| Refused                                                                                                                | Reason                   |
| ---------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| Any scheme other than `http` or `https`                                                                                | `unsupported_protocol`   |
| `localhost`, `*.localhost`, `*.local`, `*.internal`                                                                    | `internal_host`          |
| Private and loopback IPv4: `10.x`, `172.16-31.x`, `192.168.x`, `127.x`, `0.x`, and carrier-grade NAT `100.64-127.x`    | `private_ip`             |
| Link-local `169.254.x`, which includes cloud metadata endpoints                                                        | `private_ip`             |
| Special-purpose IPv4: the documentation, benchmarking and protocol-assignment ranges, and everything from `224.x` up   | `private_ip`             |
| IPv6 loopback, unique-local (`fc00::/7`), link-local (`fe80::/10`), and the multicast, documentation and Teredo ranges | `private_ip`             |
| An IPv6 address that embeds a refused IPv4 address, such as `::ffff:127.0.0.1`                                         | `private_ip`             |
| A hostname whose DNS answers include any refused address, even one address out of several                              | `resolves_to_private_ip` |
| A URL that does not parse                                                                                              | `invalid_url`            |

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](/dev/webhooks/delivery#redirects-are-not-followed).

<Note>
  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.
</Note>

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](/dev/webhooks/delivery) it, which is the
correct outcome for a transient problem and harmless for a forged request.


## Related topics

- [Quickstart](/dev/webhooks/quickstart.md)
- [Payload format](/dev/webhooks/payload.md)
- [Phone & SMS System](/blocks/widgets/dialer.md)
- [Troubleshooting](/dev/webhooks/troubleshooting.md)
- [Deliverability](/comms/deliverability/overview.md)
