Guides
Members
Add social features and settings

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.

AnswerMeans
401 INVALID_SESSIONNo session, or an expired one. Send them to sign in.
403 NOT_MEMBERSigned in, but not an accepted member of this community.
403 SECRET_KEY_REQUIREDA publishable key. These routes are server only.
429 RATE_LIMITEDToo 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 stop

Each 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.kindFieldsSuggested page
memberusertagThe member's profile
postidThe post
feed_messageid, chapterIdThe message in its feed
communitynoneYour home or membership page
urlurlOpen 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 side

send 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 }
  • link is a code. Build the full URL on your own domain, for example `${origin}/?ref=${r.link.code}` (suggestedPath is that path).
  • Referred people are never identified. joins and counts are anonymous.
  • Amounts are in cents. earnedCents is paid out; pendingCents is 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 primary

Adding 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 phone

send-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 in

Connecting 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 page
  • confirm must run with the same session that called link. 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.
  • returnUrl must be on the community's site (or localhost while developing), else 400 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 number

The 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.