Publish from wherever you write
Your writing already lives somewhere: a folder of Markdown files, a CMS, a notes app. Copying it into another editor every week is where typos creep in and send times slip. Commune takes the Markdown as it is, lets you check it and send yourself a test, then sends it to your list on time and tells you how it did.
Before you start
Sending from Commune only works for a newsletter that publishes with Commune: its esp is commune. A newsletter sent through another provider has its articles written there and mirrored into Commune afterwards, so creating or editing one answers 422 with the code not_commune_newsletter, and the error's docs_url links to the guide for moving that provider's newsletter onto Commune.
Every request the pipeline makes, in order: find the newsletter, create a draft from Markdown, read it back, change what needs changing, test it, schedule it or send it, and confirm what happened. Then the same thing wired to a repository, so a push to main is all it takes.
Before you start
- An API key with
content: write,sending: write,insights: read, exported asCOMMUNE_API_KEY. - Node 18 or newer, for the script at the end. Every request also works as the cURL shown, or from the collection.
- A plan that includes API writes; otherwise every write answers
402.GET /newsletters/{newsletter}/entitlements(withsettings: read) tells you in advance. - 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
Find your newsletter
A key reaches one newsletter or several, whichever its owner ticked when creating it. List them and pick yours by
handle(orid, either works in a path) for the requests below, and checkesp: onlycommunecan be written to and sent from here.cURLcurl "https://api.usecommune.com/newsletters" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "list", "data": [ { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411", "handle": "example-letter", "name": "The Example Letter", "description": "A weekly letter about how newsletters and communities fit together.", "esp": "commune", "image_url": "https://cdn.example.com/newsletters/example-letter/avatar.png", "website_url": "https://example.com", "social_links": { "twitter": "https://x.com/exampleletter", "bluesky": "https://bsky.app/profile/exampleletter.bsky.social" }, "language": "en", "chat_create_permission": "subscribers", "allow_non_subscriber_chat": false, "owner": { "object": "user", "id": "usr_2Nf8Kq1pWc" }, "featured_article": { "object": "article", "id": "4c9e2f81-0b7a-4d13-8e55-1a2b3c4d5e6f" }, "created_at": "2025-03-04T10:00:00Z", "updated_at": "2026-08-26T09:32:11Z" } ], "pagination": { "has_more": false, "next_cursor": null } } - 2
Create the article from Markdown
The body goes in
content_markdown, as Markdown: headings, emphasis, links, images, quotes, lists, code, tables. HTML is not accepted, and raw HTML inside the Markdown is refused rather than passed through (write a literal<as\<). Commune parses it strictly: a line it cannot read answers400naming the line, and nothing is written. Merge tags such as{{ subscriber.first_name }}are kept exactly as written.The body is stored exactly as sent, so it has to carry its own footer: an unsubscribe link and your mailing address.
<EmailOnly>keeps them in the email and off the web page. A send refuses a body without them.What comes back is always a draft. Nothing reaches anybody until step 6 or 7. Set
slugyourself when the article comes from a file: a slug already taken answers422instead of becoming something else, which is what stops a pipeline that runs twice from making two articles. It is a write, so send anIdempotency-Key.cURLcurl -X POST "https://api.usecommune.com/newsletters/example-letter/articles" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26" \ -H "Idempotency-Key: 9c2e7f41-6a3b-4d58-8e0f-1b7a5c3d9e26" \ -H "Content-Type: application/json" \ -d '{ "title": "What we learned in March", "preview_text": "The third one surprised us.", "slug": "what-we-learned-in-march", "content_markdown": "# What we learned in March\n\nThree things, and the **third** one surprised us.\n\n<EmailOnly>\n[Unsubscribe]({{ unsubscribe_url }}) | {{ address }}\n</EmailOnly>\n" }'Response 201{ "object": "article", "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012", "short_id": "Vn3Pq8Zt", "slug": "what-we-learned-in-march", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "title": "What we learned in March", "preview_text": "The third one surprised us.", "image_url": null, "external_url": null, "status": "draft", "is_imported": false, "posted_at": null, "scheduled_for": null, "authors": [ { "object": "user", "id": "usr_2Nf8Kq1pWc" } ], "thread": null, "stats": { "likes": 0, "comments": 0, "highlights": 0 }, "created_at": "2026-09-18T10:22:04Z", "updated_at": "2026-09-18T10:22:04Z" } - 3
Read it back
expand=contentaddscontent_markdownto the article: the body as Commune stored it, in the same Markdown you send. What you read back is what you sent, so this is the check that nothing was lost on the way.contentis the email rendered to HTML, with merge tags resolved against an empty context.cURLcurl "https://api.usecommune.com/articles/8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012?expand=content" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "article", "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012", "short_id": "Vn3Pq8Zt", "slug": "what-we-learned-in-march", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "title": "What we learned in March", "preview_text": "The third one surprised us.", "image_url": null, "external_url": null, "status": "draft", "is_imported": false, "posted_at": null, "scheduled_for": null, "authors": [ { "object": "user", "id": "usr_2Nf8Kq1pWc" } ], "thread": null, "stats": { "likes": 0, "comments": 0, "highlights": 0 }, "created_at": "2026-09-18T10:22:04Z", "updated_at": "2026-09-18T10:22:04Z", "content": "<h1>What we learned in March</h1><p>Three things, and the <strong>third</strong> one surprised us.</p>", "content_markdown": "# What we learned in March\n\nThree things, and the **third** one surprised us.\n\n<EmailOnly>\n[Unsubscribe]({{ unsubscribe_url }}) | {{ address }}\n</EmailOnly>\n" } - 4
Change what needs changing
Send only what changes. A property you leave out is left alone, and one you send as
nullis cleared.content_markdownreplaces the whole body, so send all of it, never a fragment.A draft, a scheduled article and one whose send failed can be edited. One that is sending, sent, archived or imported answers
422, and so does an empty object. Editing never moves the article's state.cURLcurl -X PATCH "https://api.usecommune.com/articles/8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26" \ -H "Idempotency-Key: e57b1d93-2f8c-4a06-b3d1-8c4e6f0a2b75" \ -H "Content-Type: application/json" \ -d '{ "preview_text": "Three lessons from March, and the one we did not expect." }'Response 200{ "object": "article", "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012", "short_id": "Vn3Pq8Zt", "slug": "what-we-learned-in-march", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "title": "What we learned in March", "preview_text": "Three lessons from March, and the one we did not expect.", "image_url": null, "external_url": null, "status": "draft", "is_imported": false, "posted_at": null, "scheduled_for": null, "authors": [ { "object": "user", "id": "usr_2Nf8Kq1pWc" } ], "thread": null, "stats": { "likes": 0, "comments": 0, "highlights": 0 }, "created_at": "2026-09-18T10:22:04Z", "updated_at": "2026-09-18T10:31:47Z" } - 5
Send yourself a test
You'll need
- A verified sending address on the newsletter. Test sends and real sends both refuse without one.
With no addresses, the copy goes to you: the person the key belongs to. Your address is not repeated in the answer, which says
sent_to_owner: trueinstead. Name up to five addresses intoto send it to someone else. The copy is rendered the way the real send renders it, with example data in the merge tags, a subject marked as a test and an unsubscribe link that cannot unsubscribe anybody. Nobody on the list receives anything and the article does not change, so test as often as you like. Tests do count towards the daily sending allowance.A test only needs a verified sending address and a body. It does not check the footer or the images, so it is exactly where to look for a footer that went missing before the real send refuses it.
cURLcurl -X POST "https://api.usecommune.com/articles/8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012/test-send" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26" \ -H "Idempotency-Key: 3a8d0c56-7e1f-4b92-a4c7-5d2e9f1b6a08" \ -H "Content-Type: application/json" \ -d '{}'Response 200{ "object": "test_send", "article": { "object": "article", "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012" }, "recipients": [], "sent_to_owner": true, "sent": 1, "failed": 0 } - 6
Schedule it
Give it a time in the future and it is queued.
statusbecomesscheduled,posted_atstaysnull, and it stays invisible to readers until it goes out. Commune sends queued articles in passes, so the time is honoured to within a few minutes rather than to the second. Scheduling it again moves it; taking it off the schedule isPOST /articles/{article}/unschedule.Every check the real send makes happens now, not at nine in the morning. A body without its unsubscribe link or address answers
422(missing_footer), and so does an image that definitely will not load (broken_images), naming the URLs. Fix the image, or setacknowledge_broken_imagesto go ahead anyway; any later edit to the body clears that acknowledgement. There is no way past the footer check.cURLcurl -X POST "https://api.usecommune.com/articles/8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012/schedule" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26" \ -H "Idempotency-Key: 71f4c2e8-0d5a-4b37-9c61-e3a8b0d4f2c9" \ -H "Content-Type: application/json" \ -d '{ "scheduled_for": "2026-10-01T09:00:00Z" }'Response 200{ "object": "article", "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012", "short_id": "Vn3Pq8Zt", "slug": "what-we-learned-in-march", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "title": "What we learned in March", "preview_text": "Three lessons from March, and the one we did not expect.", "image_url": null, "external_url": null, "status": "scheduled", "is_imported": false, "posted_at": null, "scheduled_for": "2026-10-01T09:00:00Z", "authors": [ { "object": "user", "id": "usr_2Nf8Kq1pWc" } ], "thread": null, "stats": { "likes": 0, "comments": 0, "highlights": 0 }, "created_at": "2026-09-18T10:22:04Z", "updated_at": "2026-09-18T10:40:02Z" } - 7
Or send it now
Instead of step 6, when it should go out straight away. It cannot be undone. The answer is
202: the article is queued for immediate dispatch, withstatusscheduledandscheduled_forset to the moment it was queued, and sending begins within a few minutes. The same checks apply as for a schedule, and an article that is already sending or sent answers422.The body is optional and usually left out. Its only field is
acknowledge_broken_images.cURLcurl -X POST "https://api.usecommune.com/articles/8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012/send" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26" \ -H "Idempotency-Key: c0e93b17-5f2d-4a84-b6e8-2a9d7c1f3e50"Response 202{ "object": "article", "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012", "short_id": "Vn3Pq8Zt", "slug": "what-we-learned-in-march", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "title": "What we learned in March", "preview_text": "Three lessons from March, and the one we did not expect.", "image_url": null, "external_url": null, "status": "scheduled", "is_imported": false, "posted_at": null, "scheduled_for": "2026-09-18T10:40:02Z", "authors": [ { "object": "user", "id": "usr_2Nf8Kq1pWc" } ], "thread": null, "stats": { "likes": 0, "comments": 0, "highlights": 0 }, "created_at": "2026-09-18T10:22:04Z", "updated_at": "2026-09-18T10:40:02Z" } - 8
Confirm it went out
A send is one run of the dispatch, and
articlenarrows the list to this article's runs (byid; the short id is not accepted here).completed_atisnullwhile recipients are still being handed over; once it is set,recipient_count,sent_countandfailed_countare the numbers the run finished on.A recipient the provider refused for a moment is retried during the send. One that still fails is followed up by Commune rather than sent again blindly, because a second copy is worse than a late one.
cURLcurl "https://api.usecommune.com/newsletters/example-letter/sends?article=8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "list", "data": [ { "object": "send", "id": "2d4f6a8c-1b3e-4c5d-8e7f-9a0b1c2d3e4f", "article": { "object": "article", "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012" }, "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "started_at": "2026-10-01T09:02:13Z", "completed_at": "2026-10-01T09:04:51Z", "recipient_count": 12402, "sent_count": 12398, "failed_count": 4, "created_at": "2026-10-01T09:02:10Z" } ], "pagination": { "has_more": false, "next_cursor": null } } - 9
Read how it did
Both sides in one report.
emailcounts recipients, not events: how many were delivered to, opened at least once, clicked at least once, bounced and unsubscribed from this article. Divide bydeliveredfor the rates a provider quotes.communityis what readers did with it on Commune: views, likes, saves, highlights and the discussion under it. It is counted when you ask and keeps rising after the send, so read it again in a week.cURLcurl "https://api.usecommune.com/articles/8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012/stats" \ -H "Authorization: Bearer $COMMUNE_API_KEY" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "article_stats", "article": { "object": "article", "id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012" }, "email": { "recipients": 12402, "delivered": 12371, "opened": 6904, "clicked": 1288, "bounced": 27, "unsubscribed": 9 }, "community": { "views": 1873, "likes": 142, "saves": 58, "highlights": 71, "thread_messages": 34, "participants": 19 } } - 10
Hear when it goes out
Rather than polling, let Commune tell you.
send.completedarrives when a run has handed every recipient to the email provider, with the same three counts as step 8 and thesend_idto read it back by.send.failedarrives instead if the dispatch broke, andarticle.publishedwhen the article goes live on the web: the moment to post the link to social media or your site.When the send was a send-now request through the API,
actornames your key andidempotency_keyis the key you sent with it. Otherwise, as for this scheduled run, treatnullas ordinary. Register the endpoint under Webhooks in your Commune dashboard.Commune posts
send.completedto your endpoint:event send.completed{ "id": "018f2a90-4444-7000-8000-000000000004", "type": "send.completed", "api_version": "2026-08-26", "occurred_at": "2026-10-01T09:04:51Z", "newsletter_id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411", "actor": null, "idempotency_key": null, "data": { "article_id": "8f1b7c44-2a19-4d90-b7e2-51d6c3a8f012", "send_id": "2d4f6a8c-1b3e-4c5d-8e7f-9a0b1c2d3e4f", "started_at": "2026-10-01T09:02:13Z", "completed_at": "2026-10-01T09:04:51Z", "recipient_count": 12402, "sent_count": 12398, "failed_count": 4 } } - 11
Write posts as files
Now wire it to where you write. Here that is a repository: one Markdown file per issue under
posts/, with a little frontmatter on top.titleandpreviewbecome the subject and preview line,slugdefaults to the file name, andsend_atdecides what happens: leave it out to keep a draft, set a time to schedule, or writenowto send on push.The script below adds the unsubscribe footer when the file does not carry one, so the posts stay about the writing.
posts/what-we-learned-in-march.md--- title: What we learned in March preview: Three lessons from March, and the one we did not expect. send_at: 2026-10-01T09:00:00Z --- # What we learned in March Three things, and the **third** one surprised us. - 12
Publish on every push to main
A GitHub Actions workflow that runs the script for every post added or changed in the push, with the key and the test addresses kept as repository secrets. Each run sends you a test first.
Push an edit to a post that is still a draft or scheduled and the script updates that article and schedules it again, which re-runs the checks. A post whose article already went out is refused on its slug and never sent twice.
.github/workflows/publish.ymlname: Publish to Commune on: push: branches: [main] paths: ["posts/**.md"] concurrency: publish jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 20 - name: Publish added or changed posts run: | for file in $(git diff --name-only --diff-filter=AM "${{ github.event.before }}" "${{ github.sha }}" -- 'posts/*.md'); do node publish.mjs "$file" done env: COMMUNE_API_KEY: ${{ secrets.COMMUNE_API_KEY }} COMMUNE_TEST_TO: ${{ secrets.COMMUNE_TEST_TO }} - 13
Check on it from your agent
Once the pipeline runs, you rarely need to read these responses yourself. Connect your agent to Commune's MCP server and ask in plain words; the tools it gets read your newsletter and change nothing.
"What is still unsent?" is answered by
what_have_i_not_sent_yet: your drafts and scheduled articles in one list, newest date first, each with itsstatus, so you can see that the push queued what you expected. "How did my most recent article perform?" is answered byhow_did_my_latest_article_do: your newest sent article with the same report as step 9, delivered, opened, clicked, bounced and unsubscribed on the email side, and views, likes, saves, highlights, replies and participants on the community side.
All together
Steps 1 to 7 as one file, with no dependencies: node publish.mjs posts/what-we-learned-in-march.md. It reads the frontmatter, updates the unsent article with the same slug or creates a new one, sends a test to COMMUNE_TEST_TO (a comma separated list, five at most), then keeps it as a draft, schedules it or sends it. Each write carries its own Idempotency-Key, reused when that request is retried, so a retry never makes a change twice.
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
const BASE = "https://api.usecommune.com";
const VERSION = "2026-08-26";
const KEY = process.env.COMMUNE_API_KEY;
const TEST_TO = (process.env.COMMUNE_TEST_TO ?? "")
.split(",")
.map((address) => address.trim())
.filter(Boolean)
.slice(0, 5);
// A write gets one Idempotency-Key, kept across its retries, so a retry
// replays the first answer instead of making the change twice.
async function api(path, { method = "GET", body, key = method === "GET" ? null : crypto.randomUUID() } = {}) {
const headers = { authorization: `Bearer ${KEY}`, "commune-version": VERSION };
if (key) headers["idempotency-key"] = key;
if (body !== undefined) headers["content-type"] = "application/json";
const res = await fetch(BASE + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 429 || (res.status === 409 && res.headers.has("retry-after"))) {
await new Promise((r) => setTimeout(r, Number(res.headers.get("retry-after") ?? 5) * 1000));
return api(path, { method, body, key });
}
const json = await res.json();
if (!res.ok) {
const { code, message, request_id } = json.error;
throw Object.assign(new Error(`${res.status} ${code}: ${message} (request ${request_id})`), {
status: res.status,
});
}
return json;
}
async function* all(path) {
let cursor = null;
do {
const sep = path.includes("?") ? "&" : "?";
const page = await api(cursor ? `${path}${sep}cursor=${encodeURIComponent(cursor)}` : path);
yield* page.data;
cursor = page.pagination.next_cursor;
} while (cursor);
}
// The post: frontmatter on top, Markdown below.
const file = process.argv[2];
const source = await readFile(file, "utf8");
const front = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
const meta = {};
for (const line of (front?.[1] ?? "").split(/\r?\n/)) {
const pair = line.match(/^([A-Za-z_]+):\s*(.*)$/);
if (pair) meta[pair[1]] = pair[2].trim().replace(/^(["'])(.*)\1$/, "$2");
}
let markdown = front ? source.slice(front[0].length) : source;
if (!markdown.includes("{{ unsubscribe_url }}")) {
markdown = `${markdown.trimEnd()}\n\n<EmailOnly>\n[Unsubscribe]({{ unsubscribe_url }}) | {{ address }}\n</EmailOnly>\n`;
}
const slug = (meta.slug ?? basename(file, ".md"))
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60)
.replace(/-+$/, "");
const fields = { content_markdown: markdown };
if (meta.title) fields.title = meta.title;
if (meta.preview) fields.preview_text = meta.preview;
if (meta.image) fields.image_url = meta.image;
// 1. The newsletter this key can see, which has to publish with Commune.
// A key can reach several newsletters. COMMUNE_NEWSLETTER picks one by
// handle; without it, a key that reaches exactly one uses that one.
const { data: newsletters } = await api("/newsletters");
const wanted = process.env.COMMUNE_NEWSLETTER;
const newsletter = wanted
? newsletters.find((n) => n.handle === wanted || n.id === wanted)
: newsletters.length === 1 ? newsletters[0] : null;
if (!newsletter) {
throw new Error(
wanted
? "This key does not reach " + wanted + "."
: "This key reaches " + newsletters.length + " newsletters. Set COMMUNE_NEWSLETTER to one handle.",
);
}
if (newsletter.esp !== "commune") {
throw new Error(`${newsletter.handle} is sent through ${newsletter.esp}, so Commune cannot send it.`);
}
// 2 and 4. Update the unsent article with this slug, or create one.
let article = null;
for await (const candidate of all(`/newsletters/${newsletter.handle}/articles?status=draft&status=scheduled&limit=100`)) {
if (candidate.slug === slug) {
article = candidate;
break;
}
}
if (article) {
article = await api(`/articles/${article.id}`, { method: "PATCH", body: fields });
console.log(`Updated ${slug} (${article.status}).`);
} else {
try {
article = await api(`/newsletters/${newsletter.handle}/articles`, {
method: "POST",
body: { ...fields, slug },
});
console.log(`Created draft ${slug} (${article.short_id}).`);
} catch (error) {
if (error.status === 422) {
console.log(`${slug}: ${error.message}. Already published? Nothing was sent.`);
process.exit(0);
}
throw error;
}
}
// 5. A test to your own inbox.
if (TEST_TO.length) {
const test = await api(`/articles/${article.id}/test-send`, { method: "POST", body: { to: TEST_TO } });
console.log(`Test sent to ${test.sent} of ${test.recipients.length} addresses.`);
}
// 6 or 7. Keep it as a draft, schedule it, or send it now.
if (!meta.send_at) {
console.log(`Left as ${article.status}. Add send_at to the frontmatter to schedule it.`);
} else if (meta.send_at === "now") {
article = await api(`/articles/${article.id}/send`, { method: "POST" });
console.log(`Queued to send now (${article.scheduled_for}).`);
} else {
article = await api(`/articles/${article.id}/schedule`, {
method: "POST",
body: { scheduled_for: new Date(meta.send_at).toISOString() },
});
console.log(`Scheduled for ${article.scheduled_for}.`);
}Next use cases
See all- API
React the moment something happens
A new subscriber, a new reply, an issue sent: start your own workflow in seconds.
Read the use case - 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