Charge per seat through your own Stripe. Your app reports how many seats are in use; Stripe bills the ones past what the plan includes. The code for every place the count changes.
A plan can charge per seat: free seats included, then a price for each one past
that. Set it on the plan, on a number feature, with Charge per seat. See
Plan features.
Soba doesn't count seats. Your app tells Soba the total, and Stripe bills it.
That means wiring a call into every place your seat count changes. There are
usually three or four of them, and this page has the code for each.
How it bills#
- You report the total in use, whenever it changes.
- Soba writes it to the customer's Stripe subscription, with no proration.
- Their next invoice charges each seat past the free ones. Nothing is charged
mid-month, and Soba never works out a charge itself; Stripe does.
- No refund mid-month either. Removing seats doesn't credit the current
month: the customer pays for the count they had at their last renewal, and the
lower count applies from the next invoice. Adding seats works the same way in
the other direction, so neither side is billed for part of a month.
The call sets the total, it never adds one. If a call is missed or sent twice,
the next one corrects it. A count that already matches writes nothing to Stripe,
so calling it on a schedule costs nothing.
Seats are per team#
Each customer in Soba is whoever holds the plan. For a seat plan that's a
team, an organization or a workspace, never one person: the team buys the plan,
and its members are the seats.
So use the team's id everywhere Soba asks for a user:
- the
customerId you pass to setQuantity
- the
user on every run, so the team's members share the team's run allowance
If runs are attributed to each person instead, every person gets a plan and an
allowance of their own, and there's nothing for a seat count to belong to. See
Identifying users for picking an id that lasts.
The helper#
Every call site below calls this one function. It counts from your own database
and sends the true total.
TypeScript
// lib/seats.ts
import { Soba } from "@soba-so/sdk"
const soba = new Soba() // reads SOBA_KEY
/**
* Tell Soba how many seats this customer uses NOW.
* Always the total, counted fresh: never `previous + 1`.
*/
export async function syncSeats(customerId: string): Promise<void> {
const count = await db.member.count({
where: { teamId: customerId, removedAt: null },
})
try {
await soba.users.setQuantity(customerId, "seats", count)
} catch (err) {
// Don't fail the user's action over billing. The daily resync below
// sends the right total tomorrow.
console.error("seat sync failed", { customerId, count, err })
}
}
customerId is the id you give Soba for whoever holds the plan, the same
one runs are attributed to. For team plans that's usually the team.
"seats" is the feature's key, as the plan shows it (features.seats).
- Count after your write has committed. Inside a transaction that hasn't
committed yet, you'd count the old number.
Every call site#
When a member joins#
TypeScript
export async function addMember(teamId: string, userId: string) {
await db.member.create({ data: { teamId, userId } })
await syncSeats(teamId)
}
Include every way someone gets a seat: accepting an invite, an admin adding them,
signing up through a team link, SSO provisioning.
When a member leaves#
This is the one people forget. Without it the count only ever goes up, and
your customer pays for people who left.
TypeScript
export async function removeMember(teamId: string, userId: string) {
await db.member.update({
where: { teamId_userId: { teamId, userId } },
data: { removedAt: new Date() },
})
await syncSeats(teamId)
}
Find every path that ends a seat, not only the Remove button:
- a member leaving the team themselves
- an admin removing them
- the member deleting their account
- SSO or SCIM deprovisioning
- a seat expiring or being deactivated
In bulk#
For an import, a CSV of invites, or a SCIM batch, sync once after the batch,
not once per row:
TypeScript
export async function importMembers(teamId: string, userIds: string[]) {
await db.member.createMany({
data: userIds.map((userId) => ({ teamId, userId })),
skipDuplicates: true,
})
await syncSeats(teamId)
}
When a whole team is deleted, sync it to 0 before the team goes, so its last
invoice doesn't charge for members who no longer exist:
TypeScript
export async function deleteTeam(teamId: string) {
await soba.users.setQuantity(teamId, "seats", 0)
await db.team.delete({ where: { id: teamId } })
}
Once a day, as a resync (recommended)#
A daily job sending every customer's true count repairs anything a missed call
left behind. A count that already matches writes nothing to Stripe, so this is
free.
TypeScript
// a daily cron job
export async function resyncAllSeats() {
const teams = await db.team.findMany({ select: { id: true } })
for (const { id } of teams) await syncSeats(id)
}
Without the SDK#
http
POST https://soba.so/v1/end_users/{customerId}/features/seats/quantity
Authorization: Bearer sk_soba_…
Content-Type: application/json
{ "quantity": 12 }
It answers 200 with what changed:
JSON
{ "feature": "seats", "quantity": 12, "previous": 9, "changed": true, "billing": "updated" }
|
|
changed: false |
The count already matched. Nothing was written |
billing: "no_subscription" |
Stored, but this customer's plan wasn't bought through Stripe, so there's nothing to bill |
400 |
Not a whole number 0 or more, or the feature has no price on their plan |
404 |
No such customer on a plan, or no such feature on their plan |
502 |
Stripe refused it. Nothing was recorded; send it again |
Reading it back#
The count you last reported comes back in plan status, beside the features:
JSON
{
"features": { "seats": null },
"quantities": { "seats": 12 }
}
features.seats is the plan's cap (null is unlimited). quantities.seats is
the count you reported.
When a customer queries a seat charge#
Open the plan's Analytics, then History beside the customer in Seat
reporting. It lists every count Soba wrote to Stripe for them, newest first:
from what to what, whether it came from your app or was set by hand, the
subscription it went to, and whether Stripe took it. Reports that matched the
count Stripe already had aren't listed.
If a count is wrong and you can't deploy a fix, Set a count by hand writes
the right total the same way your app would. Your app's next report replaces it.