# Verifying webhook signatures (HMAC-SHA256)

**Category:** [Webhooks](https://help.totalctrl.app/hc/totalctrl/totalctrl-help-center/en-US/categories/platform-webhooks)
**Updated:** 2026-10-04

## Why a signature is needed

Your endpoint has to be reachable from the public internet, so anyone who learns its address can POST to it. The signature is what separates a delivery from TotalCtrl from a request that merely looks like one.

Every delivery is signed with **HMAC-SHA256** using the signing secret shown once when you created the subscription. Because only you and TotalCtrl hold that secret, a matching signature proves both that the request came from us and that the body was not altered on the way.
## What arrives

Every delivery is a `POST` with these headers:HeaderExampleWhat it is`X-TotalCtrl-Signature``sha256=9f86d081…`The HMAC. Note the `sha256=` prefix — it is part of the value.`X-TotalCtrl-Event``note.created`The event name, also present in the body.`X-TotalCtrl-Hook-Id``42`Which of your subscriptions this came from.`Content-Type``application/json``User-Agent``TotalCtrl-Webhooks/1.0`Never use this to authenticate — anyone can send it.

The body is a JSON envelope:
```
{
  "event": "note.created",
  "timestamp": "2026-09-02T14:31:07.482913Z",
  "account_id": "dd000e97-10bf-48d7-b04a-d08cb3dd0e70",
  "data": { }
}
```

## How to verify

Compute HMAC-SHA256 over the **exact bytes of the request body**, using your signing secret as the key, hex-encode it, prepend `sha256=`, and compare against the header with a constant-time comparison.
### Python (Flask)

```
import hmac, hashlib
from flask import request, abort

SECRET = os.environ['TOTALCTRL_WEBHOOK_SECRET']

@app.post('/hooks/totalctrl')
def hook():
    body = request.get_data()          # BYTES, before any parsing
    expected = 'sha256=' + hmac.new(
        SECRET.encode('utf-8'), body, hashlib.sha256
    ).hexdigest()
    sent = request.headers.get('X-TotalCtrl-Signature', '')
    if not hmac.compare_digest(expected, sent):
        abort(401)

    payload = request.get_json()       # only AFTER the check passes
    # ... queue the work, then return quickly
    return '', 204
```

### Node (Express)

Express parses and discards the raw body by default, so ask for it explicitly:
```
const crypto = require('crypto');

app.post('/hooks/totalctrl',
  express.raw({ type: 'application/json' }),   // Buffer, not an object
  (req, res) => {
    const expected = 'sha256=' + crypto
      .createHmac('sha256', process.env.TOTALCTRL_WEBHOOK_SECRET)
      .update(req.body)
      .digest('hex');
    const sent = req.get('X-TotalCtrl-Signature') || '';
    const ok = expected.length === sent.length && crypto.timingSafeEqual(
      Buffer.from(expected), Buffer.from(sent));
    if (!ok) return res.sendStatus(401);

    const payload = JSON.parse(req.body.toString('utf8'));
    res.sendStatus(204);
  });
```

### PHP

```
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, getenv('TOTALCTRL_WEBHOOK_SECRET'));
$sent = $_SERVER['HTTP_X_TOTALCTRL_SIGNATURE'] ?? '';
if (!hash_equals($expected, $sent)) { http_response_code(401); exit; }
```

## The mistake almost everyone makes first

**Sign the raw body, never a re-serialized copy of it.** If your framework parses the JSON and you re-encode the object to get bytes back, you will produce different bytes — a different key order, different spacing, different unicode escaping — and every signature will fail while the payload looks identical on screen. Capture the body before anything touches it.

The same applies to middleware that rewrites the body: compression, a proxy that pretty-prints JSON, or a body-parser that strips whitespace will all break the check.
## Use a constant-time comparison

`==` on strings returns as soon as two characters differ, and how long it took is measurable over enough requests. Use `hmac.compare_digest`, `crypto.timingSafeEqual` or `hash_equals`. All three are in the examples above.
## Rejecting replays

A valid signed request stays valid forever, so someone who captures one can send it again. The `timestamp` field is *inside* the signed body, so it cannot be altered without breaking the signature — which makes it usable as a freshness check. After verifying the signature, reject anything older than a few minutes:
```
from datetime import datetime, timezone, timedelta

sent_at = datetime.fromisoformat(payload['timestamp'].replace('Z', '+00:00'))
if datetime.now(timezone.utc) - sent_at > timedelta(minutes=5):
    abort(401)
```

Order matters: check the signature first. The timestamp is only trustworthy once you know the body is.
## Looking after the secret

- **It is shown once.** The page after you subscribe is the only place it appears; we store it to sign with and never display it again. If you lose it, delete the subscription and create a new one.
- **Each subscription has its own secret.** Verify against the one belonging to the subscription in `X-TotalCtrl-Hook-Id`, not a single shared value.
- **Keep it out of your repository.** An environment variable or your secret manager — the same care as a database password.

## Delivery behavior worth designing around

- **There are no automatic retries.** Each event is attempted once. If your endpoint is down, that delivery is recorded as failed and is not sent again — so treat webhooks as a fast path, not as your only source of truth, and reconcile through the REST API if you need a guarantee.
- **The timeout is 10 seconds.** Return a 2xx as soon as you have verified and stored the payload, and do the real work afterwards. Anything slower is recorded as a timeout even if it eventually succeeded.
- **Any 2xx counts as success.** Everything else is logged with the status and the first 2000 characters of your response body, visible under **Deliveries** — which is the fastest way to debug a failing endpoint.
- **Deliveries are not ordered.** Each event is dispatched on its own, so two events fired close together can arrive in either order. Use the `timestamp` if order matters to you.

---

## Related Articles

- [Webhook signature checks that fail, and why](https://help.totalctrl.app/en-US/articles/webhooks-signature-troubleshooting)
- [Events you can now subscribe to that you could not before](https://help.totalctrl.app/en-US/articles/webhooks-newly-available-events)
- [Every offered webhook event now actually fires](https://help.totalctrl.app/en-US/articles/webhooks-all-events-live)
- [Events you can now subscribe to that you could not before](https://help.totalctrl.app/en-US/articles/webhooks-newly-available-events-1)
- [Issue events now fire for issues created through the API](https://help.totalctrl.app/en-US/articles/webhooks-issues-api-event-names)

---
[← Back to TotalCtrl Help Center](https://help.totalctrl.app/en-US/)