Skip to content

How to Test GitHub Webhooks

Test GitHub webhook handlers with HMAC-SHA256 signed push, PR, and ping events. Send realistic payloads to your localhost handler and verify signatures end-to-end.

Updated Mar 2026

GitHub sends webhooks for pushes, pull requests, issues, and 40+ other events. Each webhook is signed with HMAC-SHA256 via the x-hub-signature-256 header. Testing these handlers requires properly signed payloads -- your handler should reject unsigned requests. webhooks.cc sends signed GitHub webhooks to your handler, no GitHub repo configuration needed.

What you'll build

A test that sends signed GitHub push and pull request events to your local webhook handler and verifies that signature validation works correctly. By the end, you will have a repeatable test suite that covers the most common GitHub webhook events without touching a GitHub repository.

Prerequisites

  • A webhooks.cc account and API key (generate one at Account > API Keys)
  • A GitHub webhook secret (the same string configured in your handler's environment)
  • Node.js 18 or later
  • A local webhook handler running on localhost (e.g., http://localhost:3000/api/webhooks/github)

Send signed GitHub webhooks to localhost

1

Install the SDK

npm install @webhooks-cc/sdk

Set your API key and GitHub webhook secret as environment variables:

export WHK_API_KEY="whcc_your_api_key"
export GITHUB_WEBHOOK_SECRET="your_webhook_secret"
2

Send a signed push event

Use sendTo to send a signed GitHub push webhook directly to your local handler. The SDK computes the HMAC-SHA256 signature and sets all required GitHub headers automatically.

import { WebhooksCC } from "@webhooks-cc/sdk";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
 
const res = await client.sendTo("http://localhost:3000/api/webhooks/github", {
  provider: "github",
  template: "push",
  secret: process.env.GITHUB_WEBHOOK_SECRET!,
});
 
expect(res.status).toBe(200);

The push template generates a realistic payload with a commit, repository metadata, and a refs/heads/main ref. The SDK sets x-hub-signature-256, x-github-event, and x-github-delivery headers.

3

Send a pull_request.opened event

Test your handler's pull request logic with the pull_request.opened template:

const res = await client.sendTo("http://localhost:3000/api/webhooks/github", {
  provider: "github",
  template: "pull_request.opened",
  secret: process.env.GITHUB_WEBHOOK_SECRET!,
});
 
expect(res.status).toBe(200);

This template includes a pull request object with head/base branches, author information, and a realistic diff URL.

4

Send a custom payload with specific commit data

Override the template body when you need to test against specific repository or commit details:

const res = await client.sendTo("http://localhost:3000/api/webhooks/github", {
  provider: "github",
  secret: process.env.GITHUB_WEBHOOK_SECRET!,
  event: "push",
  body: {
    ref: "refs/heads/feature/payment-flow",
    before: "0000000000000000000000000000000000000000",
    after: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
    repository: {
      id: 123456789,
      full_name: "your-org/your-repo",
      private: true,
    },
    pusher: {
      name: "deploy-bot",
      email: "[email protected]",
    },
    commits: [
      {
        id: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
        message: "Add payment webhook handler",
        timestamp: "2026-03-10T14:30:00Z",
        author: { name: "deploy-bot", email: "[email protected]" },
        added: ["src/webhooks/payment.ts"],
        modified: [],
        removed: [],
      },
    ],
  },
});
 
expect(res.status).toBe(200);

When you pass event instead of template, the SDK uses your body as-is, signs it with your secret, and sets x-github-event to the value you provide.

5

Verify the signature on a captured request

Capture a webhook on a webhooks.cc endpoint, then verify the signature matches using verifyGitHubSignature:

import { WebhooksCC, verifyGitHubSignature, matchHeader } from "@webhooks-cc/sdk";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
 
// Create an endpoint and send a signed push event to it
const endpoint = await client.endpoints.create({ name: "github-sig-test" });
 
try {
  await client.endpoints.sendTemplate(endpoint.slug, {
    provider: "github",
    template: "push",
    secret: process.env.GITHUB_WEBHOOK_SECRET!,
  });
 
  // Wait for the request to arrive
  const req = await client.requests.waitFor(endpoint.slug, {
    timeout: "10s",
    match: matchHeader("x-hub-signature-256"),
  });
 
  // Verify the signature
  const valid = await verifyGitHubSignature(
    req.body!,
    req.headers["x-hub-signature-256"],
    process.env.GITHUB_WEBHOOK_SECRET!
  );
 
  expect(valid).toBe(true);
  expect(req.headers["x-github-event"]).toBe("push");
} finally {
  await client.endpoints.delete(endpoint.slug);
}

This confirms the full round-trip: the SDK signs the payload, webhooks.cc captures it with the original headers and body intact, and the signature verifies against your secret.

How it works

GitHub computes HMAC-SHA256 of the raw request body using your webhook secret. The resulting hex digest goes in the x-hub-signature-256 header as sha256=<hex>. The SDK replicates this exact process when you set provider: "github".

GitHub sends three headers with every webhook delivery:

HeaderPurposeExample value
x-hub-signature-256HMAC-SHA256 hex digest of the bodysha256=a1b2c3...
x-github-eventThe event type that triggered the deliverypush, pull_request
x-github-deliveryA unique UUID for this delivery72d3162e-cc78-11e3-81ab-4c9367dc0958

The signature format is sha256= followed by 64 hex characters. GitHub uses the raw bytes of the request body -- before any JSON parsing or transformation -- as input to the HMAC function. This is why your handler must read the body as a raw string or buffer before any middleware processes it.

Your handler should follow this sequence:

  1. Read the raw body bytes before any JSON parsing.
  2. Compute HMAC-SHA256 of the raw bytes using the shared secret.
  3. Compare the computed hash against the x-hub-signature-256 header value using a timing-safe comparison. A timing-safe comparison prevents attackers from inferring the correct signature by measuring response times.
  4. Route the request based on the x-github-event header value.
  5. Parse the JSON body only after signature verification succeeds.

Available GitHub templates

TemplateEvent headerDescription
pushpushCommit pushed to a branch. Includes ref, commits array, repository, and pusher fields.
pull_request.openedpull_requestPull request opened. Includes PR number, title, head/base branches, and author.
pingpingSent when a webhook is first configured. Includes the hook configuration and a zen message. Use this to test your handler's initial setup response.

Putting it together: a full test suite

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { WebhooksCC, verifyGitHubSignature, matchHeader } from "@webhooks-cc/sdk";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
const HANDLER_URL = "http://localhost:3000/api/webhooks/github";
 
describe("GitHub webhook handler", () => {
  it("accepts signed push events", async () => {
    const res = await client.sendTo(HANDLER_URL, {
      provider: "github",
      template: "push",
      secret: process.env.GITHUB_WEBHOOK_SECRET!,
    });
 
    expect(res.status).toBe(200);
  });
 
  it("accepts signed pull_request events", async () => {
    const res = await client.sendTo(HANDLER_URL, {
      provider: "github",
      template: "pull_request.opened",
      secret: process.env.GITHUB_WEBHOOK_SECRET!,
    });
 
    expect(res.status).toBe(200);
  });
 
  it("responds to ping events", async () => {
    const res = await client.sendTo(HANDLER_URL, {
      provider: "github",
      template: "ping",
      secret: process.env.GITHUB_WEBHOOK_SECRET!,
    });
 
    expect(res.status).toBe(200);
  });
 
  it("rejects requests with invalid signatures", async () => {
    const res = await client.sendTo(HANDLER_URL, {
      provider: "github",
      template: "push",
      secret: "wrong-secret-value",
    });
 
    expect(res.status).toBe(401);
  });
});

Common issues

"Signature mismatch" -- Your handler must verify the signature against the raw body bytes, not parsed and re-serialized JSON. JSON serialization can reorder keys, add or remove whitespace, or change number formatting. Read the raw body from the request before any middleware parses it.

In Express:

app.post("/api/webhooks/github", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["x-hub-signature-256"];
  const body = req.body; // Buffer, not parsed JSON
  // verify signature against body
});

In Next.js App Router:

export async function POST(request: Request) {
  const body = await request.text(); // raw string, not .json()
  const signature = request.headers.get("x-hub-signature-256");
  // verify signature against body
}

"Missing x-github-event header" -- The SDK includes this header automatically when you use template or set event. If you send a completely custom request without either, set the header manually via headers: { "x-github-event": "push" }.

"Handler ignores the event" -- Many handlers route by the x-github-event header value. Verify your handler checks this header and that the value matches one of your supported event types. The push template sets the event to push; the pull_request.opened template sets it to pull_request. Note that for pull request events, the event header is pull_request (not pull_request.opened) -- the action is in the body's action field.

"Timing-safe comparison fails" -- Use crypto.timingSafeEqual in Node.js to compare the expected and received signatures. A naive string comparison with === is vulnerable to timing attacks and may also fail if the buffers have different lengths. Convert both values to Buffers of equal length before comparing:

import { createHmac, timingSafeEqual } from "crypto";
 
function verifySignature(body: string, signature: string, secret: string): boolean {
  const expected = "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
  if (expected.length !== signature.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

FAQ

Next steps

GitHub + Jest

Full Jest integration test example for GitHub webhooks.

Signature Verification

Verify signatures for GitHub and 8 other providers.

Test Shopify Webhooks

Send signed Shopify webhook payloads to localhost.