# React the moment something happens

> Checking a dashboard for news means finding out late. Commune tells your own systems the moment a reader subscribes, starts a conversation or replies, or an issue finishes sending, so your CRM, your team chat and your records keep up without anybody watching.

How: with the API (About 45 minutes for the receiver and the first recipe).

What you will have:

- An HTTPS endpoint registered for the events you choose, verifying every delivery before it trusts it.
- New subscribers landing in your CRM, and your team hearing about new threads and replies as they happen.
- A message with the numbers whenever an issue finishes sending.
- Your own durable record of every event, and a way to find and replay a delivery that never arrived.

**Warning:** Events are delivered at least once and in no guaranteed order. Everything on this page assumes both: dedupe on the event's `id`, and order by `occurred_at` rather than by arrival.

## With the API

Commune posts each event to your endpoint as one signed HTTPS request. You register the endpoint once, verify every delivery, answer inside five seconds and do the work afterwards. The first three steps set that up; the recipes after them are independent, so take the ones you need. The last three are how you run it: what is registered, what was delivered, and how to send a failed one again.

Before you start:

- An API key from https://usecommune.com/settings/api-keys with: webhooks: write, sending: write, content: read. Export it as `COMMUNE_API_KEY`.
- A public HTTPS URL for your endpoint. While you build, a tunnel such as `cloudflared` or `ngrok` in front of `localhost:3000` works.
- Node 18 or newer for the receiver. It has no dependencies.
- Some steps need more than this. Each one lists it where it starts.

Every request sends `Authorization: Bearer $COMMUNE_API_KEY` and `Commune-Version: 2026-08-26`. The requests below are also a collection you can import into Postman, Insomnia, Bruno, Yaak or Hoppscotch: https://usecommune.dev/use-cases/react-in-real-time/collection.json

### 1. Open the delivery portal and add your endpoint

Destinations live in Commune's delivery portal, not behind an endpoint of their own. Either open [Webhooks](https://usecommune.com/dashboard/webhooks) in your Commune dashboard, or mint a link to the same portal with this request (it needs `webhooks: write`) and open the `url` it returns. In the portal, add a webhook destination pointing at your URL, pick the topics this page uses (`subscriber.created`, `thread.created`, `message.created` and `send.completed`), and copy the signing secret. It starts with `whsec_`; keep it as `COMMUNE_WEBHOOK_SECRET`.

The `url` is a credential: its `token` lets whoever holds it change where this newsletter's events go. Redirect yourself to it and let it be spent. Do not log it, store it or paste it anywhere; minting another is one request. It takes no body and no `Idempotency-Key`, and each call returns a new link.

