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.
Before you start
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.
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 with
webhooks: write,sending: write,content: read, exported asCOMMUNE_API_KEY. - A public HTTPS URL for your endpoint. While you build, a tunnel such as
cloudflaredorngrokin front oflocalhost:3000works. - Node 18 or newer for the receiver. It has no dependencies.
- Some steps need more than this. Each one lists it where it starts.
Run it in your client
Every request below, in order, as a collection. Import the file into Postman, Insomnia, Bruno, Yaak or Hoppscotch, set apiKey, and send them one by one.
Every request sends Authorization: Bearer $COMMUNE_API_KEY and Commune-Version: 2026-08-26.
- 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 in your Commune dashboard, or mint a link to the same portal with this request (it needs
webhooks: write) and open theurlit returns. In the portal, add a webhook destination pointing at your URL, pick the topics this page uses (subscriber.created,thread.created,message.createdandsend.completed), and copy the signing secret. It starts withwhsec_; keep it asCOMMUNE_WEBHOOK_SECRET.The
urlis a credential: itstokenlets 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 noIdempotency-Key, and each call returns a new link.cURLcurl -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{ "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-SignatureandCommune-Timestamp. The signature is HMAC-SHA256, keyed with the secret exactly as issued (thewhsec_prefix included), over the timestamp converted to Unix seconds, a literal., then the raw body bytes, in lowercase hex. The header value isv0=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.mjsimport { 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 asCommune-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. TheSethere 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.mjsimport { 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.createdarrives 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_sourceisnullfor anybody who arrived through Commune itself and names the provider (orcsv) for an import, which matters because importing five thousand people fires five thousand of these, and none of them asked for a welcome.resubscribedistruefor a returning reader; on that pathcreated_atis when they first subscribed andoccurred_atis when they came back. Act only onstatussubscribed.Commune posts
subscriber.createdto your endpoint:event subscriber.created{ "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": "[email protected]", "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'll 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_idalongside it so a later event about the same person can find the same contact.subscriber-created.mjshandlers["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.createdarrives when somebody starts a new thread in your newsletter's space. Whenarticle_idis set, the thread is the discussion under that article, which is where comments live.authoris inlined, so there is nothing to look up, andurllinks straight to it.visibilitysays who can read it in Commune:subscriberskeeps 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.Commune posts
thread.createdto your endpoint:event thread.created{ "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.createdis a reply inside a thread.thread_levelis1for a reply to the thread and2for a reply to a reply, andparent_idpoints at what it answers. It has the same inlinedauthorand aurlthat lands on the reply itself.Commune posts
message.createdto your endpoint:event message.created{ "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'll need
- A Slack incoming webhook URL for the channel.
One function for both topics, since they carry the same
author,contentandurl. 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.mjsconst slack = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); 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.completedarrives 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 thedelivery.*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).
Commune posts
send.completedto your endpoint:event send.completed{ "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_idwithexpand=articleand the article comes back inline, title and all. This is also how you ask again later without having kept the payload. It needssending: read, and expanding the article needscontent: readtoo: an article is content, and a key without it is refused with403.cURLcurl "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{ "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'll need
- A Slack incoming webhook URL for the channel.
sent_countis recipients handed to the provider,failed_countthose it could not hand over. Say so in the message, so nobody reads 538 as 538 opens.send-completed.mjshandlers["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 onidis the shared dedupe store theSetstood in for.processed_attells you which events still need their work done. And ordering byoccurred_atgives 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.sqlcreate 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
topicsit receives and whether it isenabled; a destination the portal switched off keeps its row with adisabled_at.targetis 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. Needswebhooks: read.cURLcurl "https://api.usecommune.com/newsletters/example-letter/destinations" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "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
attemptone higher, never an edit, sostatus=failedshows every failed try, including ones a later retry fixed. To follow one event from end to end, filter byevent_idinstead: it is theCommune-Event-Idyour receiver saw, which is why it is worth logging. Attempts appear shortly after they happen rather than instantly.response_statusis what your endpoint answered, ornullwithfailuresaying why nothing did (timeoutbeing 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. Needssending: read.cURLcurl "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{ "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 withmanual: true, so filter by theevent_idin the answer to find it. It is a write, so send anIdempotency-Key, and it needssending: 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
iddoes). A disabled destination answers422; switch it back on in the portal first.cURLcurl -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{ "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.
// 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, "&").replace(/</g, "<").replace(/>/g, ">");
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");
});Next use cases
See all- API
Bring the conversation to your site
Show each issue's discussion wherever your readers already are.
Read the use case - AgentAPI
Get a pulse on your newsletter in plain words
Ask how things are going and get an answer, not a dashboard.
Read the use case - API
Build an app every creator can connect
Ship an integration other newsletters install in a few clicks.
Read the use case