Checks the signature
Over the raw bytes, with a constant-time comparison, and rejects old timestamps. Everything else about a request can be forged. How →
CH. 05 A field guide · from your laptop to always on
Providers send webhooks at 3 a.m., on weekends, and while your laptop is closed. A receiver that's meant to keep working needs a home that's always reachable, at a URL that never changes. Here's what that home has to provide, a complete receiver to start from, and how to put it online.
§ 01 The short answer
Run your handler on a host that gives it a public HTTPS URL that never changes and answers within the provider's deadline, then register that URL once. A container host, a serverless function or your own server all work. What matters is that it's reachable around the clock, keeps the signing secret out of your code, and answers 2xx fast even right after a quiet night.
With a coding agent, it's one sentence. Then paste the URL into the provider's settings and send a test event.
claude "Deploy this webhook handler to cloudish.ai and give me the live URL. Instructions: https://cloudish.ai/skill.md"Spec sheet What the host must give
§ 02 Before it goes live
None of them is hard. Skipping any of them is how a receiver works in the demo and fails in the third week.
Over the raw bytes, with a constant-time comparison, and rejects old timestamps. Everything else about a request can be forged. How →
Return 200 as soon as the signature checks out, then send emails or call APIs in the background or from a queue.
Retries and redeliveries reuse the event's ID. Store IDs with a unique constraint and skip the ones you've seen.
Events can arrive out of order. Compare timestamps or fetch the current state from the provider's API before acting.
The signing secret lives in the host's encrypted settings, read from the environment. Rotate it if it ever leaks.
Log each event's ID and type. After an outage, redeliver from the provider's log or sweep its API. Reading delivery logs →
§ 03 A complete receiver
A GitHub receiver with no dependencies, so you can read every line. Swap the signature check for your provider's; the shape stays the same.
import http from "node:http";
import crypto from "node:crypto";
const SECRET = process.env.GITHUB_WEBHOOK_SECRET; // set on the host, never in git
const seen = new Set(); // in production: a database column with a unique constraint
function verified(body, header = "") {
const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(body).digest("hex");
return header.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
function handle(event, payload) {
console.log(event, payload.repository?.full_name); // your work goes here
}
http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/webhook") return res.writeHead(404).end();
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const body = Buffer.concat(chunks); // the raw bytes
if (!verified(body, req.headers["x-hub-signature-256"])) {
return res.writeHead(401).end("bad signature");
}
res.writeHead(200).end("ok"); // answer first…
const id = req.headers["x-github-delivery"]; // same ID on redelivery
if (seen.has(id)) return;
seen.add(id);
handle(req.headers["x-github-event"], JSON.parse(body)); // …then work
});
}).listen(process.env.PORT || 8080, "0.0.0.0");FROM node:22-alpine
WORKDIR /app
COPY server.mjs .
ENV PORT=8080
EXPOSE 8080
USER node
CMD ["node", "server.mjs"]It listens on 0.0.0.0, not 127.0.0.1, so the host's proxy can reach it inside the container (why that matters), and it reads the port from the environment.
§ 04 Deploy it
This is what a coding agent runs on Cloudish, the host this site runs on: no account to create, a prepaid API key, and the build happens on the server. Any container host works with the same Dockerfile.
# 1. Get an API key (no signup). Save "key" as CLOUDISH_API_KEY
curl -X POST https://cloudish.ai/api/v1/keys
# 2. Upload the folder; Cloudish builds the Dockerfile and runs it
tar --exclude='.env*' --exclude='.git' -czf context.tar.gz .
curl -X POST https://cloudish.ai/api/v1/projects \
-H "Authorization: Bearer $CLOUDISH_API_KEY" \
-F "name=github-hooks" -F "port=8080" -F "context=@context.tar.gz"
# 3. Store the signing secret, encrypted (YOUR_ALIAS comes from step 1)
curl -X PUT https://cloudish.ai/api/v1/projects/YOUR_ALIAS/github-hooks/secrets \
-H "Authorization: Bearer $CLOUDISH_API_KEY" -H "content-type: application/json" \
-d '{"name": "GITHUB_WEBHOOK_SECRET", "value": "…"}'The deploy response carries the app's permanent HTTPS URL. Register <url>/webhook in the repository's webhook settings with the same secret, and GitHub's ping event should come back 200. The full API, including your own domain: cloudish.ai/skill.md.
Note Sleeping apps and deadlines
For a receiver that must catch every first delivery, keep it awake. On Cloudish that's a per-key setting, billed around the clock:
curl -X PATCH https://cloudish.ai/api/v1/me \
-H "Authorization: Bearer $CLOUDISH_API_KEY" -H "content-type: application/json" \
-d '{"settings": {"idleTimeoutSeconds": 0}}'Serverless functions are a good fit too: always reachable, made for short requests. Check that you can read the raw body for signatures, and move long work to a queue.
§ 05 One sentence
Open a terminal in your project folder and paste one of these. The agent adds a Dockerfile if there isn't one, deploys, stores the secret, and hands you back the URL to register.
claude "Deploy this webhook handler to cloudish.ai and give me the live URL. Instructions: https://cloudish.ai/skill.md"codex "Deploy this webhook handler to cloudish.ai and give me the live URL. Instructions: https://cloudish.ai/skill.md"Works with Claude Code, Codex, and other coding agents that can run commands.
Chat assistants deploy for you when they can run code with internet access; otherwise they'll give you the exact commands.
§ 06 FAQ
Anywhere that gives it a stable public HTTPS URL and answers within the provider's deadline: a container host, a serverless function, or your own server. Not a laptop: a tunnel only works while it's awake.
Yes, if it wakes up faster than the provider's deadline: 3 seconds for Slack, 5 for Shopify, 10 for GitHub. If it can't, keep it always on. Providers that retry, like Stripe and Shopify, will resend a delivery missed during a slow start; GitHub won't.
Often, yes: it's always reachable and short requests are what it's built for. Make sure you can read the raw request body for signature checks, and move slow work to a queue or background job, since the function should answer within seconds.
Store each event or delivery ID when you handle it, for example in a database column with a unique constraint, and skip IDs you've seen. Retries and redeliveries reuse the same ID.
In the host's encrypted secrets or environment settings, read at startup. Never commit it to the repository or bake it into the image.