> ## Documentation Index
> Fetch the complete documentation index at: https://rain-sandbox-trial.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Set Up Webhooks

> Register your endpoint, secure it with signature verification, and learn the headers Rain sends with every webhook.

Before Rain can deliver events, register an endpoint and set up signature verification. This page covers registering your webhook URL, choosing and rotating a signing key, verifying signatures, and the headers Rain sends with each request. To learn how delivery behaves once your endpoint is live, see [Webhook delivery](/docs/webhook-delivery).

## Set up webhooks

<Steps>
  <Step title="Add your webhook URL">
    In your developer dashboard, add a webhook URL. Once configured, Rain sends webhook events to the specified URL whenever relevant actions occur.

    Your webhook URL must:

    * **Use `https` for any public endpoint**: HMAC signatures verify integrity but do not encrypt the payload, so always use `https` in production to keep event data private in transit. Rain also accepts `http` for local sandbox testing, but never expose an `http` endpoint publicly. Rain does not support other protocols like `ftp://` or `file://`.
    * **Resolve to a public IP address**: Rain blocks URLs that resolve to private, internal, or reserved IP addresses.
    * **Respond directly, without redirecting**: Rain does not follow HTTP redirects when it delivers events. A `3xx` response is treated as a blocked delivery and is **not retried**, so point the URL at the endpoint that handles the event.
  </Step>

  <Step title="Pass URL validation">
    Rain validates your URL when you register it. If your URL does not meet these requirements, registration fails with a `400 Bad Request` error.

    <Info>
      URLs that resolve to private networks, loopback addresses (127.0.0.1), link-local addresses (169.254.x.x), or cloud metadata endpoints are not permitted.
    </Info>
  </Step>
</Steps>

## Webhook security and signing

Each webhook request is signed using an HMAC SHA256 signature, based on the exact JSON payload sent in the body. This signature is included in the `Signature` HTTP header of the request.

Rain uses one of your tenant **API keys** as the HMAC secret. This is the same kind of key you use in the `Api-Key` header for API requests. Use the **full key value** (the secret you copied when that key was created) when you verify signatures.

### Choose a signing key

By default, the `Admin` API key that Rain creates with your tenant is also the webhook signing key. For better separation of concerns, create a dedicated signing key from the **API Settings** page in the dashboard. A few rules apply:

* Only keys with the `Webhook Signing` role can be selected.
* You need permission to manage API keys.
* The signing key is set per environment, on the sandbox or production Webhooks page.

