Set it up
- Create an endpoint. In the console, open a project and add a webhook endpoint (or create it over the API, the CLI or MCP). Copy its URL,
https://app.hookie.ai/{workspace}/{project}/{webhook}. - Point GitHub at it. In your repository or organization → Settings → Webhooks → Add webhook (content type application/json), paste the URL and pick the events you want, for example push, pull_request, issues, release.
- Turn on signature verification. Edit the endpoint, set Verification to GitHub, and paste the value you type into the webhook's Secret field. From then on a request without a valid
X-Hub-Signature-256gets a401and nothing is stored or counted. - Forward it to your service. Add a destination: any HTTPS URL, Slack, S3-compatible storage, SQS or Pub/Sub. Each delivery is signed, retried for about a day, and replayable.
Suggested routing
Each endpoint files what it receives into one dataset, through its own criteria. The GitHub preset suggests these; leave the criteria empty to keep every event in one dataset instead.
| Endpoint | Criteria | Dataset |
|---|---|---|
| Pushes to main | ref = refs/heads/main | gh_pushes |
| Pull requests opened | action = opened | gh_pull_requests |
What Hookie checks
GitHub sends sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the webhook's secret. Hookie recomputes it and compares in constant time. GitHub signs no timestamp, so there is no age check.
An event the criteria do not match is still answered 201 and kept as a submission, marked filtered, so you can see what was turned away and why.
Verify what Hookie forwards
Your service should check Hookie's signature the same way Hookie checks GitHub's. Every delivery to an HTTPS destination carries Hookie-Signature, keyed with that destination's signing secret (whsec_…, which an owner or admin can reveal on the destination). It also carries the Standard Webhooks headers, so any Standard Webhooks library verifies it too.
import crypto from "node:crypto";
// Hookie-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
// keyed with your destination's signing secret (whsec_...). During a secret
// rotation it carries one v1 per secret, so accept the request if ANY matches.
export function verifyHookie(rawBody, header, secret, toleranceSeconds = 300) {
if (!header) return false;
const parts = header.split(",").map((p) => p.trim().split("="));
const t = parts.find(([k]) => k === "t")?.[1];
const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v);
if (!t || !signatures.length) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return signatures.some((s) => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}Wired into an Express receiver, with the raw body the signature covers:
import express from "express";
import { verifyHookie } from "./verify-hookie.js";
const app = express();
// The RAW body: a signature is over the exact bytes sent, not re-serialised JSON.
app.post("/hooks/hookie", express.raw({ type: "*/*" }), (req, res) => {
const body = req.body.toString("utf8");
if (!verifyHookie(body, req.get("Hookie-Signature"), process.env.HOOKIE_SIGNING_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(body);
// Retries repeat Hookie-Delivery-Id; dedupe the same event on Hookie-Event-Id.
console.log(req.get("Hookie-Event-Id"), event);
res.sendStatus(200);
});
app.listen(3000);Check GitHub's signature yourself
Hookie does this for you on ingest. If you also receive GitHub directly somewhere else, this is the same check in plain Node, with no dependencies:
import crypto from "node:crypto";
// X-Hub-Signature-256: sha256=<hex HMAC-SHA256 of the raw body>
export function verifyGitHub(rawBody, header, webhookSecret) {
const match = /^sha256=([0-9a-f]{64})$/i.exec(header?.trim() ?? "");
if (!match) return false;
const expected = crypto.createHmac("sha256", webhookSecret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(match[1].toLowerCase()), Buffer.from(expected));
}Next: the quickstart, pricing, or how Hookie compares with Hookdeck, Svix and Formspree. Create a free endpoint and send GitHub a test event.