In short
- Four libraries: Node, Python, PHP, Go. One file each, no dependencies.
- All four are tested against the same vectors the server signs with, so they cannot drift.
- Verify the raw request body — not a re-serialised object. That is the mistake that costs an afternoon.
Get the file
Copy it into your project. Nothing to install, nothing to keep up to date.
| Language | File | Requires |
|---|---|---|
| Node.js | hookget.mjs · types | Node 18+ |
| Python | hookget.py | Python 3.8+, standard library only |
| PHP | hookget.php | PHP 7.4+ |
| Go | hookget.go | Go 1.21+ |
Apache-2.0. The shared test vectors are at
/sdk/vectors.json if you would rather write your own.
Node
import express from 'express';
import { verify, HookgetVerificationError } from './hookget.mjs';
const app = express();
// express.raw, not express.json: the signature covers the bytes that arrived.
app.post('/hooks', express.raw({ type: '*/*' }), (req, res) => {
try {
verify(process.env.HOOKGET_SECRET, req.headers, req.body);
} catch (err) {
if (err instanceof HookgetVerificationError) return res.status(400).send(err.reason);
throw err;
}
res.sendStatus(200); // acknowledge first
queueForProcessing(JSON.parse(req.body)); // work afterwards
});
Python
from flask import Flask, request
from hookget import verify, VerificationError
app = Flask(__name__)
@app.post("/hooks")
def hooks():
try:
# get_data(), not get_json(): the signature covers the bytes that arrived.
verify(os.environ["HOOKGET_SECRET"], request.headers, request.get_data())
except VerificationError as err:
return err.reason, 400
enqueue(request.get_json()) # acknowledge first, work afterwards
return "", 200
PHP
<?php
require __DIR__ . '/hookget.php';
// php://input, not $_POST: the signature covers the bytes that arrived.
$raw = file_get_contents('php://input');
try {
\HookGet\verify(getenv('HOOKGET_SECRET'), getallheaders(), $raw);
} catch (\HookGet\VerificationError $e) {
http_response_code(400);
exit($e->reason);
}
http_response_code(200); // acknowledge first
enqueue(json_decode($raw, true)); // work afterwards
Go
func hooks(w http.ResponseWriter, r *http.Request) {
// Read the body before decoding it: the signature covers these bytes.
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
if _, err := hookget.Verify(os.Getenv("HOOKGET_SECRET"), r.Header, body, hookget.Options{}); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK) // acknowledge first
go process(body) // work afterwards
}
Why they cannot drift
Four implementations of the same HMAC in four languages agree on the easy cases and disagree on the ones that matter.
So none of them is trusted on its own. A vector file is generated from the server's own signer and every library is checked against it in CI — including a body of Hebrew, an emoji and an accented Latin word, which is precisely where a byte-versus-character mistake stops being theoretical.
POST /hooks HTTP/1.1webhook-id: msg_01J8ZQ3F9VBAQ4E1S0TZY6P8YV
Stable across every retry — use it as your idempotency keywebhook-timestamp: 1755264000
Outside a 5-minute window, refuse it — that is the replay guardwebhook-signature: v1,ZeHt1v… v1a,mK4p…
HMAC and ed25519 side by side during a scheme change, space separated{"type":"order.created","timestamp":"…","data":{…}}
Signed content is id.timestamp.body — the exact bytes, never a re-serialisationIf you write your own verifier, check it against the same file. The signed content is
{id}.{timestamp}.{body} for both schemes. With a whsec_ secret it is
HMAC-SHA256 over the secret's decoded bytes, base64-encoded, presented as
v1,<signature>. With a whpk_ key it is ed25519 over the same
bytes, presented as v1a,<signature> — that key verifies and cannot sign,
which is why it is safe to hand to a partner or an auditor. An endpoint switching schemes
sends both at once, so nobody has to change in the same second you do.
Questions
Start delivering webhooks today
Point your webhooks at HookGet and watch the first delivery arrive, signed, in under a minute.
Create a free account Try the free webhook tester
10,000 deliveries a month free, no credit card. The free tier blocks rather than bills, so trying it cannot produce an invoice.