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.
If you write your own verifier, check it against the same file. The signed content is
{id}.{timestamp}.{body}, HMAC-SHA256 with the secret's decoded bytes as the
key, base64-encoded, presented as v1,<signature>.