# Signatures

URL: https://submitator.com/docs/webhooks/signatures

> Every delivery is signed as Standard Webhooks describes. Check it with an official library and your whsec_ secret, or with HMAC-SHA256 in any language.

Every webhook delivery is signed as [Standard Webhooks](https://www.standardwebhooks.com) describes. Anyone can send a POST to your URL, so check the signature before you trust the body.

## The headers

| Header | What it holds |
| --- | --- |
| `webhook-id` | The event's id, such as `evt_5Rt8Wq2Lm7Xn3Kb9Vz1PdJ`. Every attempt of the same event carries the same id, so store it and skip the deliveries you have handled. |
| `webhook-timestamp` | When this attempt was signed, in Unix seconds. |
| `webhook-signature` | `v1,` and a base64 HMAC-SHA256. For 24 hours after you roll the secret it holds 2 signatures, separated by a space. |

The body is the event as JSON, the same object `GET /events/{event_id}` returns. [Event types](https://submitator.com/docs/webhooks/event-types) lists what it can hold.

A delivery, its body cut short:

```http
POST /listings HTTP/1.1
Host: hooks.northlight.test
Content-Type: application/json
webhook-id: evt_5Rt8Wq2Lm7Xn3Kb9Vz1PdJ
webhook-timestamp: 1791469022
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"object":"event","id":"evt_5Rt8Wq2Lm7Xn3Kb9Vz1PdJ","type":"listing.live","created_at":"2026-10-08T14:17:01Z","livemode":false,"project":"prj_4QzX1m9Lr2Vb7Nc8Tk3HwP","data":{...}}
```

## Your secret

Adding an endpoint returns its secret once: `whsec_` and the base64 of 32 random bytes. Store it in your secret manager; these docs read it from `SUBMITATOR_WEBHOOK_SECRET`. Each endpoint has its own secret, and live and test endpoints are separate.

## Check it with a library

The official Standard Webhooks libraries check each signature in the header, and reject a timestamp more than 5 minutes from your clock. Pass them the raw body exactly as it arrived, the headers and your `whsec_` secret. They return the event, or raise an error when the delivery is not ours.

verify-webhook.mjs:

```js
import { Webhook } from "standardwebhooks";

// The whsec_ secret of your endpoint, shown once when you added it.
const secret = process.env.SUBMITATOR_WEBHOOK_SECRET;

// Returns the event when the delivery is ours. Throws a
// WebhookVerificationError when the signature does not match or the
// timestamp is more than 5 minutes from your clock.
export function verifyWebhook(rawBody, headers, whsec = secret) {
  return new Webhook(whsec).verify(rawBody, headers);
}
```

```python
# pip install standardwebhooks
import os
from standardwebhooks.webhooks import Webhook

webhook = Webhook(os.environ["SUBMITATOR_WEBHOOK_SECRET"])

# raw_body: the request body as bytes. Raises WebhookVerificationError
# when the delivery is not ours. With Flask:
# event = verify_webhook(request.get_data(), dict(request.headers))
def verify_webhook(raw_body, headers):
    return webhook.verify(raw_body, headers)
```

```ruby
# gem install standardwebhooks
require "standardwebhooks"

WEBHOOK = StandardWebhooks::Webhook.new(ENV.fetch("SUBMITATOR_WEBHOOK_SECRET"))

# In a Rails controller. Raises StandardWebhooks::WebhookVerificationError
# when the delivery is not ours.
def verify_webhook
  WEBHOOK.verify(request.raw_post, {
    "webhook-id" => request.headers["webhook-id"],
    "webhook-timestamp" => request.headers["webhook-timestamp"],
    "webhook-signature" => request.headers["webhook-signature"],
  })
end
```

```php
<?php
// composer require standard-webhooks/standard-webhooks
$webhook = new \StandardWebhooks\Webhook(getenv("SUBMITATOR_WEBHOOK_SECRET"));

// Throws \StandardWebhooks\Exception\WebhookVerificationException
// when the delivery is not ours.
$event = $webhook->verify(file_get_contents("php://input"), [
    "webhook-id" => $_SERVER["HTTP_WEBHOOK_ID"] ?? "",
    "webhook-timestamp" => $_SERVER["HTTP_WEBHOOK_TIMESTAMP"] ?? "",
    "webhook-signature" => $_SERVER["HTTP_WEBHOOK_SIGNATURE"] ?? "",
]);
```

```go
// go get github.com/standard-webhooks/standard-webhooks/libraries/go
import (
	"io"
	"net/http"
	"os"

	standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	wh, err := standardwebhooks.NewWebhook(os.Getenv("SUBMITATOR_WEBHOOK_SECRET"))
	if err != nil {
		http.Error(w, "bad secret", http.StatusInternalServerError)
		return
	}
	body, err := io.ReadAll(r.Body)
	if err != nil || wh.Verify(body, r.Header) != nil {
		http.Error(w, "not ours", http.StatusBadRequest)
		return
	}
	// body is the event as JSON.
	w.WriteHeader(http.StatusNoContent)
}
```

Answer 400 to a delivery that fails the check: it did not come from us. [Receive webhooks](https://submitator.com/docs/guides/receive-webhooks) puts the check into a complete endpoint.

## Check it without a library

The check takes 5 steps in any language:

1. Take your secret without `whsec_` and decode it from base64. That is the key.
2. Join `webhook-id`, `webhook-timestamp` and the raw body with dots: `{webhook-id}.{webhook-timestamp}.{body}`.
3. Sign that text with HMAC-SHA256 and the key, and encode the result in base64.
4. Compare it, in constant time, with each `v1,` signature in `webhook-signature`. One match is enough.
5. Reject the delivery when `webhook-timestamp` is more than 5 minutes from your clock.

verify-webhook.mjs:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

// The same check without a library. rawBody is the body exactly as received.
export function verifySignature(rawBody, headers, whsec = process.env.SUBMITATOR_WEBHOOK_SECRET) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const key = Buffer.from(whsec.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 5 * 60;
  const matches = String(headers["webhook-signature"] ?? "")
    .split(" ")
    .some((part) => {
      const [version, signature = ""] = part.split(",");
      const given = Buffer.from(signature, "base64");
      return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
    });
  return fresh && matches;
}
```

```python
import base64
import hashlib
import hmac
import time

def verify_signature(raw_body, headers, secret):
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    fresh = abs(time.time() - int(headers["webhook-timestamp"])) <= 5 * 60
    signatures = [part.split(",", 1) for part in headers["webhook-signature"].split(" ") if "," in part]
    return fresh and any(version == "v1" and hmac.compare_digest(value, expected) for version, value in signatures)
```

## Mistakes that break the check

- **Parsing the body before the check.** JSON parsed and written again has other bytes, and the signature no longer matches. Check the raw body first, then parse it.
- **A clock that drifts.** A server clock more than 5 minutes off rejects every delivery. Keep it in sync with NTP.
- **One secret for every endpoint.** Each endpoint has its own secret, and test endpoints have their own too. Check with the secret of the endpoint the delivery came to.

## Roll the secret

`POST /webhook_endpoints/{webhook_endpoint_id}/rotate_secret` returns a new secret, shown in that response only. For the next 24 hours each delivery carries 2 signatures, one per secret, so it passes with either and you can deploy the new secret without missing an event.

```bash
curl -X POST https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/rotate_secret \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY"
```

```js
const res = await fetch(
  "https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/rotate_secret",
  { method: "POST", headers: { Authorization: `Bearer ${process.env.SUBMITATOR_API_KEY}` } },
);
console.log(await res.json());
```

```python
import os
import requests

res = requests.post(
    "https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/rotate_secret",
    headers={"Authorization": f"Bearer {os.environ['SUBMITATOR_API_KEY']}"},
)
print(res.json())
```

Response 200 (some fields left out):

```json
{
  "object": "webhook_endpoint",
  "id": "we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT",
  "livemode": false,
  "url": "https://hooks.northlight.test/listings",
  "secret": "whsec_BFXycpAAK/Ay+ue0CxyVr5JQrZILyVqHTkjpX3GtfmY=",
  "previous_secret_expires_at": "2026-10-09T15:30:00Z"
}
```

`previous_secret_expires_at` says when the old secret stops signing. Roll the secret right away if it leaks.

## Send yourself a test event

`POST /webhook_endpoints/{webhook_endpoint_id}/test` sends a `ping` event to the endpoint now, whatever its `events` filter. Use it to check your signature code before a real event arrives; the delivery shows up in the endpoint's deliveries.

```bash
curl -X POST https://api.submitator.com/v1/webhook_endpoints/we_4Xk9Lm2Qt7Wr3Nb8Vz5HcT/test \
  -H "Authorization: Bearer $SUBMITATOR_API_KEY"
```

## Next steps

- [Event types](https://submitator.com/docs/webhooks/event-types): every event and what its `data` holds.
- [Retries and failures](https://submitator.com/docs/webhooks/retries-and-failures): what counts as delivered, and the retry schedule.
- [Receive webhooks](https://submitator.com/docs/guides/receive-webhooks): an endpoint from start to finish.
