Embed in your own code

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 session

1. 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;
}
  • facets count the whole visible roster, so a select keeps its options as the member narrows. tier takes a tier id from facets.tiers.
  • Page with nextCursor: pass it back as cursor; it is null on the last page.
  • One profile: member.me.member(usertag) returns the directory fields plus hostedEvents (upcoming public events they host). 404 MEMBER_NOT_FOUND when 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, Secure cookie and is never sent to the browser in a response body.
  • Every return URL is https on your own origin (returnUrl() builds them).
  • You branch on CobuntuError.code, not on the message.
  • Sign-up passes endUserIp.