Guides
Members
Build the account area

Build the account area

Requires: A secret key with MEMBER_AUTH, and the member's session.

This guide builds the member's account pages on your own domain: My events, a Ledger of money in and out, and a Library of what they own. They are the same pages a member sees in the community app, scoped to your community.

It builds on Embed in your own code: the browser talks to your server, your server talks to Cobuntu, and the key never leaves the server. It uses cobuntuForRequest() from that guide (the client, with the member's session when there is one).

events    me.events                               ──▶ MyEvents
ledger    me.ledger, me.invoice, me.receipt       ──▶ Ledger
library   me.library, me.purchaseDownload         ──▶ Library
settings  me.profile, me.preferences, me.password,
          me.account, me.friends, me.notes

Key and session

All three routes need a secret key with MEMBER_AUTH and the member's session in X-Cobuntu-Auth-Token (the client sends it for you on client.as(session)).

AnswerMeans
401 INVALID_SESSIONNo session, an expired one, or the user is not an accepted member. Send them to sign in.
403 SECRET_KEY_REQUIREDA publishable key. These routes are server only.
403 MISSING_SCOPEThe key lacks MEMBER_AUTH.
400 INVALID_INPUTA bad when, role, type or date.

The member is identified only by the session. No route takes a user id, so one member can never read another's account.

All amounts are integers in the smallest currency unit (cents for EUR).

1. My events

// app/account/events/page.tsx (server component)
import { MyEvents } from "@cobuntu/embed-react";
import { cobuntuForRequest } from "@/lib/session";
 
export default async function MyEventsPage() {
  const events = await cobuntuForRequest().me.events();
  return (
    <MyEvents
      events={events}
      eventHref={(e) => `/events/${e.slug ?? e.id}`}
      receiptHref={(e) => `/account/ledger?txn=${e.transactionId}`}
      browseHref="/events"
    />
  );
}

me.events() answers four groups:

GroupHolds
attendingUpcoming events where the member has a place (approved or awaiting approval).
attendedThe same, in the past.
hostingUpcoming events the member hosts.
hostedThe same, in the past.
  • Only events of your community appear, even for a member of several.
  • Declined and cancelled places are left out.
  • An event both hosted and attended is in both groups. MyEvents shows it once.
  • Past means the end date (else the start date) is before now. An event with no date counts as upcoming.
  • Upcoming groups are soonest first, past groups most recent first.
  • when (upcoming | past) and role (attending | hosting) narrow the answer; an excluded group comes back as [].
  • transactionId is the latest purchase for the event, for a receipt link. Free places have none.

For per-place detail (companions, whether a ticket can be refunded) use me.tickets.

2. Ledger

The ledger lists, newest first, everything that moved money for the member in your community:

typedirectionamount
PURCHASEoutWhat they paid (gross).
REFUNDinWhat came back to them.
SALEinWhat they earned as a seller, after fees.

A sale also carries fees (gross, platform fee, card processing, tax fee), payout (status, the date it becomes payable, the date it was paid out) and the buyer as counterpart. A refunded sale keeps its entry, with status and refund.

summary.totals has one row per currency over the filtered entries: spent, refunded, earned and fees. Amounts in different currencies are never added together. pendingPayouts, readyForPayout and inEscrowPeriod are the member's payout state in your community, before filters.

Payouts to a bank account are not listed: one payout can cover sales in several communities. Each sale shows its own payout state instead.

The page

The filters live in the browser, so the ledger is a client component that asks your server for each page.

// app/api/ledger/route.ts
import { cobuntuForRequest } from "@/lib/session";
 
export async function GET(req: Request) {
  const p = new URL(req.url).searchParams;
  const ledger = await cobuntuForRequest().me.ledger({
    type: (p.get("type") as "purchase" | "refund" | "sale" | null) ?? undefined,
    from: p.get("from") ?? undefined,
    limit: 20,
    offset: Number(p.get("offset") ?? 0),
  });
  return Response.json(ledger);
}
// app/account/ledger/LedgerPage.tsx
"use client";
import { useEffect, useState } from "react";
import { Ledger, EMPTY_LEDGER_FILTERS, toLedgerQuery, type CbLedger } from "@cobuntu/embed-react";
 
export function LedgerPage({ initial }: { initial: CbLedger }) {
  const [ledger, setLedger] = useState(initial);
  const [filters, setFilters] = useState(EMPTY_LEDGER_FILTERS);
  const [offset, setOffset] = useState(0);
  const [loading, setLoading] = useState(false);
 
  useEffect(() => {
    const q = new URLSearchParams(Object.entries(toLedgerQuery(filters, { offset })).map(([k, v]) => [k, String(v)]));
    setLoading(true);
    fetch(`/api/ledger?${q}`).then((r) => r.json()).then(setLedger).finally(() => setLoading(false));
  }, [filters, offset]);
 
  return (
    <Ledger
      ledger={ledger}
      loading={loading}
      filters={filters}
      onFiltersChange={(f) => { setFilters(f); setOffset(0); }}
      offset={offset}
      onOffsetChange={setOffset}
      invoiceHref={(e) => `/api/invoice/${e.saleId}`}
      receiptHref={(e) => `/api/receipt/${e.saleId}`}
    />
  );
}

Invoices and receipts

invoiceUrl is set on the member's own purchases and receiptUrl on purchases and sales. Both are API paths that need the secret key, so point the links at small routes on your server:

// app/api/invoice/[saleId]/route.ts
export async function GET(_: Request, { params }: { params: { saleId: string } }) {
  const url = await cobuntuForRequest().me.invoice(params.saleId);
  return Response.redirect(url, 302);
}
 
// app/api/receipt/[saleId]/route.ts
export async function GET(_: Request, { params }: { params: { saleId: string } }) {
  const pdf = await cobuntuForRequest().me.receipt(params.saleId);
  return new Response(pdf, { headers: { "Content-Type": "application/pdf" } });
}

An invoice is issued shortly after payment. Until then me.invoice answers 404 INVOICE_NOT_AVAILABLE; invoiceAvailable on the entry tells you whether it exists yet.

3. Library

// app/account/library/page.tsx (server component)
import { cobuntuForRequest } from "@/lib/session";
import { LibraryGrid } from "./LibraryGrid";
 
export default async function LibraryPage() {
  const { items } = await cobuntuForRequest().me.library();
  return <LibraryGrid items={items} />;
}
// app/account/library/LibraryGrid.tsx
"use client";
import { Library, type CbLibraryItem } from "@cobuntu/embed-react";
 
export function LibraryGrid({ items }: { items: CbLibraryItem[] }) {
  return (
    <Library
      items={items}
      itemHref={(i) => (i.product.sku ? `/shop/${i.product.sku}` : null)}
      onDownload={async (i, d) => {
        const { url } = await fetch(`/api/download/${i.saleId}/${d.attachmentId}`).then((r) => r.json());
        window.location.href = url;
      }}
      browseHref="/shop"
    />
  );
}
// app/api/download/[saleId]/[attachmentId]/route.ts
export async function GET(_: Request, { params }: { params: { saleId: string; attachmentId: string } }) {
  const link = await cobuntuForRequest().me.purchaseDownload(params.saleId, params.attachmentId);
  return Response.json(link);
}
  • The library holds what the member owns in your community. Refunded purchases are not in it.
  • downloads lists each deliverable. A LINK opens its url directly. A FILE goes through me.purchaseDownload, which returns a signed URL valid for about five minutes and counts one download against any download limit.
  • awaitingApproval: true marks a purchase the seller has not approved yet. Its downloads is empty until they do, and Library shows it dimmed.

4. Profile and settings

The me namespace covers the account screens: profile, avatar, preferences, password, data export, account deletion, friends and notes. Every call needs a member session (client.as(token)) and a secret key with MEMBER_AUTH.

const me = cobuntuForRequest().me;
 
// Profile, including handle, socials, birthday and contact visibility
const profile = await me.profile.get();
const { available, alternatives } = await me.profile.usertagAvailable("alex");
await me.profile.update({
  usertag: "alex",
  pronouns: "they/them",
  socials: [{ label: "Site", url: "https://example.com" }],
  birthday: { month: 4, day: 12 },
  emailVisibility: "CIRCLE",
});
 
// Avatar: a png, jpeg or webp data URL, up to 5 MB
const avatarUrl = await me.profile.avatar.upload(dataUrl);
 
// Preferences: notifications are this community's email settings
await me.preferences.update({ timeFormat: "H24", notifications: { postsEmail: false } });
  • profile.bioMaxLength is the bio limit for the account; use it for your counter. phoneVisibility: "PUBLIC" is saved as CIRCLE.
  • A taken usertag on update is 409 USERTAG_TAKEN with alternatives. Call usertagAvailable as the member types to show checked suggestions instead.

Password, export and deletion

const { token } = await me.password.change({ currentPassword, newPassword });
// Every session is now signed out. Save `token` as this device's session.
setSessionCookie(token);
 
const data = await me.export(); // JSON, offer it as a download
 
await me.account.delete({ password });
  • password.change errors: BAD_PASSWORD (wrong current password), NO_PASSWORD (the account signs in without one; send the member to password reset), WEAK_PASSWORD, SAME_PASSWORD.
  • account.delete erases the whole account, not only the membership. Accounts without a password are deleted on the session alone, so ask for a typed confirmation before calling it.

Friends and notes

const { items, nextCursor } = await me.friends.list({ limit: 20 });
const notes = await me.notes.list();
const note = await me.notes.create({ content: "Looking for a cofounder", category: "LOOKING_FOR" });
await me.notes.delete(note.id);
  • Notes belong to the account, so the same set shows in every community. Free accounts hold up to two.

Labels and styling

The components render no CSS and fetch nothing. Every string has an English default you can replace through labels, and every element has a cb- class for your stylesheet. See the React page for the props and the class map.