> For the complete documentation index, see [llms.txt](https://docs.shortcutco.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.shortcutco.com/developers/webhooks/verify-signatures.md).

# Verify signatures

Check the Shortcut-Signature header on every webhook request, with complete examples in Node, Python, Ruby, PHP, and Go.

After this page your receiver accepts only requests that Shortcut sent, unchanged, in the last five minutes, and refuses everything else.

Your webhook URL is public, so anyone can send it a request. The `Shortcut-Signature` header proves a request came from Shortcut: only Shortcut and you know the webhook's signing secret. Check it on every request, before you act on the body.

## The scheme

```http
Shortcut-Signature: t=1792249445,v1=7f4286ee39f4b09942722c368f2dd0c34e07adfb5e2cc76f949c7049ed0ad076
```

* `t` is when Shortcut signed the request, in Unix seconds. Each attempt is signed again, so a retry has a new `t`.
* `v1` is the signature: the HMAC-SHA256 of the string `<t>.<raw body>`, keyed with the webhook's whole secret, `whsec_` included, written as 64 lowercase hex characters.
* For 24 hours after a [secret rotation](/developers/webhooks/overview.md#rotate-the-signing-secret), the header has two `v1` values, one per secret, the new one first: `t=…,v1=…,v1=…`. A request is genuine when any `v1` matches.

To verify a request:

1. **Read the raw body**, exactly the bytes that arrived, before any JSON parsing. A parsed and re-serialized body may differ in spacing, key order, or number formatting, and then it does not match.
2. **Split the header** on `,`, then each part at its first `=`. Keep `t` and every `v1`. Ignore parts you do not recognize.
3. **Check the time.** Refuse the request if `t` is more than 300 seconds (five minutes) away from your clock. This stops someone replaying a request they captured. Keep your server's clock synchronized.
4. **Compute** the hex HMAC-SHA256 of `t`, a period, and the raw body, with your secret as the key.
5. **Compare** it with each `v1` in constant time. Accept if one matches; otherwise answer `400` and do nothing.

Then drop duplicates by the envelope's `id`, store the event, and answer `2xx`. See [Retries and monitoring](/developers/webhooks/retries-and-monitoring.md#build-a-receiver-that-keeps-up).

Each example below reads the secret from `SHORTCUT_WEBHOOK_SECRET`.

## Node

With Express, `express.raw` keeps the body as the bytes that arrived. Mount it on the webhook route only, ahead of any `express.json()` that would consume the body.

```js
import crypto from "node:crypto";
import express from "express";

const TOLERANCE_SECONDS = 300;

export function verifyShortcutSignature(rawBody, header, secret) {
  if (!header) return false;
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const separator = part.indexOf("=");
    if (separator === -1) continue;
    const key = part.slice(0, separator).trim();
    const value = part.slice(separator + 1).trim();
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    if (key === "v1" && value) signatures.push(value);
  }
  if (timestamp === null) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = Buffer.from(
    crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex"),
  );
  return signatures.some((signature) => {
    const candidate = Buffer.from(signature);
    return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected);
  });
}

const app = express();

app.post("/webhooks/shortcut", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Shortcut-Signature");
  if (!verifyShortcutSignature(req.body, header, process.env.SHORTCUT_WEBHOOK_SECRET)) {
    return res.status(400).send("Invalid signature");
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // Store the event and process it from a queue; answer before doing the work.
  console.log("Received", event.type, event.id);
  res.sendStatus(200);
});

app.listen(3000);
```

In another framework, the rule is the same: hand the verifier the unparsed body as a `Buffer` or string.

## Python

With Flask, `request.get_data()` returns the raw bytes. Call it before anything reads `request.json`.

```python
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

TOLERANCE_SECONDS = 300


def verify_shortcut_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
    if not header:
        return False
    timestamp = None
    signatures = []
    for part in header.split(","):
        key, separator, value = part.partition("=")
        if not separator:
            continue
        key, value = key.strip(), value.strip()
        if key == "t" and value.isdigit():
            timestamp = int(value)
        elif key == "v1" and value:
            signatures.append(value)
    if timestamp is None or abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        f"{timestamp}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return any(
        hmac.compare_digest(expected.encode("utf-8"), signature.encode("utf-8"))
        for signature in signatures
    )


app = Flask(__name__)


@app.post("/webhooks/shortcut")
def shortcut_webhook():
    raw_body = request.get_data()
    header = request.headers.get("Shortcut-Signature")
    if not verify_shortcut_signature(raw_body, header, os.environ["SHORTCUT_WEBHOOK_SECRET"]):
        return "Invalid signature", 400
    event = json.loads(raw_body)
    # Store the event and process it from a queue; answer before doing the work.
    print("Received", event["type"], event["id"])
    return "", 200
```

In Django, use `request.body` and `request.headers.get("Shortcut-Signature")`.

## Ruby

With Sinatra or any Rack app, read the body from `request.body`, and compare with `Rack::Utils.secure_compare`.

```ruby
require "json"
require "openssl"
require "rack/utils"
require "sinatra"

TOLERANCE_SECONDS = 300

def verify_shortcut_signature(raw_body, header, secret)
  return false if header.nil? || header.empty?

  timestamp = nil
  signatures = []
  header.split(",").each do |part|
    key, value = part.split("=", 2).map(&:strip)
    next if value.nil?

    if key == "t" && value.match?(/\A\d+\z/)
      timestamp = value.to_i
    elsif key == "v1" && !value.empty?
      signatures << value
    end
  end
  return false if timestamp.nil? || (Time.now.to_i - timestamp).abs > TOLERANCE_SECONDS

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}." + raw_body)
  signatures.any? { |signature| Rack::Utils.secure_compare(expected, signature) }
end

post "/webhooks/shortcut" do
  request.body.rewind
  raw_body = request.body.read
  header = request.env["HTTP_SHORTCUT_SIGNATURE"]
  halt 400, "Invalid signature" unless verify_shortcut_signature(raw_body, header, ENV.fetch("SHORTCUT_WEBHOOK_SECRET"))

  event = JSON.parse(raw_body)
  # Store the event and process it from a queue; answer before doing the work.
  logger.info("Received #{event["type"]} #{event["id"]}")
  status 200
end
```

In Rails, use `request.raw_post` and `request.headers["Shortcut-Signature"]`, and `ActiveSupport::SecurityUtils.secure_compare`.

## PHP

`php://input` holds the raw body; the header arrives as `$_SERVER['HTTP_SHORTCUT_SIGNATURE']`. PHP's hash\_equals function compares in constant time.

```php
<?php

const TOLERANCE_SECONDS = 300;

function verify_shortcut_signature(string $rawBody, ?string $header, string $secret): bool
{
    if ($header === null || $header === '') {
        return false;
    }
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        $pair = explode('=', $part, 2);
        if (count($pair) !== 2) {
            continue;
        }
        $key = trim($pair[0]);
        $value = trim($pair[1]);
        if ($key === 't' && ctype_digit($value)) {
            $timestamp = (int) $value;
        } elseif ($key === 'v1' && $value !== '') {
            $signatures[] = $value;
        }
    }
    if ($timestamp === null || abs(time() - $timestamp) > TOLERANCE_SECONDS) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }
    return false;
}

$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_SHORTCUT_SIGNATURE'] ?? null;
$secret = getenv('SHORTCUT_WEBHOOK_SECRET') ?: '';

if ($secret === '' || !verify_shortcut_signature($rawBody, $header, $secret)) {
    http_response_code(400);
    echo 'Invalid signature';
    exit;
}

$event = json_decode($rawBody, true);
// Store the event and process it from a queue; answer before doing the work.
error_log('Received ' . $event['type'] . ' ' . $event['id']);
http_response_code(200);
```

In Laravel, use `$request->getContent()` and `$request->header('Shortcut-Signature')`.

## Go

Read the whole body with `io.ReadAll` before decoding it, and compare with `hmac.Equal`.

```go
package main

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

const toleranceSeconds = 300

func verifyShortcutSignature(rawBody []byte, header, secret string) bool {
	var timestamp int64
	haveTimestamp := false
	var signatures []string
	for _, part := range strings.Split(header, ",") {
		key, value, found := strings.Cut(part, "=")
		if !found {
			continue
		}
		key, value = strings.TrimSpace(key), strings.TrimSpace(value)
		switch key {
		case "t":
			parsed, err := strconv.ParseInt(value, 10, 64)
			if err != nil || parsed < 0 {
				return false
			}
			timestamp, haveTimestamp = parsed, true
		case "v1":
			if value != "" {
				signatures = append(signatures, value)
			}
		}
	}
	if !haveTimestamp {
		return false
	}
	age := time.Now().Unix() - timestamp
	if age > toleranceSeconds || age < -toleranceSeconds {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "."))
	mac.Write(rawBody)
	expected := []byte(hex.EncodeToString(mac.Sum(nil)))
	for _, signature := range signatures {
		if hmac.Equal(expected, []byte(signature)) {
			return true
		}
	}
	return false
}

func shortcutWebhook(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Unreadable body", http.StatusBadRequest)
		return
	}
	header := r.Header.Get("Shortcut-Signature")
	if !verifyShortcutSignature(rawBody, header, os.Getenv("SHORTCUT_WEBHOOK_SECRET")) {
		http.Error(w, "Invalid signature", http.StatusBadRequest)
		return
	}
	var event struct {
		ID   string `json:"id"`
		Type string `json:"type"`
	}
	if err := json.Unmarshal(rawBody, &event); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}
	// Store the event and process it from a queue; answer before doing the work.
	log.Printf("Received %s %s", event.Type, event.ID)
	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("/webhooks/shortcut", shortcutWebhook)
	log.Fatal(http.ListenAndServe(":3000", nil))
}
```

## Check your code against a known signature

Run your verifier on these values with the time check turned off (they were signed in the past). The secret is an example made for this page; it signs nothing real.

| Input         | Value                                                              |
| ------------- | ------------------------------------------------------------------ |
| Secret        | `whsec_docs-example-secret-000`                                    |
| `t`           | `1792249445`                                                       |
| Raw body      | The single line below, 348 bytes, no trailing newline.             |
| Expected `v1` | `7f4286ee39f4b09942722c368f2dd0c34e07adfb5e2cc76f949c7049ed0ad076` |

```
{"id":"evt_3f9a1c7e5b2d4f6a8c0e1b3d5f7a9c2e","type":"webhook.test","createdAt":"2026-10-17T15:04:05.000Z","apiVersion":"v1","data":{"object":{"object":"webhook_test","webhookId":"7d2f4b8e-1a3c-4e5f-9b6d-0c8a2e4f6b1d","message":"A test event from Shortcut. If you can read this, your endpoint receives and can verify webhooks."}},"changedFields":[]}
```

Then send a real test event to your endpoint with `POST /v1/webhooks/{id}/test`, and check that it answered `200`. See [Local development](/developers/webhooks/local-development.md).

## When verification fails

| Symptom                             | Likely cause                                                                                                                                                              |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Every request fails                 | The body was parsed before verifying (a JSON body parser ran first), the secret is wrong or has a stray space or newline, or the `whsec_` prefix was left out of the key. |
| Requests fail only after a rotation | The receiver has neither the new nor the previous secret. For 24 hours either one verifies; after that only the new one does.                                             |
| Requests fail with an old `t`       | Your server's clock is off by more than five minutes. Synchronize it with NTP.                                                                                            |
| Some requests fail                  | A proxy or framework changed the body, for example by re-encoding characters outside ASCII. Verify the bytes as they arrived.                                             |

A request that fails verification should get a `400`. Shortcut counts it as a failed attempt and retries it, so a genuine event whose secret you fixed in time is delivered on a later attempt; look it up in the [deliveries list](/developers/webhooks/retries-and-monitoring.md#see-what-was-sent).