`POST /newsletters/{newsletter}/portal-session` ([reference](https://api-reference.usecommune.dev/operation/operation-createportalsession))

```sh
curl -X POST "https://api.usecommune.com/newsletters/example-letter/portal-session" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "object": "portal_session",
  "url": "https://portal.example.com/?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3OTEyMzk3MDB9.q8Zt3kVn1RfW0cXyL2pHs9aJ4mDb6uEo7gTi5NwKxQs",
  "expires_at": "2026-09-30T10:15:00Z"
}
```

### 2. Verify every delivery

Every request carries `Commune-Signature` and `Commune-Timestamp`. The signature is HMAC-SHA256, keyed with the secret exactly as issued (the `whsec_` prefix included), over the timestamp converted to Unix seconds, a literal `.`, then the raw body bytes, in lowercase hex. The header value is `v0=` followed by one digest, or two separated by a comma while a secret rotation is in flight, so accept a match with any of them.

This is the webhooks guide's function unchanged. Run it on the raw bytes, before anything parses the JSON: a parsed and re-serialised body is different bytes and never matches. If you want to check your own port of it, the guide has a real delivery with its secret and digest to test against.

verify.mjs:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.COMMUNE_WEBHOOK_SECRET; // "whsec_..."

export function verify(raw, header, timestamp) {
  if (!header || !timestamp) return false;

  // The signed string is the timestamp in UNIX SECONDS, a dot, then the raw
  // body. The header is RFC 3339, so convert before signing.
  const seconds = Math.floor(Date.parse(timestamp) / 1000);
  if (!Number.isFinite(seconds)) return false;

  // The timestamp is covered by the signature, so this window is a real
  // defence rather than decoration. Five minutes either way absorbs clock
  // skew; a retry arriving outside it still carries an id you have seen.
  if (Math.abs(Date.now() / 1000 - seconds) > 300) return false;

  const expected = Buffer.from(
    createHmac("sha256", SECRET)
      .update(seconds + ".")
      .update(raw)
      .digest("hex"),
    "utf8",
  );

  // "v0=" then one or more hex digests, comma separated. More than one means
  // a key rotation is in flight and both keys are live. Accept any of them.
  return header
    .replace(/^v0=/, "")
    .split(",")
    .some((candidate) => {
      const presented = Buffer.from(candidate.trim(), "utf8");
      return (
        // timingSafeEqual throws on a length mismatch rather than returning
        // false, and a forged header is free to be any length at all.
        presented.length === expected.length &&
        timingSafeEqual(presented, expected)
      );
    });
}
```

### 3. Answer first, then route on the type

Three rules shape the handler. **Answer in under five seconds**: an attempt still open at five is recorded as a failure and delivered again, however well your work went. **Expect duplicates**: delivery is at least once, and the envelope's `id` (also sent as `Commune-Event-Id`) is the same on every redelivery, so it is your dedupe key. **Ignore topics you do not know**: new ones are added over time, and throwing on one makes Commune retry an event you were never going to handle.

A wrong signature answers `401`. That is retried like any other non-2xx, eleven attempts over about eight and a half hours, which is what lets a receiver holding a stale secret recover. The `Set` here keeps the sample to one file; in production the seen ids belong in a store every instance shares, which is what the last recipe gives you.

receive.mjs:

```js
import { createServer } from "node:http";
import { verify } from "./verify.mjs";

const seen = new Set(); // One process only. See "Keep your own record".
const handlers = {}; // Filled in by the recipes below, keyed by event type.

createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const raw = Buffer.concat(chunks); // The raw bytes, before any parsing.

    // Node lower-cases incoming header names.
    if (!verify(raw, req.headers["commune-signature"], req.headers["commune-timestamp"])) {
      return res.writeHead(401).end();
    }

    const event = JSON.parse(raw.toString("utf8"));
    if (seen.has(event.id)) return res.writeHead(200).end();
    seen.add(event.id);

    // Acknowledge, then work.
    res.writeHead(200).end();
    const handler = handlers[event.type];
    if (!handler) return; // A topic you did not subscribe to, or a new one.
    handler(event).catch((error) => console.error(`${event.type} ${event.id} failed`, error));
  });
}).listen(3000);
```

### 4. Recipe: welcome a new subscriber in your CRM

`subscriber.created` arrives when somebody subscribes in Commune, finishes signing up, accepts an invitation, or is imported from a CSV or another provider. It carries the address, so this is the one recipe that moves personal data: send it only somewhere you are allowed to keep it.

Two fields decide what to do. `acquisition_source` is `null` for anybody who arrived through Commune itself and names the provider (or `csv`) for an import, which matters because importing five thousand people fires five thousand of these, and none of them asked for a welcome. `resubscribed` is `true` for a returning reader; on that path `created_at` is when they first subscribed and `occurred_at` is when they came back. Act only on `status` `subscribed`.

Event `subscriber.created`, as Commune posts it to your endpoint:

```json
{
  "id": "018f2a90-9999-7000-8000-000000000009",
  "type": "subscriber.created",
  "api_version": "2026-08-26",
  "occurred_at": "2026-08-26T12:20:05Z",
  "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
  "actor": null,
  "idempotency_key": null,
  "data": {
    "subscriber_id": "33445566-7788-4990-a1b2-c3d4e5f60718",
    "email": "reader@example.com",
    "status": "subscribed",
    "user_id": "usr_2Nf8Kq1pWc",
    "acquisition_source": null,
    "resubscribed": false,
    "created_at": "2026-08-26T12:20:05Z"
  }
}
```

### 5. Send them to your CRM

You will need:

- An inbound webhook URL from your CRM (or swap the call for whatever you use).

Most CRMs accept a JSON POST on an inbound webhook and match on the address. Keep Commune's `subscriber_id` alongside it so a later event about the same person can find the same contact.

subscriber-created.mjs:

```js
handlers["subscriber.created"] = async ({ data, occurred_at }) => {
  if (data.status !== "subscribed") return;
  if (data.acquisition_source) return; // An import, not somebody who just chose you.

  const res = await fetch(process.env.CRM_WEBHOOK_URL, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      email: data.email,
      commune_subscriber_id: data.subscriber_id,
      stage: data.resubscribed ? "returning_subscriber" : "new_subscriber",
      subscribed_at: occurred_at,
    }),
  });
  if (!res.ok) throw new Error(`CRM answered ${res.status}`);
};
```

### 6. Recipe: hear when a reader starts a conversation

`thread.created` arrives when somebody starts a new thread in your newsletter's space. When `article_id` is set, the thread is the discussion under that article, which is where comments live. `author` is inlined, so there is nothing to look up, and `url` links straight to it.

`visibility` says who can read it in Commune: `subscribers` keeps it inside your newsletter's space. Posting it into a private team channel is fine; posting it anywhere public is republishing something a reader wrote for your subscribers.

Event `thread.created`, as Commune posts it to your endpoint:

```json
{
  "id": "018f2a91-dddd-7000-8000-00000000000d",
  "type": "thread.created",
  "api_version": "2026-08-26",
  "occurred_at": "2026-08-26T15:10:44Z",
  "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
  "actor": null,
  "idempotency_key": null,
  "data": {
    "thread_id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9",
    "short_id": "k3n8qz",
    "url": "https://example.com/t/k3n8qz",
    "author": {
      "user_id": "usr_2Nf8Kq1pWc",
      "username": "mira",
      "display_name": "Mira Okafor",
      "avatar_url": "https://cdn.example.com/avatars/mira.png"
    },
    "content": "The bit about moderation load matched my experience exactly.",
    "visibility": "subscribers",
    "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
    "created_at": "2026-08-26T15:10:44Z"
  }
}
```

### 7. And when somebody replies

`message.created` is a reply inside a thread. `thread_level` is `1` for a reply to the thread and `2` for a reply to a reply, and `parent_id` points at what it answers. It has the same inlined `author` and a `url` that lands on the reply itself.

Event `message.created`, as Commune posts it to your endpoint:

```json
{
  "id": "018f2a91-eeee-7000-8000-00000000000e",
  "type": "message.created",
  "api_version": "2026-08-26",
  "occurred_at": "2026-08-26T15:14:02Z",
  "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
  "actor": null,
  "idempotency_key": null,
  "data": {
    "message_id": "c2d3e4f5-0617-4829-93a4-b5c6d7e8f90a",
    "short_id": "p7w2rd",
    "url": "https://example.com/t/k3n8qz#p7w2rd",
    "thread_id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9",
    "parent_id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9",
    "thread_level": 1,
    "author": {
      "user_id": "usr_9Lp3Zr7tYb",
      "username": "dan",
      "display_name": "Dan Whitlock",
      "avatar_url": null
    },
    "content": "Same here. We ended up capping thread depth for that reason.",
    "created_at": "2026-08-26T15:14:02Z"
  }
}
```

### 8. Post both to your team's channel

You will need:

- A [Slack incoming webhook](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/) URL for the channel.

One function for both topics, since they carry the same `author`, `content` and `url`. Slack treats `&`, `<` and `>` as markup, so escape a reader's words before they go in. A display name can be an empty string rather than missing, which is why the fallback uses `||`.

conversation.mjs:

```js
const slack = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

