Offer member prices with Join to Save
Requires: A secret key with
READ_PUBLIC,WRITE_SALESandMEMBER_AUTH; the member's session once they join.
Some ticket and product tiers have a lower price for members. This page starts with free tiers; paid memberships follow the same idea with one extra trip through your apply or plans page.
For a free tier: When a visitor who is not a member taps Buy (or Add to basket) on one of them, invite them to join first. They join for free, and the purchase continues at the member price. They can also continue at the regular price, or close the invitation and buy nothing.
It builds on Embed in your own code: the browser
talks to your server, your server talks to Cobuntu with a secret key that has
MEMBER_AUTH, and the member's session travels in X-Cobuntu-Auth-Token
(client.as(session)). The UI is useJoinToSave and JoinToSaveModal from
@cobuntu/embed-react.
Buy tapped
└─ events.memberPricingPreview(tierId) no offer ──▶ buy as usual
└─ offer (bestOffer.joinable)
├─ Continue ──▶ buy at the regular price
├─ Close ──▶ nothing
└─ Join
├─ signed out ──▶ sign in, come back with ?jts=<tierId>
└─ segments.form(segmentId)
├─ NATIVE ──▶ show the form ──▶ me.segments.apply({ answers })
├─ NONE ──▶ me.segments.apply()
└─ TALLY / JOTFORM ──▶ me.segments.apply() ──▶ onboarding form, stop
JOINED ──▶ buy (the member price resolves at checkout)1. Ask for the preview
GET /tiers/:tierId/member-pricing/preview works with or without a session.
Send the session when there is one, so a member is recognised.
// app/api/member-pricing/route.ts
export async function GET(req: Request) {
const tierId = new URL(req.url).searchParams.get("tierId")!;
const preview = await cobuntuForRequest().events.memberPricingPreview(tierId);
return Response.json(preview);
}Show the offer only when all of these hold. joinToSaveOffer(preview) checks them for you:
buyerOverrideis null. Otherwise the visitor is already a member and the member price applies on its own.viewerHasRoleAccessis false. Staff never see the invitation.bestOffer.joinableis true andbestOffer.segmentIdis set.
2. Sign in when needed
A signed-out visitor who taps Join goes to your sign-in page with a return
address that resumes the flow. useJoinToSave builds it for you: the current
URL plus ?jts=<tierId>. After sign-in, pass resume: true when the key matches
the tier and the hook continues the join without another tap.
3. Read the tier's form
GET /segments/:segmentId/form (publishable or secret key) returns formSource:
formSource | What to do |
|---|---|
NATIVE with fields | Show the fields inline, then apply with answers. |
NONE | Apply right away. |
TALLY or JOTFORM | Apply, then show the onboarding form from me.onboarding.get() (form.embedFormUrl). Stop there. The member price applies once that form is done, so the visitor taps Buy again. |
4. Join
// app/api/join/route.ts
import { isCobuntuError } from "@cobuntu/embed";
export async function POST(req: Request) {
const { segmentId, answers } = await req.json();
const member = cobuntuForRequest(); // must be signed in
try {
const r = await member.me.segments.apply(segmentId, {
answers,
source: "event_join_upsell",
referralCode: readRefCookie(req) ?? undefined,
});
if (r.status !== "JOINED") return Response.json({ error: "NOT_JOINED" }, { status: 409 });
return Response.json(r);
} catch (e) {
if (isCobuntuError(e)) return Response.json({ error: e.code }, { status: e.status });
throw e;
}
}apply is idempotent for a free tier, so a member who applies again also gets
JOINED. A paid tier or a community that is not open never joins instantly: it
answers PENDING or PAYMENT_REQUIRED, which the preview already excludes by
setting joinable to false.
5. Continue the purchase
Run the original purchase with the same session. Checkout resolves the buyer as a member and applies the member price on the server. Nothing in the request names the price.
Copy
Present the member price as something that comes with belonging, not as a discount:
- Lead with the community: its logo and
Join {name}. - Show the member price once, in a box with "Membership is free. No card needed, leave any time."
- Show the regular price once, on the quiet Continue button.
- No struck-through prices, no "save X", no labels for people who do not join.
The default labels in JoinToSaveModal follow these rules. Override them with
labels to translate or to match your voice.
Paid memberships
When the member price comes with a paid tier, bestOffer.joinMode is
"paid" (and joinable stays false, so a site built for free offers never shows
it). The offer carries:
segmentId: the tier to join.membership:{ currency, prices: [{ billingCycle, amount, installment }], trial }. Amounts in cents;amountis null for an installment-only cadence.joinPath: which call fits this viewer. A signed-out viewer always getsapply; read the preview again after sign-in.
The modal takes no payment. Its Join button links to one of your pages, with
?jts=<tierId>&returnTo=<item path>?jts=<tierId>:
joinPath | Your page | Calls |
|---|---|---|
apply | apply page | Sign in, then me.segments.apply(segmentId, { source: "event_join_upsell" }) answers PAYMENT_REQUIRED; then me.applications.checkout(requestId, { successUrl, cancelUrl }). |
checkout | apply page | me.membership.checkout({ segmentId, billingCycle, successUrl, cancelUrl }). |
changeTier | plans page | me.membership.changeTier({ segmentId, billingCycle, successUrl, cancelUrl }). |
Use the sanitised returnTo as the successUrl (as a full https URL on your
site; query strings are fine). readJoinToSavePaidResume(search) reads jts and
returnTo on your page and drops any returnTo that is not a same-origin path.
// app/api/membership/change-tier/route.ts
const r = await cobuntuForRequest().me.membership.changeTier({
segmentId, billingCycle: "MONTHLY",
successUrl: `https://example.com${returnTo}`,
cancelUrl: "https://example.com/plans",
});
if (r.status === "CHECKOUT_REQUIRED") return Response.json({ redirect: r.checkoutUrl });
return Response.json({ redirect: returnTo }); // CHANGED or SCHEDULED: no payment stepchangeTier answers CHECKOUT_REQUIRED (free to paid, or a cycle or currency
change), CHANGED (prorated, access now) or SCHEDULED (a lower tier, from the
next renewal). In the first case the current membership stays active until the
new payment succeeds, and is only then replaced. If the person abandons the
checkout, nothing changes, so there is no need to warn them before they switch.
Errors: 400 INVALID_INPUT, INVALID_RETURN_URL,
CYCLE_NOT_OFFERED; 401 INVALID_SESSION; 403 BANNED; 404 SEGMENT_NOT_FOUND;
409 NO_LIVE_MEMBERSHIP (use checkout), ALREADY_ON_TIER, COMMUNITY_NOT_LIVE.
Back on the item
The payment confirmation can land a few seconds after the person returns. The
item checkout prices the order when it starts, so wait first: read the preview
with the session every 2 seconds until buyerOverride is set, then start the
purchase. Give up after a minute with "Your membership is still being
confirmed. Refresh in a minute." and keep the regular-price option.
waitForMemberPrice(loadPreview) does this, and useJoinToSave runs it for you
when you pass resume and loadPreview. Do not poll me.membership.get() for
this: it answers for the subscription, not for the member price.
Copy for a paid offer
- Title
Join {name}, subtitleBecome a member of {name} and enjoy member prices, starting with this one. As a member, this is {member price}, thenMembership is €9/month or €90/year(the monthly price, plus yearly when offered).- Join button
Join {name}; Continue buttonContinue at {regular price}.
Products
The preview takes a product variant tier id as well as an event tier id, so the same flow works in a shop. Run the product checkout with the same session after the join.
Errors
| Answer | Means |
|---|---|
401 INVALID_SESSION | No live session. Send them to sign in. |
403 SECRET_KEY_REQUIRED / MISSING_SCOPE | The key is publishable or lacks MEMBER_AUTH. |
400 INVALID_INPUT | A required form field is missing, or an unknown source. Keep the form open. |
400 NOT_OPEN, 403 INVITE_ONLY, 403 BANNED | The visitor cannot join. Let them continue at the regular price. |
409 APPLICATION_PENDING | They already applied. Let them continue at the regular price. |