GitHat Webhooks
Developer reference for GitHat's outbound webhook system: registration, event types, signature verification, delivery, and retries.
Authentication
Every webhook management request requires two headers working together. The `X-GitHat-App-Key` header carries your publishable key (a `pk_live_` string) and identifies which app the webhook belongs to. A session JWT in `Authorization: Bearer <token>` then proves you are an admin (or member for read operations) of the org that owns that app.
Read operations (GET /webhooks, GET /webhooks/:id, GET /webhooks/:id/deliveries) require org membership. Write operations (POST, PATCH, DELETE) require org admin role. Using the publishable key alone is rejected — this is an explicit cross-tenant guard in the handlers.
Registering a Webhook
POST /webhooks creates a webhook scoped to the app identified by your X-GitHat-App-Key. Supply a destination URL and an array of event types to subscribe to.
The URL must be HTTPS (plain HTTP is rejected unless the env var WEBHOOK_ALLOW_HTTP=1 is set, which is for development only). The URL is also DNS-resolved at registration time to block private IPs (RFC 1918, 169.254.x.x, loopback, and similar) as an SSRF defence. The same check repeats on every delivery attempt.
The response body includes the signing secret in the `secret` field. This is the only time the secret is ever returned — it is not exposed in GET /webhooks or GET /webhooks/:id. Store it immediately.
POST /webhooks
X-GitHat-App-Key: pk_live_<your-key>
Authorization: Bearer <session-token>
Content-Type: application/json
{
"url": "https://your-server.example.com/githat-events",
"events": ["user.created", "user.signed_in"]
}Create Response
A successful 200 response has the following shape. The `secret` is only present here.
{
"id": "<uuid>",
"url": "https://your-server.example.com/githat-events",
"events": ["user.created", "user.signed_in"],
"isActive": true,
"createdAt": "2026-06-16T12:00:00.000Z",
"secret": "<64-hex-char-secret>"
}Event Types
The following values are accepted in the `events` array. Any other value is rejected with an error listing the unknown types.
- `user.signed_in` — a user completed sign-in to your app - `user.created` — a new user account was created - `user.mfa_enabled` — a user enabled MFA - `user.mfa_disabled` — a user disabled MFA - `user.passkey_added` — a passkey was registered - `user.passkey_removed` — a passkey was removed - `user.email_verified` — a user's email address was verified - `session.revoked` — a session was revoked - `audit.*` — all audit log events (wildcard for the audit namespace) - `*` — every event type (subscribe to all)
Payload Shape
Every delivery is a JSON POST. The body is an envelope that merges the event-specific payload with two fixed top-level fields: `event` (the event type string, e.g. `user.created`) and `deliveryId` (a UUID that uniquely identifies this delivery attempt). The total serialized payload must not exceed 256 KB; oversized payloads are dropped and logged rather than delivered.
The exact fields under the event-specific payload depend on the event type. The delivery system deliberately excludes tokens, secrets, and hashes from payloads.
{
"event": "user.created",
"deliveryId": "<uuid>",
// ...event-specific fields merged in here
}Signature Verification
Every delivery carries a `Webhook-Signature` header with a Stripe-style timestamped HMAC-SHA256 signature. The format is `t=<unix-seconds>,v1=<hex-digest>`. During a secret rotation grace window there can be two v1 entries: `t=<sec>,v1=<primary-hex>,v1=<secondary-hex>`.
To verify: 1. Split the header on commas to extract `t` (the Unix timestamp in seconds) and all `v1` values. 2. Reject if the timestamp is more than 5 minutes from the current time (replay-window check). 3. Reconstruct the signed string: `<t>.<raw-request-body>` (the literal timestamp followed by a period followed by the UTF-8 request body exactly as received — do not parse or re-serialize JSON). 4. Compute `HMAC-SHA256(secret, signed_string)` and hex-encode it. 5. Compare in constant time against each `v1` value. Accept if any matches.
Additional headers sent on every delivery:
- `Webhook-Id` — the `deliveryId` UUID, usable as an idempotency key - `Webhook-Timestamp` — the delivery timestamp in milliseconds (different from the `t=` value which is in seconds) - `Webhook-Event-Type` — the event type string, e.g. `user.created` - `User-Agent` — `GitHat-Webhooks/1.0` - `Content-Type` — `application/json`
const crypto = require('crypto');
function verifyGitHatWebhook(rawBody, signatureHeader, secret, toleranceMs = 5 * 60 * 1000) {
// Parse t=<sec>,v1=<hex>[,v1=<hex>]
let t = null;
const signatures = [];
for (const part of signatureHeader.split(',')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const key = part.slice(0, eq).trim();
const val = part.slice(eq + 1).trim();
if (key === 't') t = parseInt(val, 10);
else if (key === 'v1') signatures.push(val);
}
if (!t || signatures.length === 0) throw new Error('malformed_header');
// Replay-window check
if (Math.abs(Date.now() - t * 1000) > toleranceMs) throw new Error('timestamp_outside_tolerance');
// Compute expected HMAC
const signed = `${t}.${rawBody}`;
const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex');
// Constant-time comparison against every v1 value
for (const sig of signatures) {
if (sig.length === expected.length) {
const sigBuf = Buffer.from(sig, 'hex');
const expBuf = Buffer.from(expected, 'hex');
if (sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf)) return true;
}
}
throw new Error('signature_mismatch');
}Delivery and Retries
The delivery system makes up to 3 attempts per event. The per-attempt HTTP timeout is 10 seconds. Between attempts it uses exponential backoff: the base delay is `min(30s, 1s * 2^(attempt-1))` plus up to 500ms of random jitter. If the subscriber responds with a `Retry-After` header (in seconds or HTTP-date format), that value overrides the exponential backoff for that retry, capped at 30 seconds and floored at 500ms.
Retries happen on network errors, timeouts (status 0), all 5xx responses, 408 (Request Timeout), and 429 (Too Many Requests). Any 4xx response other than 408/429 is treated as a permanent client error and no further attempts are made.
HTTP redirects are not followed. A 3xx response is treated as non-success.
After 5 consecutive failed deliveries across all events, the webhook is automatically set to `isActive: false` and a `WEBHOOK_AUTO_DISABLED` audit event is written. A successful delivery resets the consecutive-failure counter.
Each delivery is recorded in the GitHat-WebhookDeliveries table and retained for 30 days. You can retrieve the 50 most recent delivery records (newest first) from GET /webhooks/:id/deliveries.
Delivery Records
GET /webhooks/:id/deliveries returns up to 50 delivery records, sorted newest-first. Each record includes the following fields: `webhookId`, `deliveryId`, `event`, `payload`, `statusCode`, `response` (up to 1024 chars of the response body, present only on failure), `attemptCount`, `deliveredAt` (ISO 8601 timestamp), and `ttl` (Unix epoch, 30 days from delivery).
GET /webhooks/<webhook-id>/deliveries
X-GitHat-App-Key: pk_live_<your-key>
Authorization: Bearer <session-token>Secret Rotation
PATCH /webhooks/:id with `rotateSecret: true` generates a new primary secret and demotes the current secret to a secondary slot with a grace window (default 7 days, minimum 60 seconds, maximum 30 days). During the grace window, deliveries are dual-signed: the `Webhook-Signature` header contains two `v1=` entries, one for the new primary and one for the old secondary. Your receiver can verify against either secret.
Once you have updated your receiver to use the new secret, call PATCH /webhooks/:id with `rotateSecret: "finalize"` to clear the secondary slot and end the grace window.
The new primary secret is returned in the PATCH response body (once only, same contract as create). The old secondary secret is never returned in any response.
PATCH /webhooks/<webhook-id>
X-GitHat-App-Key: pk_live_<your-key>
Authorization: Bearer <session-token>
Content-Type: application/json
{"rotateSecret": true, "rotationGraceMs": 604800000}Other Management Endpoints
GET /webhooks — list all webhooks for the app. Signing secrets are stripped from list results.
GET /webhooks/:id — fetch a single webhook. Signing secret is not returned.
PATCH /webhooks/:id — update `url`, `events`, or `isActive`. Any combination of the three fields can be included in a single request.
DELETE /webhooks/:id — permanently delete the webhook and stop all future deliveries.
URL Requirements
Webhook URLs must be HTTPS. Plain HTTP is only accepted when the `WEBHOOK_ALLOW_HTTP=1` environment variable is set on the Lambda (intended for development environments only). The hostname is DNS-resolved at registration and again on each delivery attempt; URLs that resolve to private IPv4 ranges (10.x, 172.16-31.x, 192.168.x), loopback (127.x), link-local (169.254.x), or private IPv6 ranges (::1, fc00::/7, fe80::/10) are rejected. Redirects are not followed at delivery time.