async function toSlack(text) {
  const res = await fetch(process.env.SLACK_WEBHOOK_URL, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ text }),
  });
  if (!res.ok) throw new Error(`Slack answered ${res.status}`);
}

async function onConversation({ type, data }) {
  const who = data.author.display_name || data.author.username || "A reader";
  const what = type === "thread.created" ? "started a thread" : "replied";
  const said = data.content.length > 280 ? data.content.slice(0, 280) + "..." : data.content;
  await toSlack(`*${slack(who)}* ${what}: ${slack(said)}\n<${data.url}|Open it in Commune>`);
}

handlers["thread.created"] = onConversation;
handlers["message.created"] = onConversation;
```

### 9. Recipe: post the results when an issue finishes sending

`send.completed` arrives when a send run has handed every recipient to the email provider. The three counts are settled at that moment and do not move afterwards. It is the dispatch milestone, not the delivery one: whether the messages reached inboxes, and who opened them, arrives later through the `delivery.*` topics and the article's stats.

It is named for the send, not the article, because an article can have more than one run (a failed send and its retry, for instance).

Event `send.completed`, as Commune posts it to your endpoint:

```json
{
  "id": "018f2a90-2222-7000-8000-000000000002",
  "type": "send.completed",
  "api_version": "2026-08-26",
  "occurred_at": "2026-08-26T09:32:11Z",
  "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411",
  "actor": null,
  "idempotency_key": null,
  "data": {
    "article_id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
    "send_id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
    "started_at": "2026-08-26T09:28:40Z",
    "completed_at": "2026-08-26T09:32:11Z",
    "recipient_count": 540,
    "sent_count": 538,
    "failed_count": 2
  }
}
```

### 10. Read the send back for the article's title

The event carries ids and counts but no title. Read the run behind `send_id` with `expand=article` and the article comes back inline, title and all. This is also how you ask again later without having kept the payload. It needs `sending: read`, and expanding the article needs `content: read` too: an article is content, and a key without it is refused with `403`.

`GET /sends/{send}` ([reference](https://api-reference.usecommune.dev/operation/operation-getsend))

```sh
curl "https://api.usecommune.com/sends/9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d?expand=article" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "object": "send",
  "id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
  "article": {
    "object": "article",
    "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f",
    "short_id": "k7Rm2xQp",
    "slug": "what-newsletters-get-wrong-about-community",
    "newsletter": {
      "object": "newsletter",
      "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
    },
    "title": "What newsletters get wrong about community",
    "preview_text": "The moderation load is the product, not a tax on it.",
    "image_url": "https://cdn.example.com/articles/k7Rm2xQp/cover.png",
    "external_url": null,
    "status": "sent",
    "is_imported": false,
    "posted_at": "2026-08-26T09:32:11Z",
    "scheduled_for": null,
    "authors": [
      {
        "object": "user",
        "id": "usr_2Nf8Kq1pWc"
      }
    ],
    "thread": {
      "object": "thread",
      "id": "b1c2d3e4-f506-4718-8293-a4b5c6d7e8f9"
    },
    "stats": {
      "likes": 148,
      "comments": 27,
      "highlights": 63
    },
    "created_at": "2026-08-24T11:04:52Z",
    "updated_at": "2026-08-26T09:32:11Z"
  },
  "newsletter": {
    "object": "newsletter",
    "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
  },
  "started_at": "2026-08-26T09:28:40Z",
  "completed_at": "2026-08-26T09:32:11Z",
  "recipient_count": 540,
  "sent_count": 538,
  "failed_count": 2,
  "created_at": "2026-08-26T09:28:38Z"
}
```

### 11. Post the numbers

You will need:

- A [Slack incoming webhook](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks/) URL for the channel.

`sent_count` is recipients handed to the provider, `failed_count` those it could not hand over. Say so in the message, so nobody reads 538 as 538 opens.

send-completed.mjs:

```js
handlers["send.completed"] = async ({ data }) => {
  const send = await api(`/sends/${data.send_id}?expand=article`); // api() is in the full script.
  const title = send.article?.title ?? "An issue";
  const share = data.recipient_count
    ? ` (${((data.sent_count / data.recipient_count) * 100).toFixed(1)}%)`
    : "";
  await toSlack(
    `*${slack(title)}* finished sending: ${data.sent_count} of ${data.recipient_count} handed to the email provider${share}, ${data.failed_count} failed.`,
  );
};
```

### 12. Recipe: keep your own record

Acknowledging before you work has a cost: once you have answered `200`, Commune will not send that event again, so work that fails afterwards is yours to retry. A table of every event you accepted solves that and two other problems at once. The primary key on `id` is the shared dedupe store the `Set` stood in for. `processed_at` tells you which events still need their work done. And ordering by `occurred_at` gives you the real sequence, since delivery order is not guaranteed.

Insert the row before you answer (one indexed insert fits comfortably in five seconds), then do the work and stamp `processed_at`. The delivery log on Commune's side is a recent record, not an archive, so this table is also your history.

events.sql:

```sql
create table commune_events (
  id            uuid primary key,          -- the envelope id, stable across redeliveries
  type          text not null,
  occurred_at   timestamptz not null,      -- order by this, not by arrival
  newsletter_id uuid,
  payload       jsonb not null,            -- the whole envelope, as verified
  received_at   timestamptz not null default now(),
  processed_at  timestamptz                -- null until the handler finished
);

