CH. 05 A field guide · from your laptop to always on

Deploy a webhook receiver

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.

Laptop awake
~8 h a day
Provider sends
24 h a day
URL changes
Never
Fig. 01 — one day of webhooksLive
One day of webhook deliveries, to a laptop tunnel and to a deployed receiver A 24-hour timeline with eleven webhook events spread across the day. In the top row, a tunnel to a laptop is only up during working hours, so the events at night and in the evening fail. In the bottom row, a deployed receiver is up all day, and every event is delivered. 00:0006:0012:0018:0024:00 A · TUNNEL TO YOUR LAPTOP LAPTOP AWAKE 09–12 · 13–18 6 OF 11 FAIL · RETRIED, THEN LOST B · DEPLOYED RECEIVER UP 24 H · SAME URL SINCE THE DAY IT WAS REGISTERED 11 OF 11 DELIVERED
Eleven events in one day. Through a tunnel (A), every event that lands while the laptop sleeps fails. Some providers retry for hours or days, but GitHub doesn't retry at all. A deployed receiver (B) takes them all.

§ 01 The short answer

Deploying a receiver, in one paragraph

Quick 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

URL
Public, HTTPS, stable for months
Answer time
Under the deadline: Slack 3 s, Shopify 5 s, GitHub 10 s
Availability
24 hours a day, or a wake-up faster than the deadline
Secrets
Encrypted, injected as environment variables
Body size
Proxy limits above your largest payload
Logs
Enough to trace an event ID end to end

§ 02 Before it goes live

Six things a production receiver does

None of them is hard. Skipping any of them is how a receiver works in the demo and fails in the third week.

01 · Trust

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 →

02 · Speed

Answers first, works after

Return 200 as soon as the signature checks out, then send emails or call APIs in the background or from a queue.

03 · Duplicates

Does each event once

Retries and redeliveries reuse the event's ID. Store IDs with a unique constraint and skip the ones you've seen.

04 · Order

Doesn't trust the order

Events can arrive out of order. Compare timestamps or fetch the current state from the provider's API before acting.

05 · Secrets

Keeps the secret out of the code

The signing secret lives in the host's encrypted settings, read from the environment. Rotate it if it ever leaks.

06 · Recovery

Can catch up

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

Forty lines and a Dockerfile

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.

server.mjs
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");
Dockerfile
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

The commands, if you'd rather type them

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.

Terminal · deploy the receiver
# 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

Cold startsMany hosts, Cloudish included, put an idle app to sleep and start it on the next request. That first request waits for the start. If that can take longer than the provider's deadline (Slack's is 3 seconds), the delivery fails. Stripe, Shopify and Slack retry, and the retry finds the app awake; GitHub doesn't retry. Timeouts in detail →

For a receiver that must catch every first delivery, keep it awake. On Cloudish that's a per-key setting, billed around the clock:

Terminal · never sleep
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

Your receiver, online,
at a URL that stays.

One sentence · no dashboard
A Already have a handler?

Ask your coding agent

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.

B Starting from zero?

Have one built and deployed

Chat assistants deploy for you when they can run code with internet access; otherwise they'll give you the exact commands.

§ 06 FAQ

Frequently asked questions

Where should I host a webhook receiver?

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.

Can a webhook receiver sleep when idle?

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.

Is a serverless function good for webhooks?

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.

How do I avoid processing the same webhook twice?

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.

Where do I keep the webhook signing secret?

In the host's encrypted secrets or environment settings, read at startup. Never commit it to the repository or bake it into the image.