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

# Verifying signatures

> Check the Runcrate-Signature header so your endpoint only acts on events Runcrate sent.

Your endpoint's URL is public, so anyone could post to it. Every request on the **Webhook** channel is signed with your endpoint's signing secret. Check the signature before you act on an event, and reject the request when it does not match.

Slack and Discord endpoints are not signed: Runcrate posts to Slack and Discord, never to your own server, and their webhook URL is itself the secret. See [Slack](/docs/webhooks/slack#the-url-is-the-secret) and [Discord](/docs/webhooks/discord#the-url-is-the-secret).

## The header

```text theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
Runcrate-Signature: t=1792504804,v1=5d2f8c0b9e...e91a
```

* `t` is the Unix time, in seconds, when the request was signed. Every attempt is signed afresh, so a retry carries a new `t`.
* `v1` is the hex HMAC-SHA256 of `t`, a full stop and the raw request body, keyed with the whole secret string, `whsec_` included.
* For 24 hours after you rotate the secret, the header carries two `v1` values: `t=...,v1=<signed with the new secret>,v1=<signed with the old one>`. Accept the request if any `v1` matches.

To verify:

1. Read the **raw** body bytes. Parsing and re-serializing the JSON changes the bytes and breaks the signature.
2. Compute HMAC-SHA256 over `` `${t}.${rawBody}` `` with your secret.
3. Compare it with each `v1` in constant time. Accept if one matches.
4. Reject a `t` more than 5 minutes from your clock, so a captured request cannot be replayed later. Keep your server's clock in sync (NTP).

## Code

```javascript Node.js theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.RUNCRATE_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;

function verifyRuncrateSignature(rawBody, header, secret) {
  let t = null;
  const signatures = [];
  for (const item of (header ?? '').split(',')) {
    const i = item.indexOf('=');
    if (i <= 0) continue;
    const key = item.slice(0, i).trim();
    const value = item.slice(i + 1).trim();
    if (key === 't') t = value;
    else if (key === 'v1' && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
  }
  if (!/^\d+$/.test(t ?? '') || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  // During a secret rotation the header carries two v1 values: accept either.
  return signatures.some(sig => crypto.timingSafeEqual(expected, Buffer.from(sig, 'hex')));
}

const app = express();

// express.raw keeps the body as the exact bytes Runcrate signed.
app.post('/runcrate/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyRuncrateSignature(req.body, req.get('Runcrate-Signature'), SECRET)) {
    return res.status(400).send('bad signature');
  }
  const event = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200);
  // Act on event.type after responding: a delivery times out after 10 seconds.
});

app.listen(3000);
```

```python Python theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
import hashlib
import hmac
import json
import os
import re
import time

from flask import Flask, request

SECRET = os.environ["RUNCRATE_WEBHOOK_SECRET"]  # whsec_...
TOLERANCE_SECONDS = 300


def verify_runcrate_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
    t, signatures = None, []
    for item in (header or "").split(","):
        key, sep, value = item.strip().partition("=")
        if not sep:
            continue
        if key == "t":
            t = value
        elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
            signatures.append(value)
    if t is None or not re.fullmatch(r"[0-9]+", t) or not signatures:
        return False
    if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    # During a secret rotation the header carries two v1 values: accept either.
    return any(hmac.compare_digest(expected, sig) for sig in signatures)


app = Flask(__name__)


@app.post("/runcrate/webhooks")
def runcrate_webhook():
    raw_body = request.get_data()  # the exact bytes Runcrate signed
    if not verify_runcrate_signature(raw_body, request.headers.get("Runcrate-Signature"), SECRET):
        return "bad signature", 400
    event = json.loads(raw_body)
    # Respond fast and do the work elsewhere: a delivery times out after 10 seconds.
    return "", 200
```

```go Go theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

const tolerance = 5 * time.Minute

func verifyRuncrateSignature(body []byte, header, secret string, now time.Time) bool {
	var t string
	var signatures [][]byte
	for _, item := range strings.Split(header, ",") {
		key, value, ok := strings.Cut(strings.TrimSpace(item), "=")
		if !ok {
			continue
		}
		switch key {
		case "t":
			t = value
		case "v1":
			if sig, err := hex.DecodeString(value); err == nil && len(sig) == sha256.Size {
				signatures = append(signatures, sig)
			}
		}
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil || len(signatures) == 0 {
		return false
	}
	if age := now.Sub(time.Unix(ts, 0)); age > tolerance || age < -tolerance {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(body)
	expected := mac.Sum(nil)
	// During a secret rotation the header carries two v1 values: accept either.
	for _, sig := range signatures {
		if hmac.Equal(expected, sig) {
			return true
		}
	}
	return false
}

func main() {
	secret := os.Getenv("RUNCRATE_WEBHOOK_SECRET") // whsec_...

	http.HandleFunc("POST /runcrate/webhooks", func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) // the exact bytes Runcrate signed
		if err != nil {
			http.Error(w, "bad body", http.StatusBadRequest)
			return
		}
		if !verifyRuncrateSignature(body, r.Header.Get("Runcrate-Signature"), secret, time.Now()) {
			http.Error(w, "bad signature", http.StatusBadRequest)
			return
		}
		var event struct {
			ID   string `json:"id"`
			Type string `json:"type"`
		}
		if err := json.Unmarshal(body, &event); err != nil {
			http.Error(w, "bad json", http.StatusBadRequest)
			return
		}
		w.WriteHeader(http.StatusOK)
		// Respond fast and do the work elsewhere: a delivery times out after 10 seconds.
		log.Printf("received %s %s", event.Type, event.ID)
	})

	log.Fatal(http.ListenAndServe(":8080", nil))
}
```

> [!WARNING]
>
> Frameworks that parse JSON before your handler runs (`express.json()`, many serverless templates) hand you an object, not the bytes. Re-serializing it rarely gives back the same bytes. Read the raw body for this route, as the examples do.

## Compute a signature by hand

To check your verifier, or to post a signed event to your own server, compute a signature with `openssl` and send it with `curl`. Save an event body to `event.json` first (copy one from [events](/docs/webhooks/events)).

```bash theme={"theme":{"light":"github-light","dark":"catppuccin-mocha"}}
SECRET='whsec_your_secret'
T=$(date +%s)

# HMAC-SHA256 over "<t>.<body>", as hex. cat keeps the file's bytes exactly.
SIG=$({ printf '%s.' "$T"; cat event.json; } | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)

curl -X POST http://localhost:3000/runcrate/webhooks \
  -H 'Content-Type: application/json' \
  -H 'Runcrate-Event: webhook.test' \
  -H "Runcrate-Signature: t=$T,v1=$SIG" \
  --data-binary @event.json
```

`--data-binary` sends the file unchanged; `-d` would strip its newlines and the signature would no longer match.

## Rotating the secret

Rotate the secret when it may have leaked, when someone who had it leaves, or on a schedule.

**1. Rotate it in Runcrate**

In [Settings > Webhooks](https://runcrate.ai/dashboard/settings/webhooks), open the endpoint's menu and select **Rotate secret**, then confirm. Only owners and admins can.

**2. Copy the new secret**

It is shown once, under **Copy the new signing secret now**. Store it where your server reads its secrets.

**3. Deploy it within 24 hours**

For the next 24 hours every request is signed with both secrets, so your server keeps working while it still holds the old one and as soon as it holds the new one. After 24 hours the old secret stops being used.

If your server reads the secret only when it starts, restart or reload it after you update the secret.

## Related

> **[Best practices](/docs/webhooks/best-practices)**
>
> Answer fast, dedupe on id, keep the secret in a secret store.

> **[Testing locally](/docs/webhooks/testing-locally)**
>
> Run one of these verifiers on your machine and send it real events.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.