Build an app every creator can connect
An API key works for your own newsletter. To build something other creators use, they need a way to say yes without copying a secret into your app. Commune lets them connect your app from a consent screen, pick the newsletters it may reach and see exactly what it may do, and take it back whenever they like.
Before you start
The sign-in half of this happens on the Commune app rather than the API, so those endpoints are not in the API reference and not in the collection. The collection holds the three requests made with the token.
Your app asks Commune for permission, the creator grants it on a screen Commune shows them, and your app receives an access token it uses exactly like an API key. Thirteen steps: choosing what to ask for, the eight that get and keep a token, three requests made with it, and letting go of it.
Before you start
- An app that asks creators for
content: read. The access token it gets back is exported asACCESS_TOKEN. - A Commune account that runs at least one newsletter, so you can be the creator on the other side of the consent screen while you build.
- Node 18 or newer, for the script at the end. Nothing to install.
- 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 accessToken, and send them one by one.
Every request sends Authorization: Bearer $ACCESS_TOKEN and Commune-Version: 2026-08-26.
- 1
Ask for the least you need
Permissions come in six families,
content,audience,sending,insights,settingsandwebhooks, each atreadorwrite, writtenfamily:leveland separated by spaces. The consent screen shows the creator each family and level you ask for, and the same set applies to every newsletter they tick. Ask only for what your app uses today.audience:readcarries every subscriber's email address, andsending:writeputs real mail in front of real people: asking for either when you do not need it is the fastest way to a creator pressing cancel.This walkthrough builds an app that reads a newsletter's published articles, so it asks for
content:readand nothing else. A scope never names a newsletter. It says what may be done, and the creator says where.Three rules worth knowing before you choose. Sending no
scopeat all means all six families atread, which is more than most apps need, so always send one. An unknown scope is refused withinvalid_scope, never quietly dropped. Andaccount:readis a separate axis: alone, it reaches the person's own account and no newsletter, and with a family it adds a switch to the screen that starts off, so the creator decides.If your app needs more later, you send the creator through the flow again asking for the whole new set. A second consent replaces what the connection held rather than adding to it.
- 2
Follow the 401 to the authorization server
Call the API with no credential and the answer tells you where to go, in the
WWW-Authenticateheader:resource_metadatais the address of a document describing the API as a protected resource. Itsauthorization_serversfield is the one you need. The API and the authorization server are different origins on purpose: issuing a credential means showing a signed-in person a screen, and that is the Commune app's job, not the API's.Both documents are public, need no credential and are the same for everyone, so read them once when your app starts.
shellcurl -i https://api.usecommune.com/newsletters # HTTP/2 401 # www-authenticate: Bearer realm="Commune API", resource_metadata="https://api.usecommune.com/.well-known/oauth-protected-resource" curl https://api.usecommune.com/.well-known/oauth-protected-resource # { # "resource": "https://api.usecommune.com", # "authorization_servers": ["https://usecommune.com"], # "scopes_supported": [ # "content:read", "content:write", # "audience:read", "audience:write", # "sending:read", "sending:write", # "insights:read", "insights:write", # "settings:read", "settings:write", # "webhooks:read", "webhooks:write", # "account:read" # ], # "bearer_methods_supported": ["header"], # "resource_documentation": "https://usecommune.dev" # } - 3
Read the authorization server's metadata
The authorization server describes itself at
/.well-known/oauth-authorization-serveron the origin the last document named. It holds every URL the rest of this walkthrough uses: read them from here rather than hardcoding them.Three entries decide how you build.
code_challenge_methods_supportedisS256and onlyS256, so PKCE is not optional.resource_indicators_supportedistrue, so name the API when you ask for a token. Andregistration_endpointis there, so your app registers itself.There is no OpenID discovery document and no
id_token. Tokens are opaque strings, which is what lets a creator's disconnect take effect on your very next request.GET https://usecommune.com/.well-known/oauth-authorization-server{ "issuer": "https://usecommune.com", "authorization_endpoint": "https://usecommune.com/api/oauth/authorize", "token_endpoint": "https://usecommune.com/api/oauth/token", "userinfo_endpoint": "https://usecommune.com/api/oauth/userinfo", "registration_endpoint": "https://usecommune.com/api/oauth/register", "revocation_endpoint": "https://usecommune.com/api/oauth/revoke", "scopes_supported": [ "content:read", "content:write", "audience:read", "audience:write", "sending:read", "sending:write", "insights:read", "insights:write", "settings:read", "settings:write", "webhooks:read", "webhooks:write", "account:read", "offline_access" ], "response_types_supported": ["code"], "response_modes_supported": ["query"], "grant_types_supported": ["authorization_code", "refresh_token"], "token_endpoint_auth_methods_supported": [ "none", "client_secret_basic", "client_secret_post" ], "revocation_endpoint_auth_methods_supported": [ "none", "client_secret_basic", "client_secret_post" ], "code_challenge_methods_supported": ["S256"], "claims_supported": [ "sub", "name", "preferred_username", "picture", "email", "email_verified", "commune:newsletter_ids", "commune:grant_id", "commune:scopes" ], "resource_indicators_supported": true, "resource_documentation": "https://usecommune.dev", "commune:protected_resource": "https://api.usecommune.com" } - 4
Register your app
Registration is open and unauthenticated: one request, no form, no allowlist.
redirect_urisis the only required field, up to ten of them, eachhttps, orhttpon a loopback host such aslocalhostwhile you develop, and none with a fragment. The script at the end registershttp://localhost:3000/callback.Keep the `client_id` that comes back. A registration cannot be read back, updated or deleted afterwards, so there is no way to recover it. Register once, store it with your app's configuration, and reuse it for every creator.
token_endpoint_auth_methoddefaults tonone, a public client with no secret, and that is the right choice for anything you distribute. Ask forclient_secret_basicorclient_secret_postonly if your app is a server nobody else runs; the response then also carries aclient_secretthat never expires.client_nameis what the consent screen shows, in quotes, beside a line telling the creator the name is self-reported and that they should recognise it as the app they just started. Register the name your users know you by.shellcurl -sS https://usecommune.com/api/oauth/register \ -H "Content-Type: application/json" \ -d '{ "client_name": "Drift Report", "client_uri": "https://driftreport.example", "redirect_uris": ["https://driftreport.example/callback"], "token_endpoint_auth_method": "none", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"] }' # HTTP/2 201 # { # "client_id": "cmn_cid_7Qk2Rm9xTp4Zb1Vw6Ys3Nd8Hc5Jf0Lg", # "client_id_issued_at": 1789159049, # "redirect_uris": ["https://driftreport.example/callback"], # "token_endpoint_auth_method": "none", # "grant_types": ["authorization_code", "refresh_token"], # "response_types": ["code"], # "client_name": "Drift Report", # "client_uri": "https://driftreport.example" # } - 5
Make a PKCE pair for each connection
Every authorization needs a fresh PKCE pair: a random
verifieryour app keeps, and achallengederived from it that goes in the URL. Only the holder of the verifier can turn the code into tokens, so a code intercepted on its way back to you is worthless to whoever caught it.The challenge method must be
S256, and it must be sent. Leavingcode_challenge_methodout is refused rather than treated asplain, which matters because some OAuth libraries rely on that default. The server does not check the verifier's length, so keep to the 43 to 128 characters the specification asks for; 32 random bytes as base64url is exactly 43.To check your code, RFC 7636's own pair is
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk, whose challenge isE9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM.pkce.mjsimport { createHash, randomBytes } from "node:crypto"; // 32 random bytes as base64url: 43 characters, the shortest legal verifier. const verifier = randomBytes(32).toString("base64url"); // BASE64URL(SHA256(ASCII(verifier))), unpadded. const challenge = createHash("sha256").update(verifier).digest("base64url"); // A value you can recognise when the creator comes back. const state = randomBytes(16).toString("hex"); // Keep verifier against state until the callback: in your session or database. pending.set(state, { verifier, startedAt: Date.now() }); - 6
Send the creator to the consent screen
Redirect the creator's browser to the authorization endpoint with the parameters below.
redirect_urimust match one you registered byte for byte: no extra query string, no trailing slash.resourcenames the API, so the token cannot be replayed anywhere else.stateis optional to the server and essential to you: it comes back on every redirect, success or failure, and it is how you know the callback is one you started.If the creator is not signed in to Commune, they sign in first and come straight back to this URL; you do not handle that. Then they see the consent screen, which always appears, whatever your app sends. It asks which newsletters your app may reach, as a tick box per newsletter they run with nothing ticked to begin with, and shows what it may do on them, family by family, from the scopes you asked for. With
account:readin the request it also shows a switch, off by default, for reading their account.A request is good for ten minutes. After that the screen says it expired and the creator starts again from your app. A newsletter holds at most twenty live connections, and your app spends one on each newsletter ticked. If one of them is full, the screen refuses and names it, and the creator unticks it or disconnects something there.
open this in the creator's browserhttps://usecommune.com/api/oauth/authorize ?response_type=code &client_id=cmn_cid_7Qk2Rm9xTp4Zb1Vw6Ys3Nd8Hc5Jf0Lg &redirect_uri=https%3A%2F%2Fdriftreport.example%2Fcallback &scope=content%3Aread &state=9f2c1b7e4a &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256 &resource=https%3A%2F%2Fapi.usecommune.com - 7
Handle the callback
The creator comes back to your
redirect_urieither way. On allow, the query carries acodeand yourstate. On cancel, it carrieserror=access_denied, anerror_descriptionand yourstate. The same three parameters also report a request the server refused before the screen:invalid_requestfor a missing or non-S256challenge,invalid_scope,invalid_targetfor a wrongresourceandunsupported_response_type.Check `state` before you touch anything else, on both branches, and drop a callback whose
stateyou do not hold. Then look up the verifier you stored under it.A problem with the
client_idor theredirect_uriitself never reaches you: until the redirect URI is proven to be yours there is nowhere safe to send an error, so Commune shows it to the creator instead. If creators report an error page instead of arriving back, check those two first.the two ways backhttps://driftreport.example/callback ?code=cmn_ac_4Hd8Kf2Lm6Qp0Rs9Tv3Wx7Yz1Ab5Cd &state=9f2c1b7e4a https://driftreport.example/callback ?error=access_denied &error_description=The%20newsletter%27s%20owner%20declined%20this%20request. &state=9f2c1b7e4a - 8
Exchange the code for tokens
Post the code to the token endpoint straight away: it is single use and lives sixty seconds. Send it form encoded, with the same
redirect_urithe authorization used, yourclient_id, thecode_verifieryou kept and theresource. A public client sends no secret.The answer holds an access token that lives an hour (
expires_in), a refresh token, and the scopes actually granted. Readscopeoff the response and believe it rather than assuming it echoes your request; the creator can narrow it. You get a refresh token whether or not you asked foroffline_access.Store the pair against the creator in your database before you do anything else with it. Every failure here is a
400or401with anerrorsuch asinvalid_grant, which covers a code that is unknown, expired, already used or issued to someone else, and a verifier that does not match.shellcurl -sS https://usecommune.com/api/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=authorization_code \ -d code=cmn_ac_4Hd8Kf2Lm6Qp0Rs9Tv3Wx7Yz1Ab5Cd \ -d redirect_uri=https://driftreport.example/callback \ -d client_id=cmn_cid_7Qk2Rm9xTp4Zb1Vw6Ys3Nd8Hc5Jf0Lg \ -d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk \ -d resource=https://api.usecommune.com # HTTP/2 200 # { # "access_token": "cmn_at_2Bc5Df8Gh1Jk4Lm7Np0Qr3St6Uv9Wx2Y", # "token_type": "Bearer", # "expires_in": 3600, # "refresh_token": "cmn_rt_5Ef8Hj1Km4Np7Qs0Tv3Wy6Za9Bd2Cg5H", # "scope": "content:read" # } - 9
See who connected
You'll need
- An access token from step 8. To try this and the next two steps before your app has a connection, an API key with
content: readanswers them exactly as an access token with the same permissions does.
From here on your app talks to the API, and the bearer on every request is the access token, sent exactly as an API key would be. Send
Commune-Versionas well: without it the connection is pinned to whichever version was current when the creator consented, a version your code never chose.This request takes any credential and needs no scope, so it works whatever the creator granted. Use
idas the key for this creator in your own database.display_namecan be empty, so fall back tousername.cURLcurl "https://api.usecommune.com/me" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "me", "id": "usr_2Nf8Kq1pWc", "username": "mira", "display_name": "Mira Okafor", "avatar": "https://cdn.example.com/avatars/mira.png", "email": "[email protected]", "email_verified": true, "created_at": "2025-03-04T09:12:00Z" } - An access token from step 8. To try this and the next two steps before your app has a connection, an API key with
- 10
Find the newsletters they ticked
One connection covers every newsletter the creator ticked, and this is where you learn which. It returns those newsletters and nothing else, so read it rather than assuming there is one. Keep each
handleorid(either works in a path): every other request names one.Call it again whenever it matters. What the token reaches is recomputed on every request from what the creator granted and what they can still do: if they leave a newsletter's team, or an owner cuts your app off one newsletter, it drops out of this list with nothing else changing.
cURLcurl "https://api.usecommune.com/newsletters" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -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 } } - 11
Read what they granted you
Now the work your app exists for. This needs
content:read, which is what it asked for, and returns the newsletter's published articles, newest first, 20 at a time; followpagination.next_cursorfor more.A request that needs more than the creator granted answers
403withinsufficient_scope, andallowed_valuesnames the scope that would have worked. The fix is not a retry: it is another trip through the consent screen asking for the full set you now need.cURLcurl "https://api.usecommune.com/newsletters/example-letter/articles" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Commune-Version: 2026-08-26"Response 200{ "object": "list", "data": [ { "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" }, { "object": "article", "id": "5b7a1d90-2c34-4e18-9f6b-8d0a1c2b3e4f", "short_id": "q4Ts9wLm", "slug": "the-week-we-stopped-chasing-opens", "newsletter": { "object": "newsletter", "id": "7d3f1c02-58a1-4a4e-9a0b-2f6d1c9e4411" }, "title": "The week we stopped chasing opens", "preview_text": null, "image_url": null, "external_url": "https://example.com/p/the-week-we-stopped-chasing-opens", "status": "sent", "is_imported": true, "posted_at": "2026-08-19T09:30:00Z", "scheduled_for": null, "authors": [ { "object": "user", "id": "usr_5Qw8Hn2vFd" } ], "thread": null, "stats": { "likes": 61, "comments": 9, "highlights": 14 }, "created_at": "2026-08-26T20:21:09Z", "updated_at": "2026-08-26T20:21:09Z" } ], "pagination": { "has_more": true, "next_cursor": "Y3Vyc29yOjE3NTY0MjM2MDAwMDA6MDE5MmM4" } } - 12
Refresh before the hour is up
Schedule a refresh against
expires_inrather than waiting for a401. The answer has the same shape as the exchange: a new access token, a new refresh token, and the grant's scopes. The old refresh token is dead before the response reaches you, so write the new pair down before you use it.Never refresh the same token twice. Presenting a refresh token that was already used revokes the creator's whole connection, because a replay and a theft look the same from the server, and the creator has to consent again from scratch. Two workers refreshing at once is exactly how that happens, so let one refresh run at a time per connection, as below, or hold a lock in your database.
A refresh token lives ninety days and each refresh restarts the clock, so a connection in regular use never lapses and one nobody has touched in a quarter does.
refresh.mjs// One refresh in flight per connection. A second caller waits for the first. const inFlight = new Map(); function refresh(connection) { if (!inFlight.has(connection.id)) { inFlight.set(connection.id, (async () => { const res = await fetch("https://usecommune.com/api/oauth/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: connection.refreshToken, client_id: CLIENT_ID, }), }); const body = await res.json(); if (!res.ok) throw Object.assign(new Error(body.error_description), { code: body.error }); // Persist first: a crash after this line must not leave you holding the spent token. await saveTokens(connection.id, { accessToken: body.access_token, refreshToken: body.refresh_token, scope: body.scope, expiresAt: Date.now() + body.expires_in * 1000, }); })().finally(() => inFlight.delete(connection.id))); } return inFlight.get(connection.id); } - 13
Disconnect, and notice when the creator does
When a creator disconnects from your side, post the refresh token to the revocation endpoint with your
client_id. The access token issued with it stops working too, since the two are one pair. It always answers200with an empty body, whether or not the token was live, so there is nothing to check. This ends your app's hold on the connection; the creator still sees the connection listed on their newsletter until they remove it there, and removing it there is theirs to do.The creator can also cut your app off at any time, from each newsletter's MCP page in Commune, and that ends every token issued under the connection at once, from your very next request. You will see it one of two ways: a
401that a refresh does not fix, or a refresh answeringinvalid_grantbecause the connection was revoked. Either way, mark the connection as disconnected and offer the connect link again. Do not retry.disconnect.mjs// Your side: the creator pressed Disconnect in your app. await fetch("https://usecommune.com/api/oauth/revoke", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ token: connection.refreshToken, client_id: CLIENT_ID }), }); await deleteTokens(connection.id); // Their side: a refresh that fails with invalid_grant means start again. try { await refresh(connection); } catch (err) { if (err.code !== "invalid_grant") throw err; await deleteTokens(connection.id); await askToReconnect(connection.id); }
All together
A complete integration in one file, with no dependencies: it discovers the authorization server, registers itself on first run, serves /connect and /callback, keeps the tokens in memory, refreshes one at a time, and on connection shows who connected and each granted newsletter's latest articles. Run it with node connect.mjs, open http://localhost:3000/connect while signed in to Commune, and set COMMUNE_CLIENT_ID to the printed id so later runs reuse the registration. A real app keeps the pending states and the tokens in its database, per creator.
import { createServer } from "node:http";
import { createHash, randomBytes } from "node:crypto";
const API = "https://api.usecommune.com";
const VERSION = "2026-08-26";
const PORT = 3000;
const REDIRECT_URI = `http://localhost:${PORT}/callback`;
const SCOPE = "content:read";
// The authorization server answers RFC 6749 errors; the API answers its own envelope.
async function read(res) {
const body = await res.json();
if (res.ok) return body;
const detail = body.error?.message
? `${body.error.code}: ${body.error.message} (request ${body.error.request_id})`
: `${body.error}: ${body.error_description}`;
throw Object.assign(new Error(`${res.status} ${detail}`), { status: res.status, code: body.error });
}
// 1. Discovery: the API names its authorization server, which describes itself.
const resource = await read(await fetch(`${API}/.well-known/oauth-protected-resource`));
const server = await read(
await fetch(`${resource.authorization_servers[0]}/.well-known/oauth-authorization-server`),
);
// 2. Registration, once. The client_id cannot be recovered, so keep it.
let clientId = process.env.COMMUNE_CLIENT_ID;
if (!clientId) {
const client = await read(
await fetch(server.registration_endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
client_name: "Drift Report",
redirect_uris: [REDIRECT_URI],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
}),
}),
);
clientId = client.client_id;
console.log(`Registered. Run with COMMUNE_CLIENT_ID=${clientId} to reuse it.`);
}
const pending = new Map(); // state -> { verifier, startedAt }
let tokens = null; // { access, refresh, scope, expiresAt }
let refreshing = null;
async function requestTokens(params) {
const body = await read(
await fetch(server.token_endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: clientId, ...params }),
}),
);
tokens = {
access: body.access_token,
refresh: body.refresh_token,
scope: body.scope,
expiresAt: Date.now() + body.expires_in * 1000,
};
}
// One refresh at a time: replaying a refresh token revokes the whole connection.
function refresh() {
refreshing ??= requestTokens({ grant_type: "refresh_token", refresh_token: tokens.refresh })
.catch((err) => {
if (err.code === "invalid_grant") tokens = null; // revoked or lapsed: connect again
throw err;
})
.finally(() => {
refreshing = null;
});
return refreshing;
}
async function api(path, retried = false) {
if (!tokens) throw new Error("Not connected. Open /connect.");
if (Date.now() > tokens.expiresAt - 60_000) await refresh();
const res = await fetch(API + path, {
headers: { authorization: `Bearer ${tokens.access}`, "commune-version": VERSION },
});
if (res.status === 401 && !retried) {
await refresh();
return api(path, true);
}
if (res.status === 401) {
tokens = null;
throw new Error("The connection was revoked. Open /connect to connect again.");
}
return read(res);
}
createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
const reply = (status, text, headers = {}) =>
res.writeHead(status, { "content-type": "text/plain; charset=utf-8", ...headers }).end(text);
try {
// 3 and 4. A fresh PKCE pair and state, then off to the consent screen.
if (url.pathname === "/connect") {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const state = randomBytes(16).toString("hex");
pending.set(state, { verifier, startedAt: Date.now() });
const authorize = new URL(server.authorization_endpoint);
authorize.search = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: REDIRECT_URI,
scope: SCOPE,
state,
code_challenge: challenge,
code_challenge_method: "S256",
resource: API,
}).toString();
return reply(302, "", { location: authorize.href });
}
// 5. The way back: state first, then the error, then the code.
if (url.pathname === "/callback") {
const state = url.searchParams.get("state");
const started = pending.get(state);
pending.delete(state);
if (!started || Date.now() - started.startedAt > 10 * 60_000) {
return reply(400, "This callback was not started here, or it expired. Open /connect.");
}
if (url.searchParams.has("error")) {
return reply(400, `Not connected: ${url.searchParams.get("error_description")}`);
}
// 6. The code lives sixty seconds, so exchange it now.
await requestTokens({
grant_type: "authorization_code",
code: url.searchParams.get("code"),
redirect_uri: REDIRECT_URI,
code_verifier: started.verifier,
resource: API,
});
// 7. Who connected, what they granted, and the work itself.
const me = await api("/me");
const { data: newsletters } = await api("/newsletters");
const lines = [];
for (const newsletter of newsletters) {
const { data: articles } = await api(
`/newsletters/${encodeURIComponent(newsletter.handle)}/articles?limit=5`,
);
lines.push(`${newsletter.name}`, ...articles.map((article) => ` ${article.title}`));
}
const name = me.display_name || me.username || me.id;
return reply(200, `Connected as ${name}, granted ${tokens.scope}.\n\n${lines.join("\n")}\n`);
}
// 8. Your side of letting go: revoking the refresh token ends the pair.
if (url.pathname === "/disconnect" && tokens) {
await fetch(server.revocation_endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token: tokens.refresh, client_id: clientId }),
});
tokens = null;
return reply(200, "Disconnected.");
}
return reply(404, "Try /connect.");
} catch (err) {
return reply(500, err.message);
}
}).listen(PORT, () => console.log(`Open http://localhost:${PORT}/connect`));Next use cases
See all- AgentAPI
Win back readers before they leave
See who is going quiet while there is still time to reach them.
Read the use case - AgentAPI
Plan your next issue from what readers said
Turn comments, replies and highlights into your next topic.
Read the use case - AgentAPI
Find and reward your superfans
Know your most engaged readers, and give them something back.
Read the use case