---
title: Security and signatures
description: Prove that a delivery came from Coevera and was not changed on the way. Check a signature in your browser, then do the same in your receiver.
canonical: https://developers.coevera.com/webhooks/security-and-signatures/
section: webhooks
verification: schema
lastVerified: 2026-10-15
publisher: Coevera
---

# Security and signatures

**Answer:** WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the webhook's signature value, sent with WebHook-Signature-Version: v1. Compute it over the bytes you received, before parsing, and compare in constant time. The signature has no timestamp or nonce, so also drop old events and deduplicate on event, entity.id, entity.revision and event_time.

A webhook signs its deliveries when it has a `signature`: a UUID you choose when you create it.

## The v1 scheme

- **Key:** the webhook's `signature` value, as UTF-8 bytes. The API stores it in lowercase and
  returns it that way: use it exactly as the API returns it.
- **Message:** the exact bytes of the request body. A delivery body is one line of JSON with a
  space after every comma and colon, and non-ASCII characters escaped, so parsing it and
  serialising it again changes the bytes and the signature.
- **Output:** lowercase hex, 64 characters, in the `WebHook-Signature` header, with
  `WebHook-Signature-Version: v1`.
- **Opt-in:** a webhook without a `signature` sends no signature header, and its deliveries carry
  no authentication at all. Set one on every webhook.

## Signature checker

Paste a delivery you received, the webhook's `signature` value and the `WebHook-Signature` header.
The checker computes the signature with your browser's Web Crypto and compares the two. What you
paste is not sent anywhere and not stored: reload the page and the fields are empty.

**Signature checker.** The web page has an in-browser checker: paste the raw body, the webhook's `signature` value and the received `WebHook-Signature` header, and it computes the signature with Web Crypto; nothing leaves the page. To check a delivery yourself, compute HMAC-SHA256 over the exact request body bytes, keyed with the `signature` value as UTF-8, write it as lowercase hex (64 characters), and compare it with the `WebHook-Signature` header in constant time. `WebHook-Signature-Version` is `v1`.

**TypeScript**

```ts
// WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the
// webhook's signature value. Check the bytes as received, before parsing, in constant time.
function verifySignature(rawBody: Buffer, header: string | undefined, secret: string): boolean {
  if (!header) return false;
  const expected = Buffer.from(createHmac("sha256", secret).update(rawBody).digest("hex"));
  const received = Buffer.from(header.trim().toLowerCase());
  return expected.length === received.length && timingSafeEqual(expected, received);
}
```

**Python**

```python
# WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the
# webhook's signature value. Check the bytes as received, before parsing, in constant time.
def verify_signature(raw_body: bytes, header: Optional[str], secret: str) -> bool:
    if not header:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header.strip().lower())
```

## Verify in code

Read the raw body before any JSON parsing, compute the signature, and compare it with the header
in constant time. These are the functions the [webhook receiver recipe](/recipes/webhook-receiver/)
uses; in a shell, `openssl` computes the same value:

_This sample is REST only._

**REST · cURL**

```bash
# signature_of BODY: lowercase hex HMAC-SHA256 of the exact bytes, keyed with the webhook's
# signature value. verify_signature BODY HEADER: succeeds when HEADER is that signature.
signature_of() {
  printf '%s' "$1" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.*= //'
}
verify_signature() {
  [ "$(signature_of "$1")" = "$(printf '%s' "$2" | tr -d '[:space:]' | tr 'A-F' 'a-f')" ]
}
```

**REST · TypeScript**

```ts
// WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the
// webhook's signature value. Check the bytes as received, before parsing, in constant time.
function verifySignature(rawBody: Buffer, header: string | undefined, secret: string): boolean {
  if (!header) return false;
  const expected = Buffer.from(createHmac("sha256", secret).update(rawBody).digest("hex"));
  const received = Buffer.from(header.trim().toLowerCase());
  return expected.length === received.length && timingSafeEqual(expected, received);
}
```

**REST · Python**

```python
# WebHook-Signature is the lowercase hex HMAC-SHA256 of the raw request body, keyed with the
# webhook's signature value. Check the bytes as received, before parsing, in constant time.
def verify_signature(raw_body: bytes, header: Optional[str], secret: str) -> bool:
    if not header:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header.strip().lower())
```

- In frameworks that parse JSON for you, ask for the raw body: in Express,
  `express.raw({ type: "application/json" })` instead of `express.json()`; in Flask,
  `request.get_data()`.
- Answer a delivery that fails the check with 401. Never with 403 or 410: either disables the
  webhook at once, with no retry and no notice ([delivery and retries](/webhooks/delivery-and-retries/)).

## Replays and duplicates

> **The signature does not stop replays:** There is no timestamp or nonce in the signed data, and no delivery ID. Anyone who captures a
> delivery can send it again, and it still verifies.

What the body does give you, inside the signed bytes:

- **`event_time`**, when the change was saved, in UTC without an offset. A retry sends the same
  body for about 24 hours, so drop events older than 25 hours: anything older is a replay.
- **`event`, `entity.id`, `entity.revision` and `event_time`** together identify an event. Keep
  the ones you processed for at least 25 hours and skip a repeat: a retry and a replay look the
  same.
- **`team_space_id`**: when one receiver serves several spaces, check that it is the space the
  secret belongs to.

If you trim the record with `load-only`, keep `id` and `revision` in it. The
[webhook receiver recipe](/recipes/webhook-receiver/) does all of this, and its self-test sends a
retry, an old event and a forgery to prove it.

## Keep the secret secret

- Every read of a webhook returns its `signature`. Keep those responses out of logs, and give the
  **Webhooks** permission only to the applications that manage webhooks
  ([permissions and scopes](/get-started/permissions-and-scopes/)).
- Use `https://` URLs and keep `insecure_ssl` at `false`. The API accepts `http://`, but then the
  body and its signature travel in clear text.
- To rotate the secret, update the webhook with a new `signature`. A retry carries the signature
  it was first sent with, so accept the old secret too for about 24 hours.

## Limits

- Version v1 is the only scheme. It has no timestamp, nonce or delivery ID.
- Coevera does not sign deliveries of a webhook without a `signature`.
- The checker on this page verifies v1 only.
