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

# Signature Verification

> Verify Surge webhook signatures using Standard Webhooks HMAC-SHA256 to authenticate incoming requests.

Surge signs every outbound webhook request following the [Standard Webhooks](https://www.standardwebhooks.com/) specification. Verifying the signature confirms that the request came from Surge and wasn't tampered with in transit.

## The three headers

Surge sets these headers on every webhook POST:

| Header              | Description                                                       |
| ------------------- | ----------------------------------------------------------------- |
| `webhook-id`        | Unique ID for this webhook delivery attempt                       |
| `webhook-timestamp` | Unix timestamp (seconds) when the event was sent                  |
| `webhook-signature` | HMAC-SHA256 signatures, comma-separated, each prefixed with `v1,` |

The `webhook-signature` header may contain multiple `v1,<signature>` values separated by spaces if your project has multiple signing secrets (during key rotation). Accept the request if any signature verifies successfully.

## Signing algorithm

Surge computes the signature as:

```
HMAC-SHA256(secret, "{webhook-id}.{webhook-timestamp}.{raw-body}")
```

The secret is base64-encoded and prefixed with `whsec_` in the dashboard. Strip the prefix and base64-decode it before using it as the HMAC key.

The signed message is the string from the `webhook-id` request header, a literal `.`, the integer from the `webhook-timestamp` request header, a literal `.`, and then the raw request body, all concatenated without any separating whitespace beyond the `.` characters.

## Replay protection

The `webhook-timestamp` lets you reject replays. Surge recommends rejecting requests where `webhook-timestamp` is more than five minutes old. Most Standard Webhooks library implementations enforce this tolerance window automatically.

## Verification examples

<CodeGroup>
  ```python Python theme={null}
  from standardwebhooks import Webhook
  import os

  wh = Webhook(os.environ["SURGE_WEBHOOK_SECRET"])

  try:
      payload = wh.verify(request.body, request.headers)
      # payload is the parsed event dict
  except Exception:
      return Response(status=400)
  ```

  ```typescript TypeScript theme={null}
  import { Webhook } from "standardwebhooks";

  const wh = new Webhook(process.env.SURGE_WEBHOOK_SECRET!);

  try {
    const payload = wh.verify(rawBody, req.headers);
    // payload is the parsed event object
  } catch {
    res.status(400).end();
    return;
  }
  ```

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

  wh = StandardWebhooks::Webhook.new(ENV["SURGE_WEBHOOK_SECRET"])

  begin
    payload = wh.verify(request.raw_post, request.headers)
  rescue StandardWebhooks::WebhookVerificationError
    head :bad_request and return
  end
  ```

  ```elixir Elixir theme={null}
  # The `standardwebhooks` library isn't available for Elixir yet.
  # Verify the HMAC manually:
  <<<<<<< CURRENT
  defp verify_surge_signature(conn, secret) do
    webhook_id    = List.first(get_req_header(conn, "webhook-id"))
    timestamp     = List.first(get_req_header(conn, "webhook-timestamp"))
    signature     = List.first(get_req_header(conn, "webhook-signature"))
    body          = conn.assigns[:raw_body]

    # Strip the "whsec_" prefix and base64-decode the secret
    "whsec_" <> encoded = secret
    decoded_secret = Base.decode64!(encoded, padding: false)

    # Build the signed payload: "{webhook-id}.{webhook-timestamp}.{body}"
    payload  = "#{webhook_id}.#{timestamp}.#{body}"
    expected = :crypto.mac(:hmac, :sha256, decoded_secret, payload) |> Base.encode64()

    # Plug.Crypto.secure_compare/2 is a constant-time comparison.
    Plug.Crypto.secure_compare(signature, "v1,#{expected}")
  end
  =======
  @tolerance_seconds 300

  defp verify_surge_signature(conn, secret) do
    webhook_id    = List.first(get_req_header(conn, "webhook-id"))
    timestamp     = List.first(get_req_header(conn, "webhook-timestamp"))
    signature_hdr = List.first(get_req_header(conn, "webhook-signature"))
    body          = conn.assigns[:raw_body]

    # Reject stale timestamps to prevent replay attacks (codex P1)
    with {ts, ""} <- Integer.parse(timestamp || ""),
         true <- abs(System.system_time(:second) - ts) <= @tolerance_seconds do
      # Strip the "whsec_" prefix and base64-decode the secret
      "whsec_" <> encoded = secret
      decoded_secret = Base.decode64!(encoded, padding: false)

      # Build the signed payload: "{webhook-id}.{webhook-timestamp}.{body}"
      payload  = "#{webhook_id}.#{timestamp}.#{body}"
      expected = :crypto.mac(:hmac, :sha256, decoded_secret, payload) |> Base.encode64()

      # The header may hold several space-separated "v1,<sig>" entries
      # during key rotation — accept if any matches (codex P2).
      signature_hdr
      |> String.split(" ", trim: true)
      |> Enum.any?(&Plug.Crypto.secure_compare(&1, "v1,#{expected}"))
    else
      _ -> false
    end
  end
  >>>>>>> SUGGESTED (#11 codex P1 replay-protection + #12 codex P2 key-rotation multi-sig, 2026-07-17 — DRAFTED, one block fixes both)
  ```
</CodeGroup>

## Finding your webhook secret

Your signing secret is shown in the dashboard on the top-level **Webhooks** page, next to each endpoint URL. It looks like `whsec_<base64-encoded-bytes>`.

## Deprecated header

<Warning>
  Earlier Surge versions used `surge-signature` instead of `webhook-signature`. If you are still reading `surge-signature`, migrate to `webhook-signature`. See [Deprecation Notices](../../resources/deprecations).
</Warning>