See [API key roles](/reference/authenticating-with-the-api#api-key-roles) for how to create role-scoped keys.

### Rotate signing keys

To rotate keys without downtime, use the key rotation API endpoints: create a secondary key, then promote it to the primary signing key. During [rotation](/changelog/api), Rain also sends a `Secondary-Signature` header while both keys are still valid.

<Info>
  To delete an API key that is currently your primary or secondary webhook signing key, first set a different signing key in the dashboard, then delete the old key.
</Info>

### Verify a signature

Compute the HMAC SHA256 of the exact request body using your signing key, then compare it to the `Signature` header (and the `Secondary-Signature` header, if present). Verify the signature before processing the payload to confirm the request came from Rain.

<div className="wf-diagram">
  <div className="diagram-shell">
    <svg id="sig-verify" role="img" aria-label="Signature verification handshake" viewBox="0 0 1080 700" width="1080" height="700" style={{width: "100%", height: "auto"}}><defs><marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" className="marker-fill-default" /></marker><marker id="arrowSend" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" className="marker-fill-send" /></marker><marker id="arrowDisp" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" className="marker-fill-disp" /></marker></defs><line x1="310" y1="70" x2="310" y2="640" className="lane-line" stroke-width="1.5" stroke-dasharray="2 6" /><line x1="770" y1="70" x2="770" y2="640" className="lane-line" stroke-width="1.5" stroke-dasharray="2 6" /><path d="M 310 168 L 310 204" fill="none" className="conn-default" stroke-width="1.7" marker-end="url(#arrow)" opacity="0.92" /><path d="M 310 272 C 310 290, 770 290, 770 308" fill="none" className="conn-send" stroke-width="1.7" stroke-dasharray="5 4" marker-end="url(#arrowSend)" opacity="0.92" /><path d="M 770 376 L 770 412" fill="none" className="conn-default" stroke-width="1.7" marker-end="url(#arrow)" opacity="0.92" /><path d="M 770 480 C 770 508, 660 508, 660 536" fill="none" className="conn-default" stroke-width="1.7" marker-end="url(#arrow)" opacity="0.92" /><path d="M 770 480 C 770 508, 900 508, 900 536" fill="none" className="conn-disp" stroke-width="1.7" stroke-dasharray="5 4" marker-end="url(#arrowDisp)" opacity="0.92" /><foreignObject x="216" y="16" width="188" height="54"><div className="lane-head rain"><span className="ico">🌧️</span><span className="nm">Rain</span></div></foreignObject><foreignObject x="676" y="16" width="188" height="54"><div className="lane-head"><span className="ico">🖥️</span><span className="nm">Your endpoint</span></div></foreignObject><foreignObject x="216" y="100" width="188" height="68"><div className="card action"><span className="ct"><span className="tag">Rain</span><span className="lab">Sign the raw body</span></span></div></foreignObject><foreignObject x="216" y="204" width="188" height="68"><div className="card send"><span className="ct"><span className="tag">Signed request</span><span className="lab">POST + Signature header</span></span></div></foreignObject><foreignObject x="676" y="308" width="188" height="68"><div className="card api"><span className="ct"><span className="tag">Your endpoint</span><span className="lab">Recompute the HMAC</span></span></div></foreignObject><foreignObject x="676" y="412" width="188" height="68"><div className="card action"><span className="ct"><span className="tag">Compare</span><span className="lab">Constant-time compare</span></span></div></foreignObject><foreignObject x="566" y="536" width="188" height="68"><div className="card handle"><span className="ct"><span className="tag">If it matches</span><span className="lab">Return 2xx, process</span></span></div></foreignObject><foreignObject x="806" y="536" width="188" height="68"><div className="card dispute"><span className="ct"><span className="tag">If it differs</span><span className="lab">Return 4xx, reject</span></span></div></foreignObject></svg>
  </div>
</div>

<Warning>
  Hash the raw request body exactly as received. Parsing the JSON and re-serializing it can change whitespace or key order and produce a different signature.
</Warning>

<CodeGroup>
  ```ts Node.js theme={null}
  import { createHmac, timingSafeEqual } from "crypto";

  // rawBody: the exact request body string, before any JSON parsing.
  function verifySignature(rawBody: string, header: string, signingKey: string): boolean {
    const expected = createHmac("sha256", signingKey).update(rawBody).digest("hex");
    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(header, "hex");
    return a.length === b.length && timingSafeEqual(a, b);
  }

  // Accept the request if either signature matches.
  // Secondary-Signature is only present during key rotation.
  function isRequestFromRain(
    rawBody: string,
    signatureHeader: string,
    signingKey: string,
    secondarySignatureHeader?: string,
  ): boolean {
    if (verifySignature(rawBody, signatureHeader, signingKey)) return true;
    return secondarySignatureHeader
      ? verifySignature(rawBody, secondarySignatureHeader, signingKey)
      : false;
  }

  // Call it with the values from the incoming request. Read the
  // Signature and Secondary-Signature headers and pass the raw body.
  const valid = isRequestFromRain(
    rawBody,
    signatureHeader,
    signingKey,
    secondarySignatureHeader,
  );
  ```

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

  # raw_body: the exact request body bytes, before any JSON parsing.
  def verify_signature(raw_body: bytes, header: str, signing_key: str) -> bool:
      expected = hmac.new(signing_key.encode(), raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, header)

  # Accept the request if either signature matches.
  # Secondary-Signature is only present during key rotation.
  def is_request_from_rain(raw_body, signature_header, signing_key, secondary_signature_header=None):
      if verify_signature(raw_body, signature_header, signing_key):
          return True
      if secondary_signature_header:
          return verify_signature(raw_body, secondary_signature_header, signing_key)
      return False

  # Call it with the values from the incoming request. Read the
  # Signature and Secondary-Signature headers and pass the raw body.
  valid = is_request_from_rain(
      raw_body, signature_header, signing_key, secondary_signature_header
  )
  ```
</CodeGroup>

## Webhook request headers

When Rain sends a webhook to your endpoint, the request includes the following HTTP headers:

| Header                | Description                                                                                                                                                                |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`        | Set to `application/json`. The request body is formatted as JSON.                                                                                                          |
| `Accept`              | Set to `application/json`. Rain expects your endpoint to respond with JSON.                                                                                                |
| `Signature`           | Contains an HMAC SHA256 signature of the request body, computed with your configured webhook signing key. Use it to verify webhook authenticity.                           |
| `Secondary-Signature` | Optional. Contains an HMAC SHA256 signature computed with the secondary signing key. Rain sends it during [key rotation](#rotate-signing-keys), while both keys are valid. |

## What's next

<Columns cols={2}>
  <Card title="Webhook delivery" icon="paper-plane" href="/docs/webhook-delivery">
    Learn the event lifecycle and how to respond, plus ordering, retries, and versioning.
  </Card>

  <Card title="How webhooks work" icon="list" href="/docs/webhooks">
    Browse every event category and find a specific event.
  </Card>
</Columns>
