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

# Verify signatures

> Confirm every webhook delivery genuinely came from Ionic before you trust it.

Your webhook endpoint is a public URL. Anyone who finds it can `POST` to it. Before you act on an event, verify its signature — this proves the request came from Ionic and that the body wasn't altered in transit.

Ionic signs deliveries with the open [Standard Webhooks](https://www.standardwebhooks.com) scheme: an HMAC-SHA256 over the message id, timestamp, and raw body, keyed by your endpoint's signing secret.

<Warning>
  Verify on **every** request, and verify against the **raw request body** — the exact bytes Ionic sent. If your framework parses JSON and you re-serialize it, key order and whitespace change and the signature will never match. Capture the raw body before any JSON middleware runs.
</Warning>

## What you receive

Each delivery carries three headers:

| Header              | Example                    | Meaning                                                            |
| ------------------- | -------------------------- | ------------------------------------------------------------------ |
| `webhook-id`        | `msg_2pXq...`              | Unique delivery id. Stable across retries of the *same* delivery.  |
| `webhook-timestamp` | `1746122645`               | Unix seconds when the delivery was signed. Used to reject replays. |
| `webhook-signature` | `v1,g0hM9S... v1,bm9ld...` | Space-separated list of `version,signature` pairs.                 |

The `webhook-signature` value can contain **more than one** signature (space-separated) — for example during secret rotation, when both the old and new secrets sign the body. Treat a match against *any* listed signature as valid.

## Your signing secret

When you create an endpoint (or rotate its secret), Ionic returns a secret that looks like:

```
whsec_MfKQ9r8GKYqrTwjUPD8ILPZ...
```

The bytes after the `whsec_` prefix are **base64-encoded**. Decode them to get the raw key for the HMAC. Store the secret somewhere only your server can read it; Ionic shows it once and never returns it again.

## The algorithm

1. **Reject stale deliveries.** If `webhook-timestamp` is more than five minutes from now, stop — this blocks replays of a captured request.
2. **Build the signed content** by joining the id, timestamp, and raw body with periods:
   ```
   signed_content = {webhook-id}.{webhook-timestamp}.{raw_body}
   ```
3. **Compute** `base64(HMAC_SHA256(secret_bytes, signed_content))`, where `secret_bytes` is the base64-decoded part of `whsec_…`.
4. **Compare** your result against each signature in `webhook-signature` using a **constant-time** comparison. Any match means the delivery is authentic.

## Verify it

<CodeGroup>
  ```ts TypeScript theme={null}
  import crypto from "node:crypto";

  // rawBody MUST be the exact bytes Ionic sent (a string or Buffer),
  // captured before any JSON parsing.
  export function verifyWebhook(secret: string, headers: Record<string, string>, rawBody: string) {
    const id = headers["webhook-id"];
    const timestamp = headers["webhook-timestamp"];
    const signatureHeader = headers["webhook-signature"]; // "v1,xxx v1,yyy"

    // 1. Replay protection: reject timestamps outside a 5-minute window.
    const now = Math.floor(Date.now() / 1000);
    if (Math.abs(now - Number(timestamp)) > 300) {
      throw new Error("Webhook timestamp outside tolerance");
    }

    // 2 + 3. Recompute the signature over `{id}.{timestamp}.{body}`.
    const secretBytes = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const signedContent = `${id}.${timestamp}.${rawBody}`;
    const expected = crypto.createHmac("sha256", secretBytes).update(signedContent).digest("base64");

    // 4. Constant-time compare against each provided v1 signature.
    const provided = signatureHeader.split(" ").map((part) => part.split(",")[1]);
    const matched = provided.some(
      (sig) =>
        sig.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
    );
    if (!matched) throw new Error("No matching webhook signature");
  }
  ```

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

  def verify_webhook(secret: str, headers: dict, raw_body: bytes) -> None:
      msg_id = headers["webhook-id"]
      timestamp = headers["webhook-timestamp"]
      signature_header = headers["webhook-signature"]  # "v1,xxx v1,yyy"

      # 1. Replay protection.
      if abs(int(time.time()) - int(timestamp)) > 300:
          raise ValueError("Webhook timestamp outside tolerance")

      # 2 + 3. Recompute over `{id}.{timestamp}.{body}`.
      secret_bytes = base64.b64decode(secret.removeprefix("whsec_"))
      signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
      expected = base64.b64encode(
          hmac.new(secret_bytes, signed_content, hashlib.sha256).digest()
      ).decode()

      # 4. Constant-time compare against each provided signature.
      provided = [part.split(",", 1)[1] for part in signature_header.split()]
      if not any(hmac.compare_digest(sig, expected) for sig in provided):
          raise ValueError("No matching webhook signature")
  ```

  ```go Go theme={null}
  package webhooks

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/base64"
  	"errors"
  	"fmt"
  	"math"
  	"net/http"
  	"strconv"
  	"strings"
  	"time"
  )

  // Verify checks a webhook delivery. body MUST be the raw request bytes.
  func Verify(secret string, h http.Header, body []byte) error {
  	id := h.Get("webhook-id")
  	ts := h.Get("webhook-timestamp")
  	sigHeader := h.Get("webhook-signature") // "v1,xxx v1,yyy"

  	// 1. Replay protection.
  	t, err := strconv.ParseInt(ts, 10, 64)
  	if err != nil {
  		return fmt.Errorf("bad timestamp: %w", err)
  	}
  	if math.Abs(float64(time.Now().Unix()-t)) > 300 {
  		return errors.New("timestamp outside tolerance")
  	}

  	// 2 + 3. Recompute over `{id}.{timestamp}.{body}`.
  	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
  	if err != nil {
  		return fmt.Errorf("bad secret: %w", err)
  	}
  	mac := hmac.New(sha256.New, key)
  	fmt.Fprintf(mac, "%s.%s.%s", id, ts, body)
  	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))

  	// 4. Constant-time compare against each provided signature.
  	for _, part := range strings.Fields(sigHeader) {
  		_, sig, _ := strings.Cut(part, ",")
  		if hmac.Equal([]byte(sig), []byte(expected)) {
  			return nil
  		}
  	}
  	return errors.New("no matching webhook signature")
  }
  ```
</CodeGroup>

<Note>
  Prefer a maintained [Standard Webhooks](https://www.standardwebhooks.com/#libraries) library in your language over hand-rolled code where you can — it handles the timestamp window, multiple signatures, and constant-time comparison for you. The implementations above show exactly what such a library does, for languages or environments where you'd rather not add a dependency.
</Note>

## Getting the raw body

The signature is computed over the bytes on the wire, so you must read the body *before* JSON parsing. A few common frameworks:

| Framework                | How                                                                                                                                    |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| **Express**              | Mount `express.raw({ type: "application/json" })` on the webhook route; verify `req.body` (a `Buffer`), then `JSON.parse` it yourself. |
| **Next.js (App Router)** | `const raw = await req.text();` before `req.json()`.                                                                                   |
| **FastAPI / Flask**      | `await request.body()` / `request.get_data()` — not `request.json`.                                                                    |
| **Go**                   | Read `r.Body` into a `[]byte` once; verify, then unmarshal from that slice.                                                            |

## After verification

Once verified, parse the body and handle the event. The envelope's `type` tells you what happened and `data.object` carries the resource. Because delivery is at-least-once, your handler must be [idempotent](/webhooks/idempotency-and-retries) — dedupe on the envelope `id`.
