Embed in your own code
This is how a venue site runs its event pages, member sign-in and memberships on its own domain, with Cobuntu behind it. The rule is simple: the browser talks to your server, and your server talks to Cobuntu. The API key and the member's Cobuntu session never reach the browser.
browser ──▶ your route handlers (/api/book, /api/login, ...) ──▶ @cobuntu/embed ──▶ api.cobuntu.com
◀── httpOnly cookie holds the member session1. One client, server side
// lib/cobuntu.ts
import "server-only"; // Next.js: fail the build if this is imported client-side
import { createCobuntu } from "@cobuntu/embed";
export const cobuntu = createCobuntu({
apiKey: process.env.COBUNTU_API_KEY!, // READ_PUBLIC + WRITE_SALES + MEMBER_AUTH
community: process.env.COBUNTU_COMMUNITY_TAG ?? "your-community",
});
export const SITE = process.env.SITE_ORIGIN ?? "https://example.com";Create the key in the community admin (Integrations → API keys) as a
secret key. Never put it in a NEXT_PUBLIC_ variable.
2. The session cookie
// lib/session.ts
import { cookies } from "next/headers";
import { cobuntu } from "./cobuntu";
const COOKIE = "cobuntu_session";
export function setSession(token: string) {
cookies().set(COOKIE, token, {
httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 60 * 60 * 24 * 30,
});
}
export function clearSession() {
cookies().delete(COOKIE);
}
/** The client for this request: acting for the member if signed in. */
export function cobuntuForRequest() {
const token = cookies().get(COOKIE)?.value;
return token ? cobuntu.as(token) : cobuntu;
}3. Sign in
// app/api/login/route.ts
import { CobuntuError } from "@cobuntu/embed";
import { cobuntu } from "@/lib/cobuntu";
import { setSession } from "@/lib/session";
export async function POST(req: Request) {
const { email, password } = await req.json();
try {
const { token, user } = await cobuntu.auth.signIn({ email, password });
setSession(token);
return Response.json({ user }); // never the token
} catch (e) {
if (e instanceof CobuntuError) {
return Response.json({ code: e.code }, { status: e.status });
}
throw e;
}
}Sign-up is the same with cobuntu.auth.signUp(...). Pass the visitor's IP so
the per-person limit applies to the visitor, not to your whole server:
const ip = (req.headers.get("x-forwarded-for") ?? "").split(",")[0].trim();
await cobuntu.auth.signUp({ name, email, password }, { endUserIp: ip });Passwordless: auth.emailLink({ email, redirectPath: "/events" }) emails a
link to <your site>/login/verify?token=.... That page's server calls
auth.verifyEmailLink(token) and sets the cookie.
4. The event page
// app/events/[slug]/page.tsx (a server component)
import { formatMoney, isOnSale, CobuntuError } from "@cobuntu/embed";
import { cobuntuForRequest } from "@/lib/session";
export default async function EventPage({ params }: { params: { slug: string } }) {
try {
const event = await cobuntuForRequest().events.get(params.slug);
return (
<>
<h1>{event.name}</h1>
{event.tiers.map((t) => (
<p key={t.id}>
{t.name}: {formatMoney(t.memberPrice ?? t.price, t.currency)}
{!isOnSale(t) && " (not on sale)"}
</p>
))}
</>
);
} catch (e) {
if (e instanceof CobuntuError && e.code === "MEMBERS_ONLY") return <p>Members only. Sign in to see it.</p>;
throw e;
}
}Because the request carries the member's session, memberPrice is filled in
and isAttending is true for someone who already has a place.
For the one-line price on a card or next to the date ("€20", "From €15",
"Free", "Sold out", plus a lower member price), use eventPrice from
@cobuntu/embed-react/events. It is server-safe and applies the same rules as
Cobuntu's community app. See Event price.
import { eventPrice } from "@cobuntu/embed-react/events";
const p = eventPrice(event, { locale: "en-IE" });
// p.label "From €15.00", p.kind "from", p.memberLabel "€12.00 for members"5. Booking
The booking sheet posts to your route; your route picks RSVP or checkout:
// app/api/book/route.ts
import { returnUrl } from "@cobuntu/embed";
import { SITE } from "@/lib/cobuntu";
import { cobuntuForRequest } from "@/lib/session";
export async function POST(req: Request) {
const { slug, tierId, email, formAnswers } = await req.json();
const client = cobuntuForRequest();
const tier = (await client.events.tiers(slug)).find((t) => t.id === tierId);
if (!tier) return Response.json({ code: "TIER_NOT_FOUND" }, { status: 404 });
if (tier.price === 0) {
const r = await client.events.rsvp(slug, { email, tierId, formAnswers });
return Response.json(r);
}
const { checkoutUrl } = await client.events.checkout(slug, {
tierId,
formAnswers,
customer: { email },
successUrl: returnUrl(SITE, `/events/${slug}`, { session_id: "{CHECKOUT_SESSION_ID}" }),
cancelUrl: returnUrl(SITE, `/events/${slug}`),
});
return Response.json({ checkoutUrl });
}When Stripe sends the buyer back, the page reads session_id and asks
cobuntu.checkoutSessions.get(sessionId): paid means "you're in", pending
means the payment is still being confirmed.
6. The member's account page
const member = cobuntuForRequest(); // must be signed in
const [tickets, membership] = await Promise.all([
member.me.tickets(),
member.me.membership.get(),
]);Join a tier with me.membership.checkout({ segmentId, billingCycle, successUrl, cancelUrl }), cancel with me.membership.cancel(), and send them to Stripe's
portal with me.membership.billingPortal({ returnUrl }). A ticket with
refundable: true can be refunded with events.refund(slug, { saleId, reason });
a free place is cancelled with events.leave(slug).
7. Members directory
A directory of the community's members, for signed-in members only. A visitor
with no session gets 401 INVALID_SESSION; a signed-in person who is not an
accepted member gets 403 MEMBERS_ONLY. Show both a "for members" gate with
sign in and join links.
import { buildDirectoryQuery, isCobuntuError } from "@cobuntu/embed";
const member = cobuntuForRequest();
try {
const { members, facets, nextCursor } = await member.me.directory(
buildDirectoryQuery(searchParams), // q, tier, location, language, role, cursor
);
// render cards; build the filter selects from facets
} catch (e) {
if (isCobuntuError(e) && (e.code === "INVALID_SESSION" || e.code === "MEMBERS_ONLY")) {
// render the members gate
} else throw e;
}facetscount the whole visible roster, so a select keeps its options as the member narrows.tiertakes a tier id fromfacets.tiers.- Page with
nextCursor: pass it back ascursor; it isnullon the last page. - One profile:
member.me.member(usertag)returns the directory fields plushostedEvents(upcoming public events they host).404 MEMBER_NOT_FOUNDwhen the handle is not a member. - Privacy is the community's own: a location shows only when the member's
visibility allows it, and a member who hides from the directory is left out
of the list (their profile still opens by handle). Members change this with
me.profile.update({ locationVisibility, languages, hideFromDirectory }). matchesMemberSearch(member, q)runs the same search in the browser over a loaded page.
Checklist
- The key lives in a server env var, and the client is only imported from
server code (
import "server-only"). - The session token goes into an
httpOnly,Securecookie and is never sent to the browser in a response body. - Every return URL is
httpson your own origin (returnUrl()builds them). - You branch on
CobuntuError.code, not on the message. - Sign-up passes
endUserIp.