Atune Docs
SDK reference

Billing status

Tell Atune who pays, so the advice about paying customers turns on. Mappers for Stripe and Clerk Billing, with no new keys and no extra calls.

Only your app knows who pays. Until it tells Atune, every account is an assumed trial, and the advice that only makes sense for paying customers stays off:

AdviceNeeds
Churn riskstage paying
Ready for upsellstage paying and seats
Account gone quietstage paying
Champion coolingstage paying
Feature adoption gapstage paying
Account contractedhistory of stage, plan, seats or mrr
Trial expiringtrialEndsAt
Payment failingstage past_due

Send the facts with the account you already pass to the SDK. Atune never calls Stripe or Clerk for you, and only these fields are sent: stage, plan, seats, mrr, currency, trialEndsAt and, for a cancellation, cancelReason. Never card, invoice, address or Stripe ids.

Stripe: toAtuneSubscription()

Where your code already holds the customer's Stripe subscription (for example the copy your webhook handler saves in your database), map it:

import { createAtuneMiddleware, toAtuneSubscription } from "atune";

const atune = createAtuneMiddleware({
  getAccount: async (request) => {
    const session = await getYourSession(request);
    if (!session) return null;
    const subscription = await db.subscription.findUnique({ where: { orgId: session.orgId } });
    return { accountId: session.orgId, userId: session.userId, subscription: toAtuneSubscription(subscription?.stripeObject) };
  },
});

Don't fetch the subscription from Stripe on every request. Read your own copy.

StripeAtune
status: "trialing"stage trial, with trial_end as trialEndsAt
status: "active"stage paying
status: "past_due" or "unpaid"stage past_due
status: "canceled"stage canceled, with cancellation_details.feedback mapped to the reason list
incomplete, incomplete_expired, paused, anything elseno stage: Atune keeps what it knows
quantity of per-seat prices only (see below)seats
unit_amount × quantity, normalised to a monthmrr and currency (paying and past-due only; discounts are not applied)
first price's nickname, then lookup_keyplan (or pass { plan } yourself)

Seats

Seats come only from prices you charge per seat. A flat-rate plan is bought in quantity 1, and that 1 is not a seat count, so a flat-rate or metered price sends no seats. With seats unknown, Atune never suggests an upsell.

A price counts as per-seat when:

  • you list it: toAtuneSubscription(sub, { seatPrices: ["price_123", "team_seat_monthly"] }) (price id or lookup_key; then only these count), or
  • its Stripe metadata has atune_seats: "true", or
  • it is a licensed (not metered) price bought in a quantity above 1.

If your app knows the seat count better, pass it: toAtuneSubscription(sub, { seats: org.seatLimit }). That value wins. { seats: null } means unknown and sends no seats.

Clerk Billing: clerkAccount(auth, { billing: true })

With Clerk Billing, the active plan is already in the session your proxy verifies (the pla claim). Ask clerkAccount to send it:

export default clerkMiddleware(async (auth, request) => {
  return atune(request, NextResponse.next(), { account: clerkAccount(auth, { billing: true }) });
});

A paid plan is reported as paying. The plans free, free_user and free_org are reported as not paying (trial); pass { billing: { freePlans: ["hobby"] } } for your own free plan slugs.

Your own plan column

Pass the facts directly:

return { accountId: org.id, userId: user.id, subscription: { stage: org.paying ? "paying" : "trial", plan: org.plan, seats: org.seats } };

The same fields work on track() and the HTTP API. npx atune init prints the right snippet for your app but never edits code for this step.

On this page