# 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.

> **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 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](https://developers.cloudflare.com/workers/platform/pricing/) | 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`](https://developers.cloudflare.com/workers/platform/claim-deployments/) | 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](https://deno.com/deploy/pricing) | 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](https://www.val.town/pricing) | 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](https://render.com/docs/free) | 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](https://railway.com/pricing) | 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](https://codehooks.io) | Codehooks account | `coho deploy`, MCP server | Its own subdomain | Serverless | See its pricing page | Built-in database, queues, cron |
| [here.now](https://here.now/docs) | | | | | | 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:

```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 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

```bash
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_..."}`:

```bash
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

```bash
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

```bash
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

```bash
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

```bash
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_SECRET` value 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`:

```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:

```bash
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_..."}'
```

3. 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:

```js
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](/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](/workers#limits).
