Skip to content

GitBasedDocs

Green webhook deliveries, and nothing synced

10 September 2026 Updated 10 September 2026 4 min read
GitBasedDocs admin overview with recent sync runs

The GitBasedDocs webhook passed every test, then real pushes stopped updating the app while GitHub showed green deliveries. The causes were the hook URL and GitHub's default form content type.

The webhook is the fast path in GitBasedDocs. Push to the docs repo, GitHub calls POST /api/webhooks/github, and the page updates a few seconds later. It shipped on September 9 with checks that all passed. The next day, pushes to my docs repo were not updating the app, and GitHub's delivery log said everything was fine.

What the route does

The first version (commit ad35565) does its work in a strict order. The signature check comes before anything touches the database. GitHub signs the raw body with the webhook secret and sends sha256=<hex> in X-Hub-Signature-256:

const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex")
const a = Buffer.from(header)
const b = Buffer.from(expected)
// timingSafeEqual throws on a length mismatch, and the length itself is
// not a secret, so check it first.
if (a.length !== b.length) return false
return timingSafeEqual(a, b)

A plain === returns as soon as a byte differs, which in principle lets someone time the responses and guess the digest byte by byte. timingSafeEqual always compares every byte. The length guard is there because it throws on buffers of different sizes.

After the signature, the route answers ping with a pong, ignores events other than push, ignores pushes to branches it does not track, and skips a delivery id it has already seen. Then it replies 200 and runs the sync inside after() from next/server. The comment in the route says why: "GitHub gives up after 10 seconds, so this answers immediately and does the sync in after()". GitHub gets its answer without waiting out the tree walk.

The bug I found while building it

Testing that first version over HTTP turned up a problem in a different file. proxy.ts is the cookie gate: any request without a next-auth session cookie gets redirected to /login. GitHub sends no cookie. Every delivery would have been redirected and swallowed in production. The webhook authenticates with its HMAC signature instead, so /api/webhooks/ joined /login and /api/auth on the exemption list.

With that fixed, I checked every case against the running server. A bad signature and a missing header both returned 401. A push to feature-x came back {"skipped":"branch"}, an issues event {"skipped":"event"}, and the same delivery id twice {"skipped":"duplicate"}. A push to main returned {"queued":true}, and the run landed in sync_logs as webhook sha=67caeac +0/~0/-0 1242ms.

Green, and nothing synced

The next day I pushed real edits and the app did not change, while GitHub's recent deliveries list showed green. Two separate things were wrong, both in the webhook settings on the GitHub side, and one of them was also a gap in my code.

The first was the URL. I had set the hook to the site root, not /api/webhooks/github. Every push went to the home page, which answered 200, so the delivery log was green while nothing synced. That one was a settings fix.

The second was the content type. GitHub's webhook form offers application/json and application/x-www-form-urlencoded, and the default is form. With form, the body is not JSON. It is:

payload=%7B%22ref%22%3A%22refs%2Fheads%2Fmain%22...

My route called JSON.parse(raw) on that and returned 400 "Bad payload". So even with the right URL, a hook left on the default would have rejected every push. The check file had only ever sent JSON.

The fix

Commit 79ad4be added parsePayload:

// GitHub offers two content types and defaults to form. With form the body
// is "payload=<url-encoded JSON>"; the signature still covers the raw body,
// so verify first and parse second.
export function parsePayload(raw: string, contentType: string | null): unknown {
  const type = (contentType ?? "").toLowerCase()
  if (type.includes("application/x-www-form-urlencoded")) {
    const payload = new URLSearchParams(raw).get("payload")
    if (payload === null) throw new Error("Form body has no payload field")
    return JSON.parse(payload)
  }
  return JSON.parse(raw)
}

The order in that comment is the part that matters. The HMAC is computed over the bytes GitHub sent, which for a form hook is the payload=... string. If the route decoded the form first and signed the JSON inside it, every form delivery would fail the signature check. So the route reads the body once with request.text(), verifies that exact string, and only then parses it.

The check file gained assertions for both shapes. JSON and form bodies parse to the same object, a missing content type falls back to JSON, a form body without a payload field throws, and a signature over the raw form body verifies:

const form = "payload=" + encodeURIComponent(JSON.stringify(json))
assert.deepEqual(parsePayload(form, "application/x-www-form-urlencoded"), json)
assert.equal(verifySignature(form, sign(form), SECRET), true)

To test the whole path, I sent a signed, form-encoded push through the Tailscale URL the app runs on. It returned {"queued":true}, and the sync caught the index up from 67caeac to 3466f1d (~1 changed, -1 removed).

Why duplicates are only an optimisation

The route remembers the last 500 delivery ids in memory. That is not a correctness guard, and the comment says so. A repeat delivery would start a sync, and a sync against an unchanged head stops after one ref call. Losing the cache on restart costs one cheap request.