The bug usually looks like a stale dashboard.
Someone closes a GitHub issue. GitHub sent the webhook, or at least your system expected it to. Your app still shows the issue as open. A bot keeps trying to triage it. A release checklist says one blocker remains, even though the repo moved on hours ago.
Nothing is broken in the first API call. The issue exists. The state changed. Your local view is the part that drifted.
The webhook-only handler is too trusting
The tempting handler is small:
type GitHubIssueWebhook = {
action: "opened" | "edited" | "closed" | "reopened";
issue: {
number: number;
state: "open" | "closed";
title: string;
updated_at: string;
};
repository: {
owner: { login: string };
name: string;
};
};
export async function handleIssueWebhook(event: GitHubIssueWebhook) {
await issueStore.upsert({
repo: `${event.repository.owner.login}/${event.repository.name}`,
number: event.issue.number,
state: event.issue.state,
title: event.issue.title,
updatedAt: event.issue.updated_at,
});
}
That works until it doesn't.
Webhooks are delivery, not a database replication stream. Your handler can return a non-2xx. A deploy can race the delivery. A queue can drop a job. A retry window can end before your system recovers. Or the event arrives, but another state change lands before your worker processes it.
If your app treats the webhook payload as final, the local issue state can drift from GitHub.
Treat the webhook as a reason to read
For issue automation, the webhook should wake up your reconciler. It should not be the whole reconciler.
type GitHubIssueSignal = {
issue: { number: number };
repository: {
owner: { login: string };
name: string;
};
};
export async function handleIssueWebhook(event: GitHubIssueSignal) {
const owner = event.repository.owner.login;
const repo = event.repository.name;
const number = event.issue.number;
const issue = await github.rest.issues.get({
owner,
repo,
issue_number: number,
});
await issueStore.reconcile({
repo: `${owner}/${repo}`,
number,
githubId: issue.data.id,
state: issue.data.state,
title: issue.data.title,
updatedAt: issue.data.updated_at,
closedAt: issue.data.closed_at,
});
}
Now the local state is based on GitHub's current issue record, not whatever payload happened to arrive.
That read also gives you a clean recovery path when no webhook arrives. A scheduled reconciler can walk the issues your app still thinks are active and call GET /repos/{owner}/{repo}/issues/{issue_number} before deciding the work is still blocked.
What to store locally
At minimum, store enough to make reconciliation idempotent:
- repository full name
- issue number
- GitHub issue ID
- current local state
- last GitHub
updated_at - last successful reconciliation time
- whether your automation has already acted on the terminal state
The terminal-state flag matters. If your bot posts a "blocker cleared" comment after an issue closes, it should not post that comment twice because GitHub retried a webhook or your scheduled reconciler saw the same closed issue again.
The test most examples skip
Most GitHub examples stop at create:
const created = await github.rest.issues.create({
owner,
repo,
title: "Fix rate limiter timeout under load",
});
expect(created.status).toBe(201);
That only proves you can create an issue.
The test that catches production drift is:
- create the issue
- read it back and store the issue number
- close it
- read it back again before changing local automation state
That is the flow your app needs when a webhook arrives late, twice, or not at all.
Receipts
Here's a real GitHub issue lifecycle workflow run against the FetchSandbox sandbox, captured 2026-06-11:
POST /repos/acme-corp/api-gateway/issues → 201
id: 6
GET /repos/acme-corp/api-gateway/issues/6 → 200
id: 6
PATCH /repos/acme-corp/api-gateway/issues/6 → 200
id: 6
Full timeline: fetchsandbox.com/runs/ef6e143a92?flow=run_54503218-adbf-4244-97b5-bcf183a12d49
The issue number 6 is the field your webhook handler and scheduled reconciler should agree on. The middle GET is the part most happy-path tests skip, but it is also the escape hatch when webhook delivery is not enough.
How to test this without a live repo
For GitHub automation, the finish line is not "webhook received." It is "the app can prove the issue state it is about to act on."
We use FetchSandbox to run the issue lifecycle before wiring production handlers. The point is not to replace GitHub's real webhook delivery. The point is to make the reconciliation rule obvious before a missed issues.closed event leaves your automation arguing with reality.