-- On each verified delivery, before answering 200.
-- No row returned means a redelivery: answer 200 and stop.
insert into commune_events (id, type, occurred_at, newsletter_id, payload)
values ($1, $2, $3, $4, $5)
on conflict (id) do nothing
returning id;

-- After the handler succeeds.
update commune_events set processed_at = now() where id = $1;

-- A sweep that retries whatever failed after you answered.
select payload from commune_events
where processed_at is null and received_at < now() - interval '5 minutes'
order by occurred_at;
```

### 13. Check what is registered

When something is not arriving, start here. Each destination lists the `topics` it receives and whether it is `enabled`; a destination the portal switched off keeps its row with a `disabled_at`. `target` is the host it points at, not the full URL, and nothing it authenticates with (the signing secret included) is ever returned. An empty page is not an error: it means no destination was ever added, and events are recorded and delivered nowhere. Needs `webhooks: read`.

`GET /newsletters/{newsletter}/destinations` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewsletterdestinations))

```sh
curl "https://api.usecommune.com/newsletters/example-letter/destinations" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "object": "list",
  "data": [
    {
      "object": "destination",
      "id": "des_4Nb8Fy1kLd",
      "newsletter": {
        "object": "newsletter",
        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
      },
      "type": "webhook",
      "target": "hooks.example.org",
      "topics": [
        "subscriber.created",
        "thread.created",
        "message.created",
        "send.completed"
      ],
      "enabled": true,
      "disabled_at": null,
      "created_at": "2026-08-27T10:05:19Z",
      "updated_at": null
    }
  ],
  "pagination": {
    "has_more": false,
    "next_cursor": null
  }
}
```

### 14. Find a delivery that failed

Every handover of an event to a destination is a row, newest first. A retry is a new row with `attempt` one higher, never an edit, so `status=failed` shows every failed try, including ones a later retry fixed. To follow one event from end to end, filter by `event_id` instead: it is the `Commune-Event-Id` your receiver saw, which is why it is worth logging. Attempts appear shortly after they happen rather than instantly.

`response_status` is what your endpoint answered, or `null` with `failure` saying why nothing did (`timeout` being the usual one). The body your endpoint answered with is not returned; the portal has it. This one is attempt 11, the last automatic try, so nothing will send it again unless you ask. Needs `sending: read`.

`GET /newsletters/{newsletter}/delivery-attempts` ([reference](https://api-reference.usecommune.dev/operation/operation-listnewsletterdeliveryattempts))

```sh
curl "https://api.usecommune.com/newsletters/example-letter/delivery-attempts?status=failed&destination_id=des_4Nb8Fy1kLd" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26"
```

Response `200`:

```json
{
  "object": "list",
  "data": [
    {
      "object": "delivery_attempt",
      "id": "att_2Hf6Vp8sZn",
      "newsletter": {
        "object": "newsletter",
        "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411"
      },
      "destination": {
        "object": "destination",
        "id": "des_4Nb8Fy1kLd"
      },
      "destination_type": "webhook",
      "event_id": "018f2a90-2222-7000-8000-000000000002",
      "event_type": "send.completed",
      "status": "failed",
      "response_status": 500,
      "failure": null,
      "attempt": 11,
      "manual": false,
      "created_at": "2026-08-26T18:03:41Z"
    }
  ],
  "pagination": {
    "has_more": true,
    "next_cursor": "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4"
  }
}
```

### 15. Replay it once you have fixed the cause

This delivers the same event (same `id`, same body) to the same destination once more, the same as the retry button in the portal. The attempt you name is left as it was; the new one appears in the log shortly afterwards with `manual: true`, so filter by the `event_id` in the answer to find it. It is a write, so send an `Idempotency-Key`, and it needs `sending: write`.

Replay after you have fixed whatever made your endpoint fail, not during an outage: automatic retries already cover that, and replaying something your receiver did process is a duplicate it has to absorb (your dedupe on `id` does). A disabled destination answers `422`; switch it back on in the portal first.

`POST /delivery-attempts/{attempt}/replay` ([reference](https://api-reference.usecommune.dev/operation/operation-replaydeliveryattempt))

```sh
curl -X POST "https://api.usecommune.com/delivery-attempts/att_2Hf6Vp8sZn/replay" \
  -H "Authorization: Bearer $COMMUNE_API_KEY" \
  -H "Commune-Version: 2026-08-26" \
  -H "Idempotency-Key: 5b0e7c2a-9d41-4f86-b3a7-1e6c8d2f40b9"
