GitHub Webhooks to AI Slack Alerts
Deploy a containerized webhook that turns GitHub pushes into AI Slack summaries.
Introduction
30 Second Summary
Every team has the same problem. Someone pushes code, and nobody knows what actually changed until they dig through commit messages.
In this project, you will build a webhook service that listens for GitHub push events, asks Claude to summarize what changed in plain English, and posts the result to Slack. You will package it with Docker, deploy it to AWS, and set up monitoring so you know when something breaks.
What You'll Build
Push code to a connected repo, and within seconds your Slack channel lights up with a plain-English summary of exactly what changed.
By the end of this project, you'll have:
- Push code to GitHub and watch an AI-generated summary appear in your Slack channel within seconds.
- A Docker container that packages your app and runs identically on your laptop and in AWS.
- A CloudWatch alarm for error spikes and a handoff document any non-technical stakeholder can read, proving you own what you ship.
- Secret Mission: Upgrade Slack messages to Block Kit rich formatting for professional, scannable notifications.
Are there any prerequisites?
This project assumes you have an AWS account with the CLI configured, Docker Desktop installed, Node.js 20 installed, a GitHub account, and an Anthropic API key. You will create a free Slack workspace during Step 1.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters. A Forward Deployed Engineer ships integrations that connect systems, summarize information for non-technical stakeholders, and run reliably in production without hand-holding.
Set Up Slack and Your Project
Your webhook pipeline needs a destination before it can send anything. That destination is a Slack channel with a webhook URL that accepts incoming messages from external services.
In this step, you will create a Slack workspace, set up an app that can post to it, scaffold your Node.js project, and confirm the whole connection works with a test message.
In this step, get ready to:
- Create a Slack workspace and app with an incoming webhook URL.
- Scaffold the Node.js project and install dependencies.
- Configure environment variables and verify the Slack connection.
Create your Slack workspace and app
You need a Slack workspace where your bot can post messages. If you already have a workspace you can use for testing, skip the workspace creation and go straight to creating the app.
- Open your browser and go to slack.com.
- Create a free workspace (follow the prompts to name it and add your email).
- Once your workspace is ready, open a new tab and go to https://api.slack.com/apps.
- Click Create New App.
- Select From scratch.
- Enter Webhook Bot as the app name.
- Select your workspace from the dropdown, then click Create App.
You are now on your app's settings page. Next, you will enable the Incoming Webhooks feature so your app can receive messages from external services and post them to a channel.
- In the left sidebar, click Incoming Webhooks.
- Toggle the switch at the top of the page to On.
- Scroll down and click Add New Webhook to Workspace.
- Select a channel where your bot will post (any channel works for testing).
- Click Allow.
- Copy the Webhook URL that appears. It looks like https://hooks.slack.com/services/XXX/YYY/ZZZ. Save this somewhere safe for the next substep.
What is an Incoming Webhook?
An Incoming Webhook is a unique URL that accepts a JSON payload and posts the message content into a specific Slack channel. Any service that can make an HTTP POST request can use it to send messages to Slack without needing OAuth or complex authentication.
Don't see Incoming Webhooks in the sidebar?
Make sure you selected From scratch when creating the app. Apps created from a manifest have a different feature set. If the option is missing, delete the app and create a new one from scratch.
help me find Incoming Webhooks in my Slack app settings
Scaffold your Node.js project
With your Slack webhook URL ready, you need a project folder to hold the code. You will initialize a Node.js project and install two dependencies: Express for the web server and the Anthropic SDK for calling Claude.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir github-webhook-ai-slack
cd github-webhook-ai-slack
- Initialize the project by running:
npm init -y
You should see output confirming a package.json file was created in your folder.
The generated file has default values that you will now update to match this project's needs.
- Open package.json in your preferred text editor.
- Replace the entire contents with the following:
{
"name": "github-webhook-ai-slack",
"version": "1.0.0",
"description": "GitHub webhook to Claude AI summary to Slack notification pipeline",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "4.21.2",
"@anthropic-ai/sdk": "0.115.0"
}
}
What does this file do?
The package.json file defines your project's metadata and dependencies. The dependencies section tells npm which packages to install. Pinning exact versions (4.21.2 instead of ^4.21.2) ensures everyone running this project gets identical behavior.
The start script defines how to run your app. Docker will use this later.
- Save the file.
- Install the dependencies by running:
npm install
You should see output listing express and @anthropic-ai/sdk being downloaded, plus a confirmation line showing packages added.
Seeing permission errors or EACCES?
This usually means your terminal does not have write access to the folder. Make sure you ran cd ~/Desktop and created the folder there. Try running the command again from inside the github-webhook-ai-slack folder.
help me fix npm permission errors on my machine
Configure secrets and verify the connection
Your project needs three secrets to function: the Anthropic API key for Claude, the Slack webhook URL for posting messages, and a GitHub webhook secret for signature verification. You will store these in a .env file that stays on your machine and never gets committed to version control.
- Create a new file called .env in your github-webhook-ai-slack folder.
- Add the following contents, replacing each placeholder with your real values:
ANTHROPIC_API_KEY=your-anthropic-api-key-here
SLACK_WEBHOOK_URL=your-slack-webhook-url-here
GITHUB_WEBHOOK_SECRET=your-chosen-random-secret
What should each value be?
- ANTHROPIC_API_KEY: the API key from your Anthropic account (you already have this from the prerequisites).
- SLACK_WEBHOOK_URL: the webhook URL you copied from Slack in the previous substep.
- GITHUB_WEBHOOK_SECRET: pick any random string you want (e.g. my-super-secret-2024). GitHub will use this to sign webhook payloads so your server can verify they are genuine.
- Save the .env file.
Now you will confirm the Slack webhook actually works by sending a test message directly from your terminal.
Before you run the next command, what do you expect to see happen in your Slack channel?
- Test your Slack webhook by running this command (replace your-slack-webhook-url-here with the actual URL you pasted into your .env file):
curl -X POST -H 'Content-type: application/json' --data '{"text":"Setup complete!"}' your-slack-webhook-url-here
Switch to your Slack workspace. You should see the message "Setup complete!" appear in the channel you selected when creating the webhook.
No message in Slack?
Check that you replaced your-slack-webhook-url-here with the full URL starting with https://hooks.slack.com/services/. Make sure you have no extra spaces or quotes around the URL.
If you see invalid_payload in your terminal, check that the JSON in the --data flag uses straight quotes, not curly (smart) quotes.
help me debug why my Slack webhook curl command is not delivering a message
✔️ Awesome, I've got everything!
Great. Double check you saved both package.json and .env in your github-webhook-ai-slack folder before moving on.
ⓧ I'd like to double check the full code
Your package.json should look exactly like this:
{
"name": "github-webhook-ai-slack",
"version": "1.0.0",
"description": "GitHub webhook to Claude AI summary to Slack notification pipeline",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "4.21.2",
"@anthropic-ai/sdk": "0.115.0"
}
}
Your .env should have three lines (with your real values in place of the placeholders):
ANTHROPIC_API_KEY=<your_anthropic_api_key>
SLACK_WEBHOOK_URL=<your_slack_webhook_url>
GITHUB_WEBHOOK_SECRET=<your_chosen_random_secret>
Your Slack pipeline destination is live and your project foundation is in place. Next up, you will build the Express server that receives GitHub webhook events and verifies their cryptographic signatures.
Build the Webhook Receiver
Your project is scaffolded and your Slack webhook is verified. Now you need the actual server that will receive GitHub's webhook requests.
Before you can enrich webhook data with AI, you need a server that accepts GitHub's POST requests, verifies the cryptographic signature to reject fakes, and parses the commit data into something useful. That signature verification is critical. Without it, anyone could send fake payloads to your endpoint and trigger Slack messages.
In this step, get ready to:
- Create an Express server with a health check and webhook endpoint.
- Implement HMAC-SHA256 signature verification to reject unauthorized requests.
- Parse verified payloads and log commit data as structured JSON.
Create the Express server with a health endpoint
Every webhook integration needs two things at minimum: an endpoint to receive events and a health check for monitoring. The health check is especially important for cloud deployments later.
- Create a new file called index.js in your github-webhook-ai-slack folder by running:
touch index.js
- Open index.js in your editor and add the following code to set up the server foundation:
const express = require('express');
const crypto = require('crypto');
const Anthropic = require('@anthropic-ai/sdk');
const app = express();
const port = process.env.PORT || 3000;
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
// Only parse the webhook route as raw bytes (needed for signature verification)
app.use('/webhook/github', express.raw({ type: 'application/json' }));
// Health check endpoint for monitoring
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString() });
});
app.listen(port, () => {
console.log(JSON.stringify({
level: 'info',
message: `Webhook server running on port ${port}`,
timestamp: new Date().toISOString(),
}));
});
What does this code do?
This sets up the skeleton of your webhook service with three key decisions:
- express.raw() is scoped ONLY to the webhook route. HMAC signature verification needs the exact raw bytes GitHub sent. If you parsed JSON first, re-serializing might change the bytes and break the signature check.
- The Anthropic client is initialized now so it is ready for the next step when you add AI summarization.
- The /health endpoint returns JSON with a timestamp. AWS App Runner will use this later to confirm your container is alive.
- Save index.js.
- Start your server by running:
node index.js
- Open a second terminal tab and test the health endpoint by running:
curl http://localhost:3000/health
You should see a JSON response with {"status":"healthy","timestamp":"..."}.
Seeing 'Cannot find module' or 'EADDRINUSE'?
- If you see a module error, make sure you ran npm install in the previous step. Your node_modules folder must exist in the same directory as index.js.
- If port 3000 is already in use, stop any other server running on that port (press Ctrl+C in other terminal tabs).
Still stuck? Help me debug why my Express server won't start.
- Stop the server in your first terminal by pressing Ctrl+C.
Add HMAC-SHA256 signature verification
GitHub signs every webhook payload with your secret using HMAC-SHA256 and sends the signature in the x-hub-signature-256 header. Your server must recompute that signature from the raw body and compare the two. If they don't match, the request is forged.
- In index.js, find the app.get('/health', ...) endpoint. Add the following POST handler between the health endpoint and app.listen:
app.post('/webhook/github', async (req, res) => {
const startTime = Date.now();
// Extract the signature GitHub sent and our secret
const signature = req.headers['x-hub-signature-256'];
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!signature || !secret) {
console.error(JSON.stringify({ level: 'error', message: 'Missing signature or secret', timestamp: new Date().toISOString() }));
return res.status(401).json({ error: 'Missing signature' });
}
// Compute expected signature from raw body + our secret
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(req.body)
.digest('hex');
// Timing-safe comparison to prevent timing attacks
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
console.error(JSON.stringify({ level: 'error', message: 'Invalid signature', timestamp: new Date().toISOString() }));
return res.status(401).json({ error: 'Invalid signature' });
}
res.status(200).json({ received: true });
});
What does this code do?
This is the cryptographic gatekeeper for your webhook. It enforces three security checks:
- crypto.createHmac('sha256', secret).update(req.body) recomputes the signature using the raw request bytes and your secret. If even one byte differs from what GitHub sent, the signatures won't match.
- crypto.timingSafeEqual compares the two signatures in constant time. A regular === comparison leaks information about which characters matched through timing differences.
- The handler responds 200 immediately after verification succeeds. GitHub expects a fast response and will retry if your endpoint takes too long.
- Save index.js and restart your server by running:
node index.js
- In your second terminal, test that invalid signatures are rejected by running:
curl -X POST http://localhost:3000/webhook/github \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=invalidsignature" \
-H "X-GitHub-Event: push" \
-d '{"ref":"refs/heads/main"}'
You should see {"error":"Invalid signature"} returned. Your server's first terminal also logs the structured error. The gatekeeper works.
Getting a 404 instead of 401?
- Make sure the POST handler route is exactly /webhook/github and matches the express.raw() middleware path above it.
- Check that you saved index.js and restarted the server after adding the new code.
Need more help? Help me debug my webhook signature verification returning unexpected status codes.
- Stop the server again with Ctrl+C.
Parse the payload and log commit data
Now that your server rejects forged requests, you need to extract the useful data from verified payloads. GitHub sends the repository name, branch, pusher, and every commit message inside the JSON body.
- In index.js, find the line res.status(200).json({ received: true }); inside your POST handler. Add the following code directly below it (before the handler's closing });):
// Parse the verified payload
const event = req.headers['x-github-event'];
const payload = JSON.parse(req.body.toString());
// Only process push events
if (event !== 'push') {
console.log(JSON.stringify({ level: 'info', message: `Ignoring event type: ${event}`, timestamp: new Date().toISOString() }));
return;
}
// Log structured commit data
console.log(JSON.stringify({
level: 'info',
message: 'Push event received',
repo: payload.repository?.full_name,
branch: payload.ref?.replace('refs/heads/', ''),
pusher: payload.pusher?.name,
commits: payload.commits?.map(c => c.message),
timestamp: new Date().toISOString(),
}));
What does this code do?
- x-github-event header tells you what kind of event triggered the webhook (push, pull_request, issues, etc.). You only care about pushes for now.
- payload.ref contains the full Git ref like refs/heads/main. The replace() strips the prefix so you get just the branch name.
- Every log line is structured JSON. This is intentional. When this runs in AWS, CloudWatch can parse and filter structured logs. Plain text logs are unsearchable at scale.
- Save index.js and restart the server:
node index.js
Before you test, think about this: you need to send a curl with a valid HMAC signature. What does that require? You need to compute the signature from the exact request body using the same secret your server expects.
- In your second terminal, send a simulated push event with a valid signature by running the following commands. Replace your-webhook-secret with the value you set for GITHUB_WEBHOOK_SECRET in your .env file:
BODY='{"ref":"refs/heads/main","pusher":{"name":"testuser"},"repository":{"full_name":"testuser/test-repo"},"commits":[{"message":"Add README","author":{"name":"testuser"}}]}'
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "your-webhook-secret" -binary | xxd -p -c 256)
curl -X POST http://localhost:3000/webhook/github \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=$SIG" \
-H "X-GitHub-Event: push" \
-d "$BODY"
What does this test command do?
This simulates exactly what GitHub does when it sends a webhook:
- BODY holds a minimal but valid push event payload with a repo name, branch, and one commit.
- openssl dgst -sha256 -hmac computes the same HMAC your server will compute. If the secrets match, the signatures match.
- The X-GitHub-Event: push header tells your server this is a push event, not a pull request or issue.
What do you expect to see? The curl should return {"received":true}. Switch back to your first terminal where the server is running.
You should see a structured JSON log entry containing the repo name testuser/test-repo, branch main, pusher testuser, and the commit message Add README.
Getting 'Invalid signature' with your test curl?
- Double-check that the secret in your curl command matches exactly what you put in .env for GITHUB_WEBHOOK_SECRET. Even one extra space or different capitalization breaks the signature.
- Make sure you restarted the server after saving the file. Environment variables from .env are read at startup. If you are not using a dotenv loader, export the variables in your terminal before running node index.js: export $(cat .env | xargs) then node index.js.
- On Windows, the multi-line curl with \ may not work in PowerShell. Put the entire command on one line instead.
Still stuck? Help me debug why my HMAC signature verification fails with my test curl command.
- Stop the server with Ctrl+C.
✔️ Awesome, I've got everything!
Great. Double check you have saved index.js with all three sections: the server setup, the signature verification handler, and the payload parsing with logging.
ⓧ I'd like to double check the full code
Scroll up through your work and confirm these three chunks are present in order inside index.js:
- Server foundation: imports (express, crypto, @anthropic-ai/sdk), app setup with port, Anthropic client, express.raw() middleware, and the /health GET endpoint.
- POST handler with signature verification: app.post('/webhook/github', ...) that checks x-hub-signature-256, computes expected signature with crypto.createHmac, and uses crypto.timingSafeEqual. Returns 401 on failure, 200 on success.
- Payload parsing and logging: after res.status(200), reads x-github-event header, parses the body, filters for push events, and logs structured JSON with repo, branch, pusher, and commit messages.
- app.listen: at the bottom, starts the server on the configured port with a startup log.
Your webhook receiver is live and secure. It verifies every request cryptographically and logs structured data ready for processing. Next up, you will wire in Claude to turn those raw commits into plain-English summaries and post them to Slack.
Add Claude AI Summarization and Slack Posting
Right now your webhook handler receives GitHub push events, verifies the signature, and logs the raw commit data to your terminal. That is useful for debugging, but useless for anyone who is not staring at a terminal window.
In this step, you will turn that raw payload into a plain-English summary using Claude and post it to your Slack channel. A project manager scanning Slack will see what changed without reading a single line of code.
In this step, get ready to:
- Write a generateSummary function that sends commit data to Claude and returns a non-technical summary.
- Write a postToSlack function that formats and posts the summary to your Slack webhook.
- Wire both functions into the webhook handler with structured error logging.
Write the generateSummary function
The Claude API needs structured context to produce a good summary. Your function will extract commit messages, author names, and changed files from the payload, then ask Claude to summarize them in 2-3 sentences a project manager could understand.
- In index.js, scroll to the bottom of the file, just above the app.listen block.
- Add the following generateSummary function above app.listen:
async function generateSummary(payload) {
// Extract branch name from the full ref path
const branch = payload.ref?.replace('refs/heads/', '') || 'unknown';
const commits = payload.commits || [];
// Build a readable list of commit messages with authors
const commitMessages = commits.map(c => `- ${c.message} (${c.author.name})`).join('\n');
// Collect all files that were added, modified, or removed
const filesChanged = commits.flatMap(c => [...(c.added || []), ...(c.modified || []), ...(c.removed || [])]);
const uniqueFiles = [...new Set(filesChanged)].slice(0, 20);
// Construct the prompt for Claude
const prompt = `You are a technical writer summarizing code changes for a non-technical audience.
Summarize this GitHub push event in 2-3 sentences that a project manager could understand.
Repository: ${payload.repository?.full_name}
Branch: ${branch}
Pusher: ${payload.pusher?.name}
Commits (${commits.length}):
${commitMessages}
Files changed: ${uniqueFiles.join(', ')}
Write a brief, clear summary. No markdown formatting.`;
// Call the Claude API
const message = await anthropic.messages.create({
model: 'claude-sonnet-5',
max_tokens: 256,
messages: [{ role: 'user', content: prompt }],
});
return message.content[0].text;
}
What does this code do?
This function prepares a structured prompt with all the context Claude needs: the repo name, branch, who pushed, each commit message with its author, and which files changed.
It caps the file list at 20 entries to avoid exceeding Claude's context window on large pushes. The max_tokens: 256 limit keeps the summary short and focused.
- Save index.js.
- Stop any running server with Ctrl+C, then restart it to verify there are no syntax errors:
node index.js
You should see the familiar Webhook server running on port 3000 log message. The server starts cleanly with the new function defined.
Seeing a syntax error on startup?
- Check that the function is placed between the closing }); of the app.post route and the app.listen call. Nesting it inside the route handler causes unexpected behavior.
- Make sure all template literal backticks are matched. The prompt string uses backticks, not regular quotes.
- help me fix a syntax error in my generateSummary function in index.js
Write the postToSlack function
Once Claude generates the summary, you need to deliver it to Slack. The Slack incoming webhook accepts a JSON payload with a text field and renders it as a channel message.
- In index.js, add the following postToSlack function directly below generateSummary (still above app.listen):
async function postToSlack(summary, payload) {
const slackWebhookUrl = process.env.SLACK_WEBHOOK_URL;
if (!slackWebhookUrl) {
throw new Error('SLACK_WEBHOOK_URL not configured');
}
// Extract metadata for the message header
const branch = payload.ref?.replace('refs/heads/', '') || 'unknown';
const commitCount = payload.commits?.length || 0;
// Format the Slack message with repo name, branch, and AI summary
const slackPayload = {
text: `*${payload.repository?.full_name}* \u2014 ${commitCount} commit${commitCount !== 1 ? 's' : ''} to \`${branch}\`\n\n${summary}`,
};
// POST to the Slack webhook URL
const response = await fetch(slackWebhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(slackPayload),
});
if (!response.ok) {
throw new Error(`Slack responded with ${response.status}: ${await response.text()}`);
}
}
What does this code do?
The function formats a Slack message using Slack's markdown syntax: *bold* for the repo name and backticks for the branch. It uses the built-in fetch API (available in Node.js 20) to POST the payload to your Slack webhook URL.
If Slack returns a non-200 response, the function throws an error that will be caught by the handler's try/catch block (which you will add next).
- Save index.js.
- Stop and restart the server to confirm it still starts cleanly:
node index.js
You should see Webhook server running on port 3000 again with no errors. Both functions are defined but not yet called by the handler.
Server crashes on restart?
- Check for a missing closing brace } at the end of the postToSlack function. Each opening { needs a matching close.
- Verify that postToSlack is placed after the closing } of generateSummary, not inside it.
- help me debug why my index.js won't start after adding the postToSlack function
Wire both functions into the webhook handler
The two functions exist but are never called. You need to replace the existing push-event logging block with a try/catch that calls generateSummary then postToSlack, and logs success or failure as structured JSON.
- In index.js, find this block inside the app.post('/webhook/github', ...) handler, near the bottom of the route (after the if (event !== 'push') check):
console.log(JSON.stringify({
level: 'info',
message: 'Push event received',
repo: payload.repository?.full_name,
branch: payload.ref?.replace('refs/heads/', ''),
pusher: payload.pusher?.name,
commits: payload.commits?.map(c => c.message),
timestamp: new Date().toISOString(),
}));
- Delete that entire console.log(JSON.stringify({...})); block and replace it with:
try {
const summary = await generateSummary(payload);
await postToSlack(summary, payload);
const duration = Date.now() - startTime;
console.log(JSON.stringify({
level: 'info',
message: 'Webhook processed successfully',
event,
repo: payload.repository?.full_name,
commits: payload.commits?.length,
duration_ms: duration,
timestamp: new Date().toISOString(),
}));
} catch (error) {
console.error(JSON.stringify({
level: 'error',
message: 'Failed to process webhook',
error: error.message,
event,
repo: payload.repository?.full_name,
timestamp: new Date().toISOString(),
}));
}
What does this code do?
- The handler already responded 200 to GitHub before this code runs. That means GitHub will not retry the webhook regardless of whether your AI call succeeds or fails.
- On success, it logs the repo, commit count, and processing duration in milliseconds. This structured JSON log is what CloudWatch will parse later.
- On failure, it catches the error and logs it at the error level. The error.message field tells you whether the Claude API or Slack webhook failed.
- Save index.js.
- Stop the server with Ctrl+C and restart it:
node index.js
Before you test, think about what you expect to happen. When you send a simulated push event with a valid signature, do you think a Slack message will appear? What would need to go wrong for it to fail?
- Open a second terminal. Set up the test payload and compute its signature by running these commands (replace your-webhook-secret with the value from your .env file):
PAYLOAD='{"ref":"refs/heads/main","pusher":{"name":"developer"},"repository":{"full_name":"my-org/my-repo"},"commits":[{"message":"Add user authentication flow","author":{"name":"developer"},"added":["src/auth.js"],"modified":["src/index.js"],"removed":[]}]}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "your-webhook-secret" | sed 's/^.* /sha256=/')
- Send the signed request to your running server:
curl -X POST http://localhost:3000/webhook/github \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: $SIGNATURE" \
-H "X-GitHub-Event: push" \
-d "$PAYLOAD"
Within a few seconds, check your Slack channel. You should see a message with the repo name in bold, the branch name, and a 2-3 sentence AI-generated summary of the changes.
In your server terminal, you should see a structured JSON log with Webhook processed successfully and a duration_ms value showing how long the full pipeline took.
No Slack message appearing?
- Check your server terminal for an error log. If you see SLACK_WEBHOOK_URL not configured, make sure your .env file has the correct webhook URL and that you started the server with your environment variables loaded.
- If you see a Claude API error, verify your ANTHROPIC_API_KEY is set correctly in .env and that you have credits available.
- If curl returns {"error":"Invalid signature"}, double-check that the secret in your openssl command matches the GITHUB_WEBHOOK_SECRET value in your .env file exactly.
- help me debug why my webhook is not posting to Slack after I send a test curl
✔️ Awesome, I've got everything!
Great. Make sure you have saved index.js and confirmed a Slack message appeared.
ⓧ I'd like to double check the full code
Your index.js is now over 30 lines, so confirm each section is present by scrolling through the file. You should have these sections in order:
- Imports (express, crypto, @anthropic-ai/sdk) and app setup with port.
- Anthropic client instantiation using process.env.ANTHROPIC_API_KEY.
- The express.raw() middleware scoped to /webhook/github.
- The /health GET endpoint.
- The /webhook/github POST handler with signature verification, event parsing, and the try/catch calling generateSummary then postToSlack.
- The generateSummary function with the Claude API call.
- The postToSlack function using fetch to POST to your Slack webhook.
- The app.listen block at the bottom.
Your webhook pipeline now receives GitHub events, verifies them cryptographically, generates an AI summary, and delivers it to Slack. Next up, you will package this entire app into a Docker container so it can run anywhere.
Containerize with Docker
Your webhook service works on your machine. But AWS App Runner needs a Docker image to deploy.
Docker packages your app into a container that runs identically anywhere. In this step, you will create a container image and discover firsthand why environment variables must be explicitly passed into containers.
In this step, get ready to:
- Write a Dockerfile and .dockerignore for your Node.js app.
- Build and run the container, discovering how Docker isolates environment variables.
- Inject your secrets and verify the full pipeline works inside the container.
Create the Dockerfile
A Dockerfile is a recipe that tells Docker how to build your image. It specifies the base OS, copies your code in, installs dependencies, and declares how to start the app.
- Create a new file called Dockerfile (no extension) in your github-webhook-ai-slack folder, and paste in this content:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY index.js ./
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "index.js"]
What does this Dockerfile do?
- FROM node:20-alpine starts from a minimal Linux image with Node.js 20 pre-installed. Alpine is tiny (under 200MB) which means faster builds and deploys.
- COPY package*.json then RUN npm ci --omit=dev installs only production dependencies. Copying package files first means Docker caches this layer and skips reinstalling if only your code changes.
- HEALTHCHECK tells Docker (and later App Runner) how to check if the container is healthy by hitting your /health endpoint every 30 seconds.
- CMD ["node", "index.js"] is the command Docker runs when the container starts.
- Save Dockerfile.
Create the .dockerignore
A .dockerignore file tells Docker which files to skip when copying into the image. You never want secrets or bulky node_modules inside your image.
- Create a file called .dockerignore in the same folder, and paste this content:
node_modules
.env
.git
.gitignore
Why exclude these files?
The .env file contains your secrets. Baking secrets into an image means anyone with the image can extract them. You will inject secrets at runtime instead.
The node_modules folder is excluded because npm ci inside the Dockerfile installs a fresh, clean copy. Copying your local modules would add unnecessary weight and potential platform mismatches.
- Save .dockerignore.
- Confirm both files exist by running:
ls Dockerfile .dockerignore
You should see both filenames printed in the output.
Don't see both files?
- Make sure you are in the github-webhook-ai-slack folder. Run pwd to check.
- On macOS/Linux, files starting with a dot are hidden by default. ls -a shows hidden files if ls alone does not list .dockerignore.
- help me find my Dockerfile and .dockerignore
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the complete Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY index.js ./
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "index.js"]
And the complete .dockerignore:
node_modules
.env
.git
.gitignore
Build the image and hit the container isolation wall
Time to build your Docker image and see what happens when you run it. You will discover a critical lesson about how containers handle environment variables.
- Build your Docker image by running:
docker build -t github-webhook-ai-slack .
You should see Docker download the node:20-alpine base image, install your dependencies, and finish with a line containing Successfully tagged github-webhook-ai-slack:latest or a build ID.
Build failed?
- Make sure Docker Desktop is running. You can check by looking for the whale icon in your system tray.
- Confirm you are in the folder containing your Dockerfile. The . at the end of the build command means "use the current directory".
- help me fix my Docker build
- Now run the container without passing any environment variables:
docker run -p 3000:3000 github-webhook-ai-slack
You should see the startup log from your server. It looks like everything is working.
- Open a second terminal tab. Verify the container is alive by hitting the health endpoint:
curl http://localhost:3000/health
You should see {"status":"healthy", ...} come back. The server is running inside the container.
Before you send a webhook request, what do you think will happen? The container is running your code, but you did not pass your .env file. Will the webhook endpoint accept a request?
- Send a test request to the webhook endpoint:
curl -X POST http://localhost:3000/webhook/github \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=test" \
-H "X-GitHub-Event: push" \
-d '{"ref":"refs/heads/main"}'
You get back {"error":"Missing signature"} with a 401 status. In the container's terminal, you can see the error log showing "Missing signature or secret".
Why did this fail?
Containers are completely isolated environments. Your .env file sits on your host machine, but the container cannot see it. Inside the container, process.env.GITHUB_WEBHOOK_SECRET is undefined.
The code checks if (!signature || !secret) and since secret is undefined, it rejects every request. This is the entire point of containers. Nothing leaks in unless you explicitly pass it.
- Stop the container by pressing Ctrl+C in the terminal where it is running.
Inject secrets and test the full pipeline
The fix is the --env-file flag. It reads your .env file and injects each variable into the container at startup.
- Run the container again, this time with your secrets injected:
docker run --env-file .env -p 3000:3000 github-webhook-ai-slack
You should see the same startup log as before. The difference is invisible: your three environment variables now exist inside the container.
What does --env-file do?
Docker reads each KEY=value line from your .env file and sets it as an environment variable inside the container. Your app reads process.env.ANTHROPIC_API_KEY just like before, but now the value is actually there.
Now let's prove the full pipeline works inside the container. What do you expect to happen when you send a properly signed webhook request this time?
- In your second terminal, compute a valid signature and send the test request:
PAYLOAD='{"ref":"refs/heads/main","pusher":{"name":"testuser"},"repository":{"full_name":"testuser/test-repo"},"commits":[{"message":"Add new feature","author":{"name":"testuser"},"added":["feature.js"],"modified":[],"removed":[]}]}'
SECRET=$(grep GITHUB_WEBHOOK_SECRET .env | cut -d '=' -f2)
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
curl -X POST http://localhost:3000/webhook/github \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=$SIGNATURE" \
-H "X-GitHub-Event: push" \
-d "$PAYLOAD"
What does this command do?
- PAYLOAD stores a fake GitHub push event as JSON.
- SECRET reads your webhook secret from your .env file.
- SIGNATURE computes the HMAC-SHA256 hash of the payload using your secret, exactly like GitHub would.
- The curl sends the request with all the headers your server expects.
You should see {"received":true} from the curl response. Switch to your Slack channel.
Within a few seconds, a message appears with the repo name, branch, and an AI-generated summary. The container behaves identically to running node index.js directly.
No Slack message appeared?
- Check the container's terminal output for error logs. If you see a Claude API error, confirm your ANTHROPIC_API_KEY in .env is correct (no extra spaces or quotes around the value).
- If you see a Slack error, verify your SLACK_WEBHOOK_URL starts with https://hooks.slack.com/services/.
- If curl returned {"error":"Invalid signature"}, make sure you are running the curl command from the same directory as your .env file so the grep picks up the correct secret.
- help me debug my containerized webhook
Your app now runs identically on your machine and inside a container. Next up, you will push this image to AWS and give it a public URL so GitHub can send real webhooks to it.
Deploy to AWS App Runner and Add CloudWatch Alerting
Your containerized app works perfectly on your laptop. But a container running locally cannot receive real GitHub webhooks from the internet.
In this step, you will push your Docker image to AWS, deploy it with App Runner to get a public HTTPS URL, connect a real GitHub webhook, and set up CloudWatch alerting so you own the production health of what you ship.
In this step, get ready to:
- Push your Docker image to Amazon ECR and deploy it to App Runner.
- Configure a real GitHub webhook pointing to your live service.
- Set up CloudWatch structured logging and a metric-filter alarm for error monitoring.
Push your Docker image to ECR and deploy to App Runner
Amazon ECR is a container registry that stores your Docker images in AWS. App Runner pulls from ECR to deploy your container with a public URL.
- Create an ECR repository by running this command:
aws ecr create-repository --repository-name github-webhook-ai-slack
You should see JSON output containing your repository URI. It looks like 123456789012.dkr.ecr.us-east-1.amazonaws.com/github-webhook-ai-slack. Copy this URI for the next commands.
Seeing an access denied error?
Make sure your AWS CLI is configured with credentials that have ECR permissions. Run aws sts get-caller-identity to verify you are authenticated.
help me fix ECR permission errors with my AWS CLI
- Authenticate Docker to your ECR registry by running this command (replace the account ID and region with yours):
aws ecr get-login-password --region [[AWS_REGION="us-east-1"]] | docker login --username AWS --password-stdin [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.[[AWS_REGION="us-east-1"]].amazonaws.com
You should see Login Succeeded in the output.
- Tag your local image with the ECR repository URI by running:
docker tag github-webhook-ai-slack:latest [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.[[AWS_REGION="us-east-1"]].amazonaws.com/github-webhook-ai-slack:latest
- Push the image to ECR by running:
docker push [[AWS_ACCOUNT_ID="123456789012"]].dkr.ecr.[[AWS_REGION="us-east-1"]].amazonaws.com/github-webhook-ai-slack:latest
The push uploads each layer of your Docker image. You should see progress bars for each layer and a final digest confirming the push succeeded.
Why push to ECR instead of Docker Hub?
App Runner can pull directly from ECR in the same AWS account without extra authentication setup. It keeps your images private and in the same network.
- Open the AWS Console and navigate to the App Runner service.
- Click Create service.
- Under Source, select Container registry and choose Amazon ECR.
- For Container image URI, click Browse and select the github-webhook-ai-slack repository and the latest tag.
- Under ECR access role, select Create new service role if you do not have one already.
- Click Next.
- For Service name, enter github-webhook-ai-slack.
- Set Port to 3000.
- Expand Environment variables and add three plain text variables:
- Click Next, review, then click Create & deploy.
How long does deployment take?
App Runner takes 2-5 minutes to build and deploy your service. Wait until the status shows Running and a Default domain URL appears at the top of the service page.
- Once the service status is Running, copy the Default domain URL (it looks like xxxxxxxx.us-east-1.awsapprunner.com).
- Verify your deployed service is healthy by running:
curl https://[[APPRUNNER_URL="your-service.us-east-1.awsapprunner.com"]]/health
You should see a JSON response containing {"status":"healthy"} confirming your container is running in AWS.
Health check returning an error?
Check the App Runner Logs tab for startup errors. The most common issue is a missing or mistyped environment variable.
help me debug my App Runner deployment that is not starting
Configure a real GitHub webhook on a test repository
Your service has a public URL. Now you will point a real GitHub repository at it so actual push events flow through the entire pipeline.
- Open GitHub and navigate to a test repository (any repo you can push to).
- Click Settings in the repository navigation bar.
- Click Webhooks in the left sidebar.
- Click Add webhook.
- For Payload URL, enter your App Runner URL followed by /webhook/github:
https://[[APPRUNNER_URL="your-service.us-east-1.awsapprunner.com"]]/webhook/github
- Set Content type to application/json.
- In the Secret field, paste the same value you used for GITHUB_WEBHOOK_SECRET in your .env file and App Runner environment variables.
- Under Which events would you like to trigger this webhook?, select Just the push event.
- Click Add webhook.
GitHub sends a ping event immediately. You should see a green checkmark next to your webhook within a few seconds.
Before you push a commit, what do you think will happen in Slack? Will you see a message immediately, or will it take time?
- Push a commit to your test repository to trigger the webhook. In the repository directory, run:
echo "# test" >> README.md
git add .
git commit -m "Test webhook integration"
git push
Within seconds, check your Slack channel. You should see a message with the repository name, branch, and an AI-generated summary of what changed.
No Slack message appearing?
Go back to your webhook settings on GitHub. Click the webhook, then click Recent Deliveries. Check if the delivery shows a 200 response or an error code. A 401 means your secret does not match.
help me debug why my GitHub webhook is not reaching my App Runner service
Set up CloudWatch metrics and alerting, then write the handoff document
App Runner automatically streams your application logs to CloudWatch. Your structured JSON logs are already there. Now you will create a metric filter that counts errors, and an alarm that fires when errors appear.
- Open the CloudWatch console in AWS.
- In the left sidebar, click Logs, then Log groups.
- Find and click the log group that starts with /aws/apprunner/github-webhook-ai-slack/ and ends with /application.
You should see your structured JSON log entries from the push event you just triggered. This confirms App Runner is streaming logs correctly.
- Click Actions, then Create metric filter.
- In the Filter pattern field, enter:
{ $.level = "error" }
What does this filter pattern do?
This is a JSON metric filter pattern. It matches any log event where the level field equals "error". Because your app logs structured JSON with a level property, CloudWatch can parse and filter on that field.
- Click Next.
- For Filter name, enter WebhookErrors.
- Under Metric details, for Metric namespace, enter WebhookApp and select Create new.
- For Metric name, enter WebhookErrors.
- For Metric value, enter 1.
- For Default value, enter 0.
- Click Next, then click Create metric filter.
Your metric filter is now active. Every time your app logs a JSON object with "level": "error", CloudWatch increments the WebhookErrors metric.
- Now create an alarm. In the CloudWatch console sidebar, click Alarms, then All alarms.
- Click Create alarm.
- Click Select metric.
- Navigate to WebhookApp namespace, then Metrics with no dimensions.
- Select WebhookErrors and click Select metric.
- Set the condition to Greater than with a threshold of 0.
- Leave the evaluation period at 1.
- Click Next. For the notification, you can select Remove to skip SNS for now (or add your email if you want alerts).
- For Alarm name, enter WebhookErrors.
- Click Next, then Create alarm.
You should see your WebhookErrors alarm in the Alarms dashboard with a state of OK (or Insufficient data if no errors have been logged yet).
What does this alarm prove?
You now own the production health of this service. If your webhook handler starts throwing errors (bad signatures, API failures, Slack outages), this alarm transitions to ALARM state. Combined with SNS, it could page you.
The final piece of a production-grade handoff is documentation. A non-technical stakeholder should be able to understand what this service does without your help.
- Create HANDOFF.md in your github-webhook-ai-slack project folder by pasting this content:
# Commit Summary Bot - Handoff Document
## What It Does
When anyone pushes code to our GitHub repository, this service automatically:
1. Receives the notification from GitHub
2. Uses AI to generate a plain-English summary of what changed
3. Posts that summary to our Slack channel
No action is needed from anyone. It runs continuously.
- Below that, add the operational details section:
## Where It Lives
- Service URL: [Your App Runner default domain]
- AWS Region: us-east-1
- AWS Console: App Runner > Services > github-webhook-ai-slack
- Logs: CloudWatch > Log Groups > /aws/apprunner/github-webhook-ai-slack/[service-id]/application
## How to Know It Is Working
- Every code push should produce a Slack message within 10 seconds
- The /health endpoint returns OK when the service is running
- If messages stop appearing, check the CloudWatch alarm "WebhookErrors" in the AWS console
- Finally, add the costs and contact section:
## Costs
- App Runner: approximately $3-5/month while running (pause to stop charges)
- Claude AI: under $0.01/month at typical usage
- Slack: Free
## Contact
- Built by: [Your name]
- Date: [Today]
- Code: [GitHub repo URL]
Why write a handoff document?
A handoff document proves you can communicate what you build to a non-technical stakeholder. It answers three questions: what does it do, where does it live, and how do I know it is working. This is a core Forward Deployed Engineer skill.
- Save HANDOFF.md.
- Replace the bracketed placeholders ([Your App Runner default domain], etc.) with your actual values.
Before you verify, predict: if you push another commit right now, what do you expect to see in CloudWatch Logs?
- Push another commit to your test repo to confirm the full production flow:
echo "another change" >> README.md
git add .
git commit -m "Verify full production pipeline"
git push
- Check Slack for the new AI-generated summary message.
- In CloudWatch, navigate to your App Runner log group and confirm you see a structured JSON log entry with "message": "Webhook processed successfully".
- In the CloudWatch Alarms dashboard, confirm your WebhookErrors alarm is visible.
Alarm showing Insufficient data?
This is expected if no errors have occurred. The alarm will transition to OK once at least one evaluation period passes with zero errors reported. It does not mean anything is broken.
help me understand CloudWatch alarm states for my webhook service
Your webhook pipeline is now live in production. Push code to GitHub, get an AI summary in Slack within seconds, and know that CloudWatch is watching for errors. Next up, you will make those Slack messages look professional with Block Kit formatting.
Secret mission
Slack Block Kit Rich Formatting
Your Slack messages work, but they look like plain text dumps. Real production notifications use Slack's Block Kit to create structured, scannable messages with headers, dividers, and metadata. Upgrade your postToSlack function to send rich Block Kit payloads that look professional.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Your App Runner service incurs compute charges while it is running, so review the options below.
Cost warning
Your App Runner service costs approximately $3-5/month while running. If you are not actively using it, choose Pause or Delete to stop charges.
Resources you used:
- AWS App Runner service (github-webhook-ai-slack).
- Amazon ECR repository (github-webhook-ai-slack).
- CloudWatch Metric Filter and Alarm (WebhookErrors).
- GitHub Webhook on your test repository.
- Local project folder (github-webhook-ai-slack) on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep the webhook integration live.
- Your App Runner service stays active and continues processing GitHub push events.
- Cost is approximately $3-5/month for App Runner compute.
- ECR storage and CloudWatch free tier usage add negligible cost.
- Your local Docker image (github-webhook-ai-slack) remains on your machine.
Pause - I'll come back to this later
Pause your App Runner service to stop compute charges while keeping your image and configuration intact. You can resume later without redeploying.
- Open the App Runner console at https://console.aws.amazon.com/apprunner.
- Select your github-webhook-ai-slack service.
- Click Actions, then click Pause.
- Wait for the status to change to Paused. Compute charges stop immediately.
- Your ECR image remains stored (minimal cost). When you are ready to resume, return to the same page and click Actions then Resume.
Delete - I don't want to use this again
Remove all project resources completely. This is not reversible.
- Delete the App Runner service by running:
aws apprunner delete-service --service-arn [[SERVICE_ARN="your App Runner service ARN"]]
- Delete the ECR repository by running:
aws ecr delete-repository --repository-name github-webhook-ai-slack --force
- Delete the CloudWatch alarm by running:
aws cloudwatch delete-alarms --alarm-names WebhookErrors
- Remove the GitHub webhook: go to your test repository on GitHub, click Settings, click Webhooks, find your webhook, and click Delete.
- Delete your local project folder by running:
macOS
cd ~/Desktop
rm -rf github-webhook-ai-slack
Windows
cd ~\Desktop
rmdir /s /q github-webhook-ai-slack
- Remove the local Docker image by running:
docker rmi github-webhook-ai-slack
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-style webhook pipeline that turns GitHub push events into AI-enriched Slack notifications, containerized it with Docker, and deployed it to AWS with full observability.
You've learned how to:
- Build a webhook receiver with HMAC-SHA256 signature verification, integrate the Claude API to summarize code changes in plain English, and post those summaries to Slack via incoming webhooks.
- Containerize a Node.js app with Docker, push images to Amazon ECR, and deploy to AWS App Runner so your service runs identically in the cloud as it does on your laptop.
- Set up CloudWatch structured logging with a metric-filter alarm for error monitoring, and write a non-technical handoff document proving you can communicate what you build to stakeholders.
- Secret Mission: Upgraded Slack messages to Block Kit rich formatting for professional, scannable notifications.
Ready to quiz yourself?