Webhook Guides
GitHub Pull Request Webhook
GitHub pull_request webhooks are the repository signals behind preview environments, deployment gates, CI checks, security scans, release validation, review automation, and PR cleanup jobs. They should be routed separately from push events because the payload shape and workflow intent are different.
FastHook gives PR automation a stable source URL, GitHub raw-body HMAC verification, accepted and rejected request records, connection filters, destination attempts, retry controls, and replayable failure evidence. The examples below match the current FastHook source HMAC, filter, and request inspection behavior.
Pull request delivery model
GitHub sends a pull_request webhook when activity happens on a pull request. Use X-GitHub-Event to choose the PR route, use X-GitHub-Delivery for traceability, verify X-Hub-Signature-256 against the exact raw body, then route by action,pull_request.base.ref, pull_request.head.sha, draft state, merge state, and repository identity.
Pull request headers
Headers tell you which provider event arrived, which GitHub delivery id to trace, and whether a GitHub webhook secret signature is present. FastHook stores these headers with the request record.
| Header | Example | Use |
|---|---|---|
X-GitHub-Event | pull_request | Choose the PR parser and route family before reading PR-specific fields. |
X-GitHub-Delivery | 72d3162e-cc78-11ec-9d64-0242ac120002 | Trace one provider delivery through request, event, attempt, retry, and replay records. |
X-Hub-Signature-256 | sha256=<hex-hmac> | Verify the exact raw request body with the GitHub webhook secret. |
X-GitHub-Hook-ID | 292430182 | Identify the GitHub webhook configuration that sent the delivery. |
Content-Type | application/json | Keep JSON payload delivery enabled so FastHook can filter body fields. |
POST /github HTTP/1.1
Host: hook-xxxxxx.fasthook.io
User-Agent: GitHub-Hookshot/1234567
Content-Type: application/json
X-GitHub-Event: pull_request
X-GitHub-Delivery: 72d3162e-cc78-11ec-9d64-0242ac120002
X-Hub-Signature-256: sha256=<hex-hmac-sha256>Pull request payload example
This sample shows the practical fields most PR receivers use: action, PR number, target branch, source branch, head SHA, draft state, merge state, repository, sender, and PR URL.
{
"action": "opened",
"number": 42,
"pull_request": {
"id": 1789443201,
"node_id": "PR_kwDOExample42",
"html_url": "https://github.com/acme/payments-api/pull/42",
"diff_url": "https://github.com/acme/payments-api/pull/42.diff",
"patch_url": "https://github.com/acme/payments-api/pull/42.patch",
"title": "Add webhook delivery dashboard",
"state": "open",
"draft": false,
"merged": false,
"mergeable": true,
"user": {
"login": "dev-user",
"id": 1234567
},
"head": {
"ref": "feature/webhook-dashboard",
"sha": "2c3d4e5f6a7b8c9d0e1f23456789abcdef012345",
"repo": {
"id": 987654321,
"full_name": "acme/payments-api"
}
},
"base": {
"ref": "main",
"sha": "5f6b7a8c9d0e1f23456789abcdef0123456789ab",
"repo": {
"id": 987654321,
"full_name": "acme/payments-api"
}
}
},
"repository": {
"id": 987654321,
"name": "payments-api",
"full_name": "acme/payments-api",
"default_branch": "main"
},
"sender": {
"login": "dev-user",
"id": 1234567
}
}Fields worth filtering and logging
Keep filters focused on stable, workflow-critical fields. Pull request payloads are deep, but most production receivers only need a small set of action, branch, SHA, state, and repository fields.
| Field | Example | How to use it |
|---|---|---|
action | opened | Decide whether to create, update, close, or ignore PR work. |
number | 42 | Human-facing PR number for URLs, logs, and support workflows. |
pull_request.id | 1789443201 | Stable provider id for idempotency and database joins. |
pull_request.base.ref | main | Target branch. Use it for deploy gates, preview policy, and release branch routing. |
pull_request.head.ref | feature/webhook-dashboard | Source branch. Useful for preview names, not a trust boundary. |
pull_request.head.sha | 2c3d4e5f... | Commit that checks, scans, or preview environments should evaluate. |
pull_request.draft | false | Avoid running expensive automation while a PR is still draft. |
pull_request.merged | false | Closed does not always mean merged; check this field on closed actions. |
repository.full_name | acme/payments-api | Prevent one endpoint from routing work across the wrong repository. |
PR actions and receiver decisions
The action field is the first business decision. A preview worker, merge workflow, label automation, and review workflow should not all run from the same branch of receiver code.
| Action | Typical path | Guardrail |
|---|---|---|
opened / reopened | Create or resume preview work | Use base branch and draft state before queueing. |
synchronize | Update preview or checks | Use pull_request.head.sha as the new commit to evaluate. |
ready_for_review | Start heavier automation | Good boundary for draft-to-review workflows. |
closed + merged=false | Cleanup preview state | Do not trigger merge-only deploy logic. |
closed + merged=true | Merge or release workflow | Route separately from abandoned PR cleanup. |
labeled / unlabeled | Optional policy automation | Keep label workflows separate from build workflows. |
Synchronize payload
A synchronize action usually means new commits were pushed to the PR branch. Use the new pull_request.head.sha as the commit to test or deploy to a preview environment.
{
"action": "synchronize",
"number": 42,
"pull_request": {
"id": 1789443201,
"html_url": "https://github.com/acme/payments-api/pull/42",
"state": "open",
"draft": false,
"merged": false,
"head": {
"ref": "feature/webhook-dashboard",
"sha": "6f7a8b9c0d1e2f3456789abcdef0123456789abc"
},
"base": {
"ref": "main",
"sha": "5f6b7a8c9d0e1f23456789abcdef0123456789ab"
}
},
"repository": {
"id": 987654321,
"full_name": "acme/payments-api"
},
"sender": {
"login": "dev-user"
}
}Closed and merged payload
A closed action is not always a merge. Check pull_request.merged before starting post-merge deploys, release jobs, or branch cleanup that assumes the PR landed.
{
"action": "closed",
"number": 42,
"pull_request": {
"id": 1789443201,
"html_url": "https://github.com/acme/payments-api/pull/42",
"state": "closed",
"draft": false,
"merged": true,
"merge_commit_sha": "9abc0d1e2f3456789abcdef0123456789abcdef0",
"head": {
"ref": "feature/webhook-dashboard",
"sha": "6f7a8b9c0d1e2f3456789abcdef0123456789abc"
},
"base": {
"ref": "main",
"sha": "9abc0d1e2f3456789abcdef0123456789abcdef0"
}
},
"repository": {
"id": 987654321,
"full_name": "acme/payments-api"
},
"sender": {
"login": "maintainer"
}
}Verify GitHub PR signatures in FastHook
GitHub uses X-Hub-Signature-256 for HMAC-SHA256 signatures over the exact raw body. FastHook can verify that at the source by using auth_type HMAC, signature_header x-hub-signature-256, and prefix sha256=. Do not configure a timestamp header for GitHub.
curl -X POST "https://api.fasthook.io/v1/sources" \
-H "Authorization: Bearer fhp_xxx" \
-H "x-team-id: tm_3b5335b627084a838b" \
-H "Content-Type: application/json" \
-d '{
"name": "GitHub pull request source",
"description": "GitHub PR events verified with X-Hub-Signature-256",
"type": "WEBHOOK",
"status": "enabled",
"config": {
"auth_type": "HMAC",
"auth": {
"secret": "github_webhook_secret",
"signature_header": "x-hub-signature-256",
"prefix": "sha256="
},
"allowed_http_methods": ["POST"],
"custom_response": {
"content_type": "json",
"body": "{\"ok\":true}"
}
}
}'Send a signed PR smoke test
This is a FastHook source test, not a claim that GitHub sent the request. It signs the exact JSON string being sent with the same sha256= HMAC shape GitHub documents.
import crypto from "node:crypto";
const sourceUrl = "https://hook-xxxxxx.fasthook.io/";
const secret = process.env.GITHUB_WEBHOOK_SECRET;
const payload = JSON.stringify({
action: "opened",
number: 42,
pull_request: {
id: 1789443201,
html_url: "https://github.com/acme/payments-api/pull/42",
draft: false,
merged: false,
head: {
ref: "feature/webhook-dashboard",
sha: "2c3d4e5f6a7b8c9d0e1f23456789abcdef012345"
},
base: {
ref: "main"
}
},
repository: {
id: 987654321,
full_name: "acme/payments-api"
},
sender: { login: "dev-user" }
});
const signature = "sha256=" + crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
const response = await fetch(sourceUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": "pull_request",
"x-github-delivery": crypto.randomUUID(),
"x-hub-signature-256": signature,
"user-agent": "GitHub-Hookshot/test"
},
body: payload
});
console.log(response.status, await response.text());Filter pull_request events in FastHook
Connection filters run after the source accepts the request. Use header filters for the event family and body filters for repository, action, base branch, draft state, merge state, or head SHA. When a filter has alternatives, keep them inside an explicit $and array so the backend matcher evaluates every condition.
Preview environment filter
{
"type": "filter",
"headers": {
"x-github-event": "pull_request"
},
"body": {
"action": {
"$in": [
"opened",
"reopened",
"synchronize",
"ready_for_review",
"closed"
]
},
"repository": {
"full_name": "acme/payments-api"
},
"pull_request": {
"base": {
"ref": "main"
}
}
}
}Main or release branch filter
{
"type": "filter",
"headers": {
"x-github-event": "pull_request"
},
"body": {
"$and": [
{
"repository": {
"full_name": "acme/payments-api"
}
},
{
"action": {
"$in": [
"opened",
"reopened",
"synchronize",
"ready_for_review"
]
}
},
{
"$or": [
{
"pull_request": {
"base": {
"ref": "main"
}
}
},
{
"pull_request": {
"base": {
"ref": {
"$starts_with": "release/"
}
}
}
}
]
},
{
"pull_request": {
"draft": false
}
}
]
}
}Merged PR filter
{
"type": "filter",
"headers": {
"x-github-event": "pull_request"
},
"body": {
"action": "closed",
"repository": {
"full_name": "acme/payments-api"
},
"pull_request": {
"merged": true,
"base": {
"ref": "main"
}
}
}
}Inspect captured PR payloads
Use include=data when you need the stored PR JSON body, headers, method, path, and query. Start with the request record, then open request events and destination attempts to see which connection matched and whether the receiver accepted the delivery.
curl -G "https://api.fasthook.io/v1/requests" \
-H "Authorization: Bearer fhp_xxx" \
-H "x-team-id: tm_3b5335b627084a838b" \
--data-urlencode "source_id=src_q6z62b6py5o79b" \
--data-urlencode "status=accepted" \
--data-urlencode "verified=true" \
--data-urlencode "from=now-24h" \
--data-urlencode "to=now" \
--data-urlencode "limit=20" \
--data-urlencode "include=data" \
--data-urlencode "q=pull_request"curl "https://api.fasthook.io/v1/requests/req_7Jm4Q8/events?include=attempts" \
-H "Authorization: Bearer fhp_xxx" \
-H "x-team-id: tm_3b5335b627084a838b"{
"id": "req_7Jm4Q8",
"source_id": "src_q6z62b6py5o79b",
"status": "accepted",
"verified": true,
"method": "POST",
"path": "/",
"rejection_cause": null,
"data": {
"headers": {
"x-github-event": "pull_request",
"x-github-delivery": "72d3162e-cc78-11ec-9d64-0242ac120002",
"x-hub-signature-256": "sha256=<redacted>"
},
"body": {
"action": "synchronize",
"number": 42,
"pull_request": {
"id": 1789443201,
"draft": false,
"merged": false,
"head": {
"ref": "feature/webhook-dashboard",
"sha": "6f7a8b9c0d1e2f3456789abcdef0123456789abc"
},
"base": {
"ref": "main"
}
},
"repository": {
"id": 987654321,
"full_name": "acme/payments-api"
}
},
"parsed_query": {},
"query": "",
"path": "/",
"method": "POST"
}
}Receiver idempotency for PR events
GitHub redelivery, FastHook retry, and manual replay can all deliver the same PR event more than once. Store an idempotency key before creating previews, queueing checks, deploying merged code, or cleaning up resources. Include action in the key so synchronize updates and closed cleanup do not collide.
export async function handleGitHubPullRequest(request) {
const deliveryId = request.headers.get("x-github-delivery");
const event = request.headers.get("x-github-event");
const payload = await request.json();
if (event !== "pull_request") {
return new Response("ignored", { status: 202 });
}
const repoId = payload.repository?.id;
const pr = payload.pull_request;
const action = payload.action;
const prId = pr?.id;
const headSha = pr?.head?.sha ?? "no-head-sha";
const businessKey = `github:pr:${repoId}:${prId}:${action}:${headSha}`;
const inserted = await idempotencyStore.insertOnce({
key: businessKey,
providerDeliveryId: deliveryId
});
if (!inserted) {
return Response.json({ duplicate: true });
}
if (action === "closed") {
await cleanupPreview({ repoId, prId, number: payload.number });
if (pr?.merged) {
await queuePostMergeWorkflow({
repoId,
prId,
baseRef: pr.base?.ref,
mergeCommitSha: pr.merge_commit_sha
});
}
return Response.json({ queued: pr?.merged ? "merge-workflow" : "cleanup" });
}
if (pr?.draft) {
return Response.json({ ignored: "draft" });
}
await upsertPreview({
repoId,
prId,
number: payload.number,
baseRef: pr?.base?.ref,
headRef: pr?.head?.ref,
headSha
});
return Response.json({ queued: "preview" });
}Production checklist
- Subscribe to
pull_requestonly for PR lifecycle automation. - Use separate routes for
pull_request_review, review comments, issue comments, and review threads. - Verify
X-Hub-Signature-256with the raw body before trusting payload fields. - Filter by
x-github-event, repository, PR action, base branch, draft state, and merge state. - Use
pull_request.head.shafor checks and preview updates. - Check
pull_request.mergedbefore running post-merge workflows. - Store
X-GitHub-Deliveryfor tracing and a business id for duplicate protection. - Inspect FastHook request data and destination attempts before replaying failed PR automation.
GitHub pull_request FAQ
What is a GitHub pull_request webhook?
A GitHub pull_request webhook is an HTTP POST delivery with X-GitHub-Event set to pull_request and a JSON payload containing action, number, pull_request, repository, and sender fields.
Which pull_request fields should I route on?
Route on action, repository.full_name, pull_request.base.ref, pull_request.head.ref, pull_request.head.sha, pull_request.draft, pull_request.state, and pull_request.merged depending on the workflow.
Are pull request review comments part of the pull_request event?
No. GitHub uses separate events for pull_request_review, pull_request_review_comment, issue_comment, and pull_request_review_thread. Keep those on separate routes when they trigger different work.
Can FastHook verify GitHub pull_request signatures?
Yes. FastHook source HMAC auth can verify GitHub X-Hub-Signature-256 by using signature_header x-hub-signature-256 and prefix sha256= with the same webhook secret configured in GitHub.
GitHub documentation references
GitHub documents delivery headers in the webhook events and payloads reference, the pull_request payload in the same reference, and X-Hub-Signature-256 behavior in validating webhook deliveries.