```

Response `202`:

```json
{
  "object": "delivery_replay",
  "attempt": {
    "object": "delivery_attempt",
    "id": "att_2Hf6Vp8sZn"
  },
  "event_id": "018f2a90-2222-7000-8000-000000000002",
  "destination": {
    "object": "destination",
    "id": "des_4Nb8Fy1kLd"
  }
}
```

### All together

The receiver and the three notification recipes as one file with no dependencies: it verifies with the guide's function, answers before it works, dedupes on `id`, ignores topics it does not know, and waits out a `429` when it reads a send back. Put the table from the last recipe in place of the `Set` before you run more than one copy.

```js
// receiver.mjs. Node 18 or newer, no dependencies.
//
//   COMMUNE_API_KEY=... COMMUNE_WEBHOOK_SECRET=whsec_... \
//   SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... \
//   CRM_WEBHOOK_URL=https://... node receiver.mjs

import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const BASE = "https://api.usecommune.com";
const VERSION = "2026-08-26";
const KEY = process.env.COMMUNE_API_KEY;
const SECRET = process.env.COMMUNE_WEBHOOK_SECRET; // "whsec_..."

// ------------------------------------------------ verifying (the guide's) --

function verify(raw, header, timestamp) {
  if (!header || !timestamp) return false;

  // The signed string is the timestamp in UNIX SECONDS, a dot, then the raw
  // body. The header is RFC 3339, so convert before signing.
  const seconds = Math.floor(Date.parse(timestamp) / 1000);
  if (!Number.isFinite(seconds)) return false;

  // Covered by the signature, so this window is a real defence.
  if (Math.abs(Date.now() / 1000 - seconds) > 300) return false;

  const expected = Buffer.from(
    createHmac("sha256", SECRET)
      .update(seconds + ".")
      .update(raw)
      .digest("hex"),
    "utf8",
  );

  // "v0=" then one or more hex digests, comma separated. Accept any of them.
  return header
    .replace(/^v0=/, "")
    .split(",")
    .some((candidate) => {
      const presented = Buffer.from(candidate.trim(), "utf8");
      return (
        presented.length === expected.length &&
        timingSafeEqual(presented, expected)
      );
    });
}

