Guides
Members
Offer member prices with Join to Save

Offer member prices with Join to Save

Requires: A secret key with READ_PUBLIC, WRITE_SALES and MEMBER_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:

  • buyerOverride is null. Otherwise the visitor is already a member and the member price applies on its own.
  • viewerHasRoleAccess is false. Staff never see the invitation.
  • bestOffer.joinable is true and bestOffer.segmentId is 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:

formSourceWhat to do
NATIVE with fieldsShow the fields inline, then apply with answers.
NONEApply right away.
TALLY or JOTFORMApply, 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; amount is null for an installment-only cadence.
  • joinPath: which call fits this viewer. A signed-out viewer always gets apply; 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>:

joinPathYour pageCalls
applyapply pageSign in, then me.segments.apply(segmentId, { source: "event_join_upsell" }) answers PAYMENT_REQUIRED; then me.applications.checkout(requestId, { successUrl, cancelUrl }).
checkoutapply pageme.membership.checkout({ segmentId, billingCycle, successUrl, cancelUrl }).
changeTierplans pageme.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 step

changeTier 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}, subtitle Become a member of {name} and enjoy member prices, starting with this one.
  • As a member, this is {member price}, then Membership is €9/month or €90/year (the monthly price, plus yearly when offered).
  • Join button Join {name}; Continue button Continue 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

AnswerMeans
401 INVALID_SESSIONNo live session. Send them to sign in.
403 SECRET_KEY_REQUIRED / MISSING_SCOPEThe key is publishable or lacks MEMBER_AUTH.
400 INVALID_INPUTA required form field is missing, or an unknown source. Keep the form open.
400 NOT_OPEN, 403 INVITE_ONLY, 403 BANNEDThe visitor cannot join. Let them continue at the regular price.
409 APPLICATION_PENDINGThey already applied. Let them continue at the regular price.