Tested on 1 October 2026 against chorus.host, with the commands on this page:
- Create, deploy, set the secret and get the first verified event back: 2.5 seconds in total (0.28 + 1.35 + 0.65 + 0.24 s).
- 20 signed POSTs from the US East coast: 57 ms median, 86 ms at the 95th percentile, all
200.- Unsigned, wrong-secret and tampered POSTs:
401. AGET:405.- The Stripe receiver below: valid events
200; a wrong secret, a timestamp older than 5 minutes or a missing header400.
Where to run a small webhook receiver
Checked 1 October 2026; each row links its source.
| Account needed | How an agent deploys | URL | When idle | Free tier | Storage and cron | |
|---|---|---|---|---|---|---|
| chorus.host Workers | Free chorus.host account (email code); no Cloudflare account | Two HTTP calls or beacon deploy | <name>.worker.chorus.host | Doesn't spin down | Free | No storage; no cron yet |
| Cloudflare Workers, your account | Cloudflare account, plus an API token and account ID for wrangler deploy | wrangler deploy | *.workers.dev | Doesn't spin down | 100,000 requests a day, 10 ms CPU per request; paid from $5 a month | KV, D1, Queues, cron |
wrangler deploy --temporary | None, but claim within 60 minutes or it's deleted | npx wrangler deploy --temporary | *.workers.dev | Doesn't spin down | Free | Some (KV, D1) |
| Deno Deploy | Deno account | deployctl or git | *.deno.dev | Scales to zero | 1M requests a month, 10 hours of CPU; Pro $20 a month | KV |
| Val Town | Val Town account | Web editor or API | *.val.run | Serverless | 100,000 runs a day, 1 minute per run, no custom domains; Pro $21 a month billed yearly | Blob and SQLite, cron |
| Render free web service | Render account | Git or Docker | *.onrender.com | Spins down after 15 minutes idle; about a minute to wake | 750 instance hours a month | Separate services |
| Railway | Railway account | CLI or git | *.up.railway.app | Always on while credit lasts | Free plan: $1 of credit a month; Hobby $5 a month with $5 of usage | Databases, cron |
| Codehooks.io | Codehooks account | coho deploy, MCP server | Its own subdomain | Serverless | See its pricing page | Built-in database, queues, cron |
| here.now | Static files and proxy routes only; its docs say not to use it for backend code | |||||
| Request bins (webhook.site and others) | Usually none | n/a | Their URL | n/a | Free tiers | They show payloads; they don't run your code |
Why idle behaviour matters: GitHub expects a 2xx within 10 seconds and doesn't retry a failed delivery on its own (you redeliver by hand), so a free host that sleeps can miss GitHub events while it wakes up. Stripe retries failed deliveries for up to 3 days in live mode, so it's more forgiving.
Pick Codehooks.io or your own Cloudflare account when the receiver needs to store events itself; chorus.host Workers have no built-in database.
1. The receiver
Save this as worker.js in an empty folder:
export default {
async fetch(request, env) {
if (request.method !== "POST") {
return new Response("Send a POST", { status: 405 });
}
const body = await request.text();
const signature = request.headers.get("X-Hub-Signature-256") || "";
if (!(await isSigned(body, signature, env.WEBHOOK_SECRET))) {
return new Response("Bad signature", { status: 401 });
}
console.log(JSON.stringify({
event: request.headers.get("X-GitHub-Event"),
body: body.slice(0, 2000),
}));
return Response.json({ ok: true });
},
};
async function isSigned(body, signature, secret) {
const match = /^sha256=([0-9a-f]{64})$/.exec(signature);
if (!secret || !match) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"],
);
const bytes = new Uint8Array(match[1].match(/../g).map((h) => parseInt(h, 16)));
return crypto.subtle.verify("HMAC", key, bytes, enc.encode(body));
}
It reads the raw body, checks the X-Hub-Signature-256: sha256=<hex> header against WEBHOOK_SECRET, and turns everything else away with 401. crypto.subtle.verify compares in constant time.
2. Get an API key
curl -sS https://chorus.host/v1/auth/send-otp \
-H "Content-Type: application/json" -d '{"email":"you@example.com"}'
curl -sS https://chorus.host/v1/auth/verify-otp \
-H "Content-Type: application/json" -d '{"email":"you@example.com","code":"123456"}'
Use the 6-digit code from your inbox in the second call. It returns {"apiKey":"chk_..."}:
export BEACON_API_KEY=chk_your_key
With the CLI instead: beacon login --email you@example.com, then beacon login --email you@example.com --code 123456.
3. Deploy
curl -sS https://chorus.host/v1/workers \
-H "Authorization: Bearer $BEACON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"slug":"my-hook"}'
curl -sS https://chorus.host/v1/workers/my-hook/deploy \
-H "Authorization: Bearer $BEACON_API_KEY" \
-F 'metadata={"entryPoint":"worker.js","compatibilityDate":"2026-09-01"}' \
-F "file=@worker.js"
Pick your own name instead of my-hook; if it's taken, the first call says so. With the CLI, run beacon deploy --slug my-hook from the folder instead.
4. Set the signing secret
WEBHOOK_SECRET=$(openssl rand -hex 32)
curl -sS -X PUT https://chorus.host/v1/workers/my-hook/secrets/WEBHOOK_SECRET \
-H "Authorization: Bearer $BEACON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"value":"'"$WEBHOOK_SECRET"'"}'
echo "$WEBHOOK_SECRET"
Paste the printed value into the sender's webhook settings. The secret stays set across redeploys and rollbacks. With the CLI: printf '%s' "$WEBHOOK_SECRET" | beacon secret set WEBHOOK_SECRET - --slug my-hook.
5. Send a test event
BODY='{"zen":"Keep it logically awesome."}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.* //')
curl -sS https://my-hook.worker.chorus.host \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: ping" \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$BODY"
A correct signature returns {"ok":true}. A missing or wrong one returns 401 Bad signature.
6. Watch events arrive
beacon logs my-hook
Each request prints as a JSON line, including the console.log output from step 1. Without the CLI, GET https://chorus.host/v1/workers/my-hook/logs/tail (with your API key) returns a WebSocket URL for the same stream.
Point GitHub at it
In the repository, open Settings, then Webhooks, then Add webhook:
- Payload URL:
https://my-hook.worker.chorus.host - Content type:
application/json - Secret: the
WEBHOOK_SECRETvalue from step 4
GitHub sends a ping event right away, and it should show up in beacon logs.
Stripe receiver
Stripe signs <timestamp>.<raw body> with HMAC-SHA256 and sends Stripe-Signature: t=<unix time>,v1=<hex>. This receiver checks it with Web Crypto, accepts any of several v1 signatures (Stripe sends more than one while you roll a secret), refuses events older than 5 minutes, and needs no npm package. Save it as stripe.js:
export default {
async fetch(request, env) {
if (request.method !== "POST") return new Response("Send a POST", { status: 405 });
const body = await request.text();
const header = request.headers.get("Stripe-Signature") || "";
if (!(await stripeSigned(body, header, env.STRIPE_WEBHOOK_SECRET))) {
return new Response("Bad signature", { status: 400 });
}
const event = JSON.parse(body);
console.log(JSON.stringify({ id: event.id, type: event.type }));
return Response.json({ received: true });
},
};
// Stripe signs `${t}.${body}` with HMAC-SHA256; header is "t=<unix>,v1=<hex>[,v1=<hex>]".
async function stripeSigned(body, header, secret, toleranceSec = 300) {
if (!secret) return false;
const parts = header.split(",").map((p) => p.split("="));
const t = parts.find(([k]) => k === "t")?.[1];
const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v);
if (!t || !sigs.length || Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"],
);
for (const hex of sigs) {
if (!/^[0-9a-f]{64}$/.test(hex)) continue;
const bytes = new Uint8Array(hex.match(/../g).map((h) => parseInt(h, 16)));
if (await crypto.subtle.verify("HMAC", key, bytes, enc.encode(`${t}.${body}`))) return true;
}
return false;
}
Deploy it like step 3 with "entryPoint":"stripe.js". Then:
- In the Stripe Dashboard, open Workbench, then Webhooks, and add an endpoint with the URL
https://my-hook.worker.chorus.host. - Copy its signing secret (
whsec_...) into the Worker:
curl -sS -X PUT https://chorus.host/v1/workers/my-hook/secrets/STRIPE_WEBHOOK_SECRET \
-H "Authorization: Bearer $BEACON_API_KEY" -H "Content-Type: application/json" \
-d '{"value":"whsec_..."}'
- Send a test event with the Stripe CLI:
stripe trigger payment_intent.succeeded, and watch it arrive withbeacon logs my-hook.
Read the body with await request.text() before anything else. The signature covers the exact bytes Stripe sent; parsing the JSON and serializing it again changes them, and the check fails.
Answer within 10 seconds, then forward
Send the 200 first and do the slow work afterwards. ctx.waitUntil keeps the Worker running after the response is sent:
export default {
async fetch(request, env, ctx) {
const body = await request.text();
// ...check the signature as above...
ctx.waitUntil(fetch(env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: `GitHub event: ${request.headers.get("X-GitHub-Event")}` }),
}));
return Response.json({ ok: true });
},
};
Senders sometimes deliver the same event twice. Deduplicate on X-GitHub-Delivery or the Stripe event id, which needs a store outside the Worker (a database you already use, or a key-value service): chorus.host Workers have no built-in database.
Only need to see what a webhook sends?
A request bin such as webhook.site, or stripe listen --forward-to localhost:3000 for Stripe, is quicker for looking at payloads while you build. This guide is for running your own code at a URL that stays up.
Questions
How do I get a webhook endpoint URL for GitHub or Stripe?
Deploy a small Worker to chorus.host: two HTTP calls (create, deploy) or beacon deploy. It answers at https://<name>.worker.chorus.host over HTTPS straight away. Paste that URL and a signing secret into GitHub's or Stripe's webhook settings.
Can my AI agent deploy a webhook receiver without a Cloudflare account?
Yes. chorus.host runs it on Cloudflare Workers under its own account. You sign in with a code from your email, and the agent deploys with plain HTTP calls or beacon deploy. See Workers.
Do I need to set the secret again after redeploying?
No. Secrets stay set across redeploys and rollbacks. Set them once, after the first deploy.
Will a free host that sleeps miss webhooks?
It can. Render's free tier spins down after 15 minutes idle and takes about a minute to wake, and GitHub gives up after 10 seconds without retrying. Workers don't spin down, so the first request after a quiet night is as fast as the rest.
Where do the payloads go, and can I store them?
Wherever your code sends them. The receiver above writes each event to the Worker's log, which you can follow with beacon logs. To keep events, forward them with fetch to a database, a queue or a chat tool; chorus.host Workers have no built-in storage.
What does it cost and what are the limits?
Nothing: Workers are free with a chorus.host account. Limits include 3 MB of code per deploy, 20 deploys an hour and 5 KB per secret; the full list is on the Workers page.