// ------------------------------------------------------------ calling out --

async function api(path) {
  const res = await fetch(BASE + path, {
    headers: { authorization: `Bearer ${KEY}`, "commune-version": VERSION },
  });
  if (res.status === 429) {
    await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") ?? 5) * 1000));
    return api(path);
  }
  const body = await res.json();
  if (!res.ok) {
    const { code, message, request_id } = body.error;
    throw new Error(`${res.status} ${code}: ${message} (request ${request_id})`);
  }
  return body;
}

async function postJson(url, payload) {
  const res = await fetch(url, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`${new URL(url).host} answered ${res.status}`);
}

const slack = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const toSlack = (text) => postJson(process.env.SLACK_WEBHOOK_URL, { text });

// --------------------------------------------------------------- recipes --

async function onConversation({ type, data }) {
  const who = data.author.display_name || data.author.username || "A reader";
  const what = type === "thread.created" ? "started a thread" : "replied";
  const said = data.content.length > 280 ? data.content.slice(0, 280) + "..." : data.content;
  await toSlack(`*${slack(who)}* ${what}: ${slack(said)}\n<${data.url}|Open it in Commune>`);
}

const handlers = {
  "subscriber.created": async ({ data, occurred_at }) => {
    if (data.status !== "subscribed") return;
    if (data.acquisition_source) return; // An import, not somebody who just chose you.
    await postJson(process.env.CRM_WEBHOOK_URL, {
      email: data.email,
      commune_subscriber_id: data.subscriber_id,
      stage: data.resubscribed ? "returning_subscriber" : "new_subscriber",
      subscribed_at: occurred_at,
    });
  },

  "thread.created": onConversation,
  "message.created": onConversation,

  "send.completed": async ({ data }) => {
    const send = await api(`/sends/${data.send_id}?expand=article`);
    const title = send.article?.title ?? "An issue";
    const share = data.recipient_count
      ? ` (${((data.sent_count / data.recipient_count) * 100).toFixed(1)}%)`
      : "";
    await toSlack(
      `*${slack(title)}* finished sending: ${data.sent_count} of ${data.recipient_count} handed to the email provider${share}, ${data.failed_count} failed.`,
    );
  },
};

// -------------------------------------------------------------- receiving --

const seen = new Set(); // One process only. Use the events table in production.

createServer((req, res) => {
  if (req.method !== "POST" || req.url !== "/commune/events") {
    return res.writeHead(404).end();
  }

  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const raw = Buffer.concat(chunks);

    if (!verify(raw, req.headers["commune-signature"], req.headers["commune-timestamp"])) {
      return res.writeHead(401).end();
    }

    const event = JSON.parse(raw.toString("utf8"));
    if (seen.has(event.id)) return res.writeHead(200).end();
    seen.add(event.id);

    // You have five seconds. Answer, then work.
    res.writeHead(200).end();

    const handler = handlers[event.type];
    if (!handler) return;
    handler(event).catch((error) => {
      console.error(`${event.type} ${event.id} failed:`, error.message);
    });
  });
}).listen(Number(process.env.PORT ?? 3000), () => {
  console.log("Listening for Commune events on /commune/events");
});
```
