# Signature verification

> How receivers verify that a webhook came from Webhook Admin, using the official Standard Webhooks libraries.

Source: https://webhookadmin.com/en/docs/sdk/

Every webhook is signed in the [Standard Webhooks](https://www.standardwebhooks.com/) format. Receivers verify it with the official libraries.

## Request format

```
POST https://example.com/webhooks
content-type: application/json
webhook-id: msg_2Zq8…
webhook-timestamp: 1790000000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
user-agent: Webhook Admin/1

{ "type": "invoice.paid", "timestamp": "2026-09-27T01:32:05.000Z", "data": { "invoice_id": "inv_88", "amount": 128000 } }
```

| Header | Value |
| --- | --- |
| `webhook-id` | Message ID (`msg_…`). Unchanged across retries |
| `webhook-timestamp` | Send time (Unix seconds) |
| `webhook-signature` | `v1,<base64>`. Two, space-separated, while a secret is being rotated |

The body is `{ "type", "timestamp", "data" }`, where `data` is the `payload` of `POST /v1/messages`.

## Official libraries

| Language | Package |
| --- | --- |
| JavaScript / TypeScript | `npm install standardwebhooks` |
| Python | `pip install standardwebhooks` |
| Go | `go get github.com/standard-webhooks/standard-webhooks/libraries/go` |
| Ruby | `gem install standardwebhooks` |
| Java / Kotlin | `com.standardwebhooks:standardwebhooks` (Maven Central) |
| Rust | `cargo add standardwebhooks` |
| C# | `dotnet add package StandardWebhooks.StandardWebhooks` |
| PHP・Elixir | [github.com/standard-webhooks/standard-webhooks](https://github.com/standard-webhooks/standard-webhooks) |

The secret is the endpoint’s `secret` (`whsec_…`). Pass it to the library as is.

**Node.js**

```js
import express from 'express';
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.WEBHOOK_SECRET); // whsec_…
const app = express();

// Verify against the raw body, before any JSON parsing
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = wh.verify(req.body, req.headers);
    console.log(event.type, event.data);
    res.sendStatus(200);
  } catch {
    res.sendStatus(400);
  }
});

app.listen(3000);
```

**Python**

```python
import os
from flask import Flask, request
from standardwebhooks import Webhook

wh = Webhook(os.environ["WEBHOOK_SECRET"])  # whsec_…
app = Flask(__name__)

@app.post("/webhooks")
def webhooks():
    try:
        event = wh.verify(request.get_data(), dict(request.headers))
    except Exception:
        return "", 400
    print(event["type"], event["data"])
    return "", 200
```

The official libraries reject signatures whose timestamp is more than 5 minutes off.

## SDK

The SDKs send messages, manage endpoints and verify signatures on the receiving side. When the API key is omitted, they read `WEBHOOK_ADMIN_API_KEY`.

| Language | Install |
| --- | --- |
| Go | `go get github.com/shannonllc/webhookadmin-go` |
| PHP | `composer require shannonllc/webhookadmin` |

**Go**

```go
import webhookadmin "github.com/shannonllc/webhookadmin-go"

client, err := webhookadmin.NewClient(os.Getenv("WEBHOOK_ADMIN_API_KEY"))
message, err := client.Messages.Send(ctx, webhookadmin.SendMessageParams{
	Consumer:  "cus_1024",
	EventType: "invoice.paid",
	Payload:   map[string]any{"invoice_id": "inv_88", "amount": 128000},
}, webhookadmin.WithIdempotencyKey("inv_88-paid"))

wh, err := webhookadmin.NewWebhook(os.Getenv("WEBHOOK_SECRET"))
event, err := wh.Verify(body, r.Header)
```

**PHP**

```php
use WebhookAdmin\Client;
use WebhookAdmin\Webhook;

$wha = new Client(getenv('WEBHOOK_ADMIN_API_KEY'));
$message = $wha->messages->send(
    ['consumer' => 'cus_1024', 'event_type' => 'invoice.paid', 'payload' => ['invoice_id' => 'inv_88', 'amount' => 128000]],
    ['idempotency_key' => 'inv_88-paid'],
);

$wh = new Webhook(getenv('WEBHOOK_SECRET'));
$event = $wh->verify(file_get_contents('php://input'), $_SERVER);
```

## Signing scheme

1. The signed content is `{webhook-id}.{webhook-timestamp}.{body}`, using the body exactly as received
2. The key is the base64-decoded part after `whsec_`
3. Base64-encode the HMAC-SHA256 result and prefix it with `v1,`
4. The request is authentic if any signature in `webhook-signature` matches

**Without a library (Node.js)**

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

function verify(secret, headers, body) {
  const id = headers['webhook-id'];
  const ts = headers['webhook-timestamp'];
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${ts}.${body}`).digest('base64');
  // During rotation, signatures are space-separated
  return headers['webhook-signature'].split(' ').some((s) => {
    const [version, sig] = s.split(',');
    return version === 'v1' && sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}
```

## Responses and retries

- Any 2xx is a success. Other responses, a 15-second timeout or a connection failure are retried.
- By default, attempts are made immediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours (up to 8 attempts including the first, over about 28 hours). You can choose the count and interval per endpoint ([Retry policy](https://webhookadmin.com/en/docs/api/#retry-policy)).
- Redirects are not followed.
- The same `webhook-id` can arrive more than once. Drop IDs you have already processed.
- Endpoints failing for 5 days are disabled automatically.

## Rotating secrets

After you rotate with `POST /v1/endpoints/{id}/rotate-secret` or in the dashboard, deliveries carry a signature with the old secret too for 24 hours. Switch the receiver to the new secret within that window.

## Compatibility signature

While you move receivers that verify a custom signature, you can add one extra signature header per endpoint. The Standard Webhooks headers are always sent. Set it in the dashboard, the API (`compat_signature`) or MCP; the customer portal shows it read-only.

| Field | Value |
| --- | --- |
| `header` | Header name (lowercase, up to 64 characters). Names used for delivery, such as `webhook-*`, `content-type` and `authorization`, are not allowed |
| `content` | `body` signs the body only; `timestamp_body` signs `{webhook-timestamp}.{body}` |
| `encoding` | `hex` or `base64` |
| `prefix` | Optional. Prepended to the value, such as `sha256=`. ASCII without spaces, up to 32 characters |

- HMAC-SHA256. The key is the signing secret string (the whole `whsec_…`) as UTF-8.
- During a rotation, only the new secret is used.
- Test messages carry it too. Pass `null` to remove it.

**Same as GitHub’s X-Hub-Signature-256**

```
{ "compat_signature": { "header": "x-hub-signature-256", "content": "body", "encoding": "hex", "prefix": "sha256=" } }

x-hub-signature-256: sha256=<hex>
```

**Verifying on the receiver (Node.js)**

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

function verifyCompat(body, header, secret) {
  // The key is the whsec_… string as is
  const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');
  return header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
```

`body` contains no timestamp, so **it cannot stop replay attacks** (resending a captured request as is). Use `timestamp_body` where you can, and move receivers to Standard Webhooks verification.
