Deploy a webhook receiver from your agent or CLI

You get a stable HTTPS URL like https://my-hook.worker.chorus.host that checks the sender's signature, logs each event and answers in milliseconds. It runs on Cloudflare Workers through chorus.host, web hosting for AI agents. You need a free chorus.host account (an emailed code) and curl and openssl; no Cloudflare account and no server.

Updated 1 October 2026 · For agents: /guides/deploy-webhook-from-agent.md

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. A GET: 405.
  • The Stripe receiver below: valid events 200; a wrong secret, a timestamp older than 5 minutes or a missing header 400.

Where to run a small webhook receiver

Checked 1 October 2026; each row links its source.

Account neededHow an agent deploysURLWhen idleFree tierStorage and cron
chorus.host WorkersFree chorus.host account (email code); no Cloudflare accountTwo HTTP calls or beacon deploy<name>.worker.chorus.hostDoesn't spin downFreeNo storage; no cron yet
Cloudflare Workers, your accountCloudflare account, plus an API token and account ID for wrangler deploywrangler deploy*.workers.devDoesn't spin down100,000 requests a day, 10 ms CPU per request; paid from $5 a monthKV, D1, Queues, cron
wrangler deploy --temporaryNone, but claim within 60 minutes or it's deletednpx wrangler deploy --temporary*.workers.devDoesn't spin downFreeSome (KV, D1)
Deno DeployDeno accountdeployctl or git*.deno.devScales to zero1M requests a month, 10 hours of CPU; Pro $20 a monthKV
Val TownVal Town accountWeb editor or API*.val.runServerless100,000 runs a day, 1 minute per run, no custom domains; Pro $21 a month billed yearlyBlob and SQLite, cron
Render free web serviceRender accountGit or Docker*.onrender.comSpins down after 15 minutes idle; about a minute to wake750 instance hours a monthSeparate services
RailwayRailway accountCLI or git*.up.railway.appAlways on while credit lastsFree plan: $1 of credit a month; Hobby $5 a month with $5 of usageDatabases, cron
Codehooks.ioCodehooks accountcoho deploy, MCP serverIts own subdomainServerlessSee its pricing pageBuilt-in database, queues, cron
here.nowStatic files and proxy routes only; its docs say not to use it for backend code
Request bins (webhook.site and others)Usually nonen/aTheir URLn/aFree tiersThey 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:

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:

  1. In the Stripe Dashboard, open Workbench, then Webhooks, and add an endpoint with the URL https://my-hook.worker.chorus.host.
  2. 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_..."}'
  1. Send a test event with the Stripe CLI: stripe trigger payment_intent.succeeded, and watch it arrive with beacon 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.