Add social features and settings
Requires: A secret key with
MEMBER_AUTH, and the member's session.
This guide adds the social layer and the remaining account screens of the community app to your own site: the notification bell, friend requests, full member profiles, the referral tracker, and the settings for emails, phone, connected accounts and seller VAT.
It builds on Embed in your own code and uses
cobuntuForRequest() from that guide. Every route here needs a secret key
with MEMBER_AUTH and the member's session, so all calls run on your server.
The browser talks to your own routes.
| Answer | Means |
|---|---|
401 INVALID_SESSION | No session, or an expired one. Send them to sign in. |
403 NOT_MEMBER | Signed in, but not an accepted member of this community. |
403 SECRET_KEY_REQUIRED | A publishable key. These routes are server only. |
429 RATE_LIMITED | Too many writes. Back off. |
The member is identified only by the session. No route takes a user id.
Notifications
The list shows rows that belong to this community, plus friendship rows (friendships are platform-wide, so they count everywhere). Marking rows read stays inside that scope: "mark all read" on your site never clears the member's notifications for other communities.
const me = cobuntuForRequest().me;
const count = await me.notifications.unreadCount(); // number
const { items, nextCursor } = await me.notifications.list({ limit: 20 });
const unread = await me.notifications.list({ unread: true });
await me.notifications.markRead({ ids: [items[0].id] }); // or { all: true }
await me.notifications.muteThread(postId); // reactions and comments on that post stopEach row has a plain-text title and body, an actor, and a link that
points at an entity, not a URL. Map it to your own pages:
link.kind | Fields | Suggested page |
|---|---|---|
member | usertag | The member's profile |
post | id | The post |
feed_message | id, chapterId | The message in its feed |
community | none | Your home or membership page |
url | url | Open as is (an installed app chose it) |
link is null when there is nothing to open. data carries the raw
payload if you want to write your own copy per type.
Polling the count
Poll the cheap count, then fetch the list only when it changes. With
@cobuntu/embed-react, useUnreadCount does this, pauses while the tab is
hidden and refreshes when the tab regains focus:
import { useUnreadCount } from "@cobuntu/embed-react";
function Bell() {
// /api/notifications/unread-count is your route; it calls me.notifications.unreadCount()
const { count, refresh } = useUnreadCount(
() => fetch("/api/notifications/unread-count").then((r) => r.json()).then((j) => j.count),
{ intervalMs: 45_000 },
);
return <button onClick={refresh}>Notifications {count ? `(${count})` : ""}</button>;
}Friends and member profiles
Friendships are platform-wide, so lists are not narrowed to this community.
Sending a request is: the target must be an accepted member here, otherwise
404 MEMBER_NOT_FOUND.
const me = cobuntuForRequest().me;
const incoming = await me.friends.requests.list(); // or list("outgoing")
const status = await me.friends.requests.send("sam"); // "pending" | "accepted"
await me.friends.requests.accept(requestId); // receiver only
await me.friends.requests.decline(requestId); // receiver only
await me.friends.requests.cancel(requestId); // your outgoing request
await me.friends.remove("sam"); // unfriend, either sidesend answers "accepted" straight away when the other member does not
require approval. 409 ALREADY_EXISTS means a friendship or a pending request
exists in either direction: if they already asked you, accept theirs.
A member's profile page
me.member(usertag) returns everything the community app's profile page
shows:
const m = await me.member("sam");
m.friendship; // "none" | "friends" | "outgoing" | "incoming" | "self"
m.friendRequestId; // set while pending: cancel it (outgoing) or accept/decline it (incoming)
m.friendsCount; m.mutualFriendsCount; m.signature; m.gender;
m.contact; // what the viewer may see, or null on your own profile
m.notes; // [] when the viewer is not in the member's circle
const events = await me.member.events("sam"); // their live events here
const friends = await me.member.friends("sam", { limit: 24 });Pick the button from friendship: Add friend (none), Cancel request
(outgoing), Accept / Decline (incoming), Unfriend (friends), or
nothing (self).
In contact, each field follows the member's own visibility setting. A
missing key means hidden, not empty, so render only the keys that are there.
Referrals
Members can earn a commission when people they refer join a paid tier. Two calls feed the tracker page:
const me = cobuntuForRequest().me;
const r = await me.referrals.get();
// { programEnabled, payoutsConnected, link: { code, status, suggestedPath } | null, links, joins, counts }
const e = await me.referrals.earnings();
// { totals: [{ currency, earnedCents, pendingCents, nextEligibleAt, payments }], payments }linkis a code. Build the full URL on your own domain, for example`${origin}/?ref=${r.link.code}`(suggestedPathis that path).- Referred people are never identified.
joinsandcountsare anonymous. - Amounts are in cents.
earnedCentsis paid out;pendingCentsis waiting to become payable.
Payouts come first. A link stays PENDING_STRIPE and attributes nobody
until the member connects payouts. While payoutsConnected is false, show a
prompt that sends them to me.stripe()'s connectUrl, and say clearly that
the link starts counting only after that.
Capturing ?ref= and passing referralCode
When someone lands on any page with ?ref=<code>, keep the code (a cookie for
a few weeks works well), then send it as referralCode on whichever call
makes them a member:
// middleware or a layout: remember the code
const ref = request.nextUrl.searchParams.get("ref");
if (ref) response.cookies.set("ref", ref, { maxAge: 60 * 60 * 24 * 30, sameSite: "lax" });
// then, on your sign-up / sign-in / apply routes
const referralCode = cookies().get("ref")?.value;
await cobuntu.auth.signUp({ name, email, password, referralCode });
await cobuntu.auth.signIn({ email, password, referralCode }); // existing account joining an open community
await cobuntu.as(token).me.segments.apply(segmentId, { answers, referralCode });Attribution is best effort and never fails the call. It does nothing when the code is unknown, not active yet, belongs to another community, is the person's own, or when the person is already a member. Continue with Google and email-link sign-in do not take a code yet.
Account settings: emails, phone, connected accounts, seller VAT
Emails
const me = cobuntuForRequest().me;
const emails = await me.emails.list(); // primary first
const added = await me.emails.add("alex@example.com"); // stored unverified
await me.emails.resendVerification(added.id);
await me.emails.makePrimary(added.id); // 409 EMAIL_NOT_VERIFIED until verified
await me.emails.remove(added.id); // 409 REMOVE_PRIMARY for the primaryAdding an address emails a verification link that points at your site:
https://<your site>/verify-email?token=.... It is the same page that sign-up
verification uses:
// app/verify-email/page.tsx (server)
const token = searchParams.token;
try {
await cobuntu.auth.verifyEmail(token); // { verified: true }
// show "Email verified"; me.emails.list() now shows it verified
} catch (e) {
// LINK_EXPIRED / INVALID_LINK: offer resendVerification
// EMAIL_TAKEN: someone else verified this address first
}The link uses the community's configured site address, so set that up before going live. Adding and resending are capped at 10 per 15 minutes per member.
Phone
await me.phone.sendOtp("+351912345678"); // E.164, sends one SMS
await me.phone.verify({ phoneNumber: "+351912345678", code }); // replaces any previous phonesend-otp is capped at 5 per 15 minutes per member. INVALID_CODE covers a
wrong or expired code. There is no remove-phone call.
Connected accounts and Connect Google
const { accounts, google } = await me.connectedAccounts.list();
await me.connectedAccounts.google.unlink(); // 409 LAST_CREDENTIAL if it is the only way to sign inConnecting Google is a PKCE flow with a callback on your server, like Continue with Google, but bound to the signed-in member:
import { createPkcePair } from "@cobuntu/embed";
// POST /api/account/google/link (member signed in)
const { codeVerifier, codeChallenge } = await createPkcePair();
const state = (await createPkcePair()).codeVerifier;
// set-cookie: google_link=<state>.<codeVerifier>; HttpOnly; Secure; SameSite=Lax; Max-Age=600
const authUrl = await cobuntuForRequest().me.connectedAccounts.google.link({
returnUrl: `${origin}/api/account/google/callback`,
codeChallenge,
state,
});
return redirect(authUrl);
// GET /api/account/google/callback?code=&state= (or ?error=)
// 1. compare `state` with the cookie, then clear the cookie
// 2. ?error=IDENTITY_TAKEN: that Google account belongs to another account
const g = await cobuntuForRequest().me.connectedAccounts.google.confirm({ code, codeVerifier });
// { linked: true, email }, then back to your settings pageconfirmmust run with the same session that calledlink. A code finished by anyone else is refused, so a link started by one person can never attach another person's Google account.- The code lives 60 seconds and works once. Every code failure is
400 INVALID_CODE. returnUrlmust be on the community's site (orlocalhostwhile developing), else400 RETURN_URL_NOT_ALLOWED.
Seller VAT
const vat = await me.sellerVat.get(); // { vatRegistered, vatCountry, vatNumber }
await me.sellerVat.update({ vatRegistered: true, vatCountry: "PT", vatNumber: "PT 123 456 789" });
await me.sellerVat.update({ vatRegistered: false }); // clears country and numberThe country is an ISO-2 code and is uppercased; the number has spaces removed
and is uppercased. A country without a number is 400 VAT_NUMBER_REQUIRED;
a bad country is 400 INVALID_VAT_COUNTRY.