Use Limitry with Stripe
Stripe takes the payments and Limitry does the counting. This guide covers the three ways the two meet:
- A customer’s plan decides their limits.
- Customers buy credits with Stripe Checkout, and a refund takes them back.
- You bill for usage, and Stripe’s meters get each unit exactly once.
The examples use Node with the stripe package and the
Limitry SDK, and a team as the subject. Your webhook
handler checks Stripe’s signature first, as Stripe’s docs describe.
Before you start: two things every handler does
Section titled “Before you start: two things every handler does”Pick the environment from the event. Stripe’s test mode and live
mode match Limitry’s test and live. Keep a Limitry key for each, and
choose by the event’s livemode:
import { createClient } from "@limitry/sdk";
const limitry = { live: createClient({ token: process.env.LIMITRY_LIVE_KEY! }), test: createClient({ token: process.env.LIMITRY_TEST_KEY! }),};const apiFor = (event: Stripe.Event) => event.livemode ? limitry.live : limitry.test;import os
import stripefrom limitry import Limitry
limitry = { "live": Limitry(token=os.environ["LIMITRY_LIVE_KEY"]), "test": Limitry(token=os.environ["LIMITRY_TEST_KEY"]),}
def api_for(event) -> Limitry: return limitry["live"] if event.livemode else limitry["test"]Expect the same event twice, and events out of order. Stripe redelivers events for up to three days and doesn’t promise their order. Each section below says how its handler stays correct either way.
1. The plan decides the limits
Section titled “1. The plan decides the limits”Give each plan its own subject kind, such as team-free and team-pro,
with its own limits. A kind per plan is how one plan gets more. (To
give one team more than its plan, add a limit for that team alone: it
replaces its kind’s limit with the same action and window.) Then store
each team’s plan, and send that kind in every check:
await limitry.live.guard( { subject: { kind: `team-${team.plan}`, id: team.id }, action: "report", }, () => buildReport(params),);with limitry["live"].guard( subject={"kind": f"team-{team.plan}", "id": team.id}, action="report"): report = build_report(params)Keep the plan up to date from the subscription events. Don’t trust the order they arrive in: fetch the subscription again and store what it says now. A late or repeated event then does no harm.
const PLAN_BY_PRICE: Record<string, string> = { pro_monthly: "pro", pro_annual: "pro",};
async function onSubscriptionEvent(event: Stripe.Event) { const { id } = event.data.object as Stripe.Subscription; const subscription = await stripe.subscriptions.retrieve(id); const team = await teams.byStripeCustomer(subscription.customer as string);
const paying = ["active", "trialing", "past_due"].includes( subscription.status, ); const lookupKey = subscription.items.data[0]?.price.lookup_key ?? ""; await teams.setPlan( team.id, paying ? (PLAN_BY_PRICE[lookupKey] ?? "free") : "free", );}// customer.subscription.created, .updated and .deletedPLAN_BY_PRICE = {"pro_monthly": "pro", "pro_annual": "pro"}
def on_subscription_event(event) -> None: subscription = stripe.Subscription.retrieve(event.data.object.id) team = teams.by_stripe_customer(subscription.customer)
paying = subscription.status in ("active", "trialing", "past_due") lookup_key = subscription["items"].data[0].price.lookup_key or "" teams.set_plan(team.id, PLAN_BY_PRICE.get(lookup_key, "free") if paying else "free")
# customer.subscription.created, .updated and .deletedWhat this does in each case:
- Upgrade or downgrade: the next check uses the new plan’s limits.
- Trial: a
trialingsubscription counts as the plan. - Failed payment: Stripe retries while the subscription is
past_due, and the team keeps its plan. To cut them off sooner, leavepast_dueout of the list. - Cancellation: a
canceledorunpaidsubscription drops the team tofree.
Each plan’s kind keeps its own count. A team that upgrades mid-month starts the Pro window at zero. A team that moves back to Free finds its Free count where it left it.
Refill on the billing date
Section titled “Refill on the billing date”Stripe bills from the day a subscription starts, not on the 1st. To
refill a team’s allowance on its billing day, give the plan’s limits
start: "anchor" (see When limits refill), and set
the team’s anchor from the subscription in the same handler:
async function setBillingDate( event: Stripe.Event, subscription: Stripe.Subscription, team: { id: string; plan: string },) { // Stripe's billing_cycle_anchor is in seconds. await apiFor(event).subjects.update(`team-${team.plan}`, team.id, { anchor: new Date(subscription.billing_cycle_anchor * 1000).toISOString(), });}from datetime import datetime, timezone
def set_billing_date(event, subscription, team) -> None: # Stripe's billing_cycle_anchor is in seconds. anchor = datetime.fromtimestamp(subscription.billing_cycle_anchor, tz=timezone.utc) api_for(event).subjects.update(f"team-{team.plan}", team.id, anchor=anchor.isoformat())An annual plan’s monthly allowance then refills on the same day every month, and a new subscription starts a new period. Without an anchor, a team’s first check becomes its anchor.
To match a Stripe customer to a team, store the customer id on the team
when you create the customer. Put the team’s id in the customer’s
metadata too, so you can find it from either side.
Stripe’s own Entitlements say which features a plan includes. Limitry says how much of them a team can use. You can use both: decide the subject kind from the team’s entitlements instead of the price.
2. Credits from Stripe Checkout
Section titled “2. Credits from Stripe Checkout”Make a balance, a limit with the window total, as in
Sell prepaid credits. When you create
the Checkout Session, record what it sells:
const session = await stripe.checkout.sessions.create({ mode: "payment", line_items: [{ price: "price_credits_5000", quantity: 1 }], client_reference_id: team.id, metadata: { credits: "5000" }, success_url: "https://example.com/billing?paid=1",});session = stripe.checkout.Session.create( mode="payment", line_items=[{"price": "price_credits_5000", "quantity": 1}], client_reference_id=team.id, metadata={"credits": "5000"}, success_url="https://example.com/billing?paid=1",)When the payment succeeds, add the credits. The session’s id is the idempotency key, so a repeated event adds nothing:
async function onCheckoutPaid(event: Stripe.Event) { const session = event.data.object as Stripe.Checkout.Session; if (session.payment_status !== "paid") return; // a delayed method: wait const credits = Number(session.metadata?.credits);
await purchases.record({ paymentIntent: session.payment_intent as string, teamId: session.client_reference_id!, credits, amountPaid: session.amount_total!, }); await apiFor(event).limits.grants.create( BALANCE_ID, { subject: { kind: "team", id: session.client_reference_id! }, amount: credits, note: `Checkout ${session.id}`, }, { idempotencyKey: session.id }, );}// checkout.session.completed, and checkout.session.async_payment_succeeded// for payment methods that settle laterdef on_checkout_paid(event) -> None: session = event.data.object if session.payment_status != "paid": return # a delayed method: wait credits = int(session.metadata["credits"])
purchases.record( payment_intent=session.payment_intent, team_id=session.client_reference_id, credits=credits, amount_paid=session.amount_total, ) api_for(event).limits.grants.create( BALANCE_ID, subject={"kind": "team", "id": session.client_reference_id}, amount=credits, note=f"Checkout {session.id}", request_options={"idempotency_key": session.id}, )
# checkout.session.completed, and checkout.session.async_payment_succeeded# for payment methods that settle laterA refund takes credits back
Section titled “A refund takes credits back”A negative amount takes credits back. For a partial refund, take back the same share of the credits:
async function onRefund(event: Stripe.Event) { const refund = event.data.object as Stripe.Refund; const purchase = await purchases.byPaymentIntent( refund.payment_intent as string, ); const credits = Math.round( (purchase.credits * refund.amount) / purchase.amountPaid, );
await apiFor(event).limits.grants.create( BALANCE_ID, { subject: { kind: "team", id: purchase.teamId }, amount: -credits, note: `Refund ${refund.id}`, }, { idempotencyKey: refund.id }, );}// refund.createddef on_refund(event) -> None: refund = event.data.object purchase = purchases.by_payment_intent(refund.payment_intent) credits = round(purchase.credits * refund.amount / purchase.amount_paid)
api_for(event).limits.grants.create( BALANCE_ID, subject={"kind": "team", "id": purchase.team_id}, amount=-credits, note=f"Refund {refund.id}", request_options={"idempotency_key": refund.id}, )
# refund.createdA chargeback works the same way: on charge.dispute.created, take back
the credits for dispute.amount, with the dispute’s id as the key. If
you win the dispute (charge.dispute.closed with the status won), add
them again with a new key.
If the team already spent the credits, the balance goes below zero. Their checks then say no until they buy more.
Repeats after a day
Section titled “Repeats after a day”Limitry keeps an idempotency key for 24 hours, and Stripe can redeliver an event for up to three days. Record the Stripe ids you’ve handled (the session, refund or dispute id) in your database, and skip an event whose id you’ve seen.
3. Bill usage through Stripe meters
Section titled “3. Bill usage through Stripe meters”When you charge per unit, Limitry has already counted every unit at the check. Send that count to a Stripe meter instead of counting a second time in your own code.
Once an hour, for each team, read the hours that have finished, and send each one as a meter event:
const HOUR = 3_600_000;
async function sendUsage(team: Team) { // Hours are final about a minute after they end; wait five. const until = Math.floor((Date.now() - 5 * 60_000) / HOUR) * HOUR; const from = team.usageSentUntil; // where the last run stopped if (from >= until) return;
const series = await limitry.live.usage.query({ limitId: METERED_LIMIT_ID, subjectKind: "team", subjectId: team.id, granularity: "hour", from: new Date(from).toISOString(), to: new Date(until - 1).toISOString(), }); let carry = team.usageCarry; // units given back in an earlier hour for (const point of series.points) { const value = point.used + carry; carry = Math.min(0, value); if (value <= 0) continue; await stripe.billing.meterEvents.create({ event_name: "reports", payload: { stripe_customer_id: team.stripeCustomerId, value: String(value), }, identifier: `${team.id}:${point.start}`, timestamp: Math.floor(Date.parse(point.start) / 1000), }); } await teams.saveUsageCursor(team.id, { sentUntil: until, carry });}import timefrom datetime import datetime, timezone
HOUR = 3600
def iso(seconds: float) -> str: return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat()
def send_usage(team) -> None: # Hours are final about a minute after they end; wait five. until = int((time.time() - 5 * 60) // HOUR * HOUR) start = team.usage_sent_until # where the last run stopped if start >= until: return
series = limitry["live"].usage.query( limit_id=METERED_LIMIT_ID, subject_kind="team", subject_id=team.id, granularity="hour", from_=iso(start), to=iso(until - 1), ) carry = team.usage_carry # units given back in an earlier hour for point in series["points"]: value = point["used"] + carry carry = min(0, value) if value <= 0: continue stripe.billing.MeterEvent.create( event_name="reports", payload={"stripe_customer_id": team.stripe_customer_id, "value": str(value)}, identifier=f"{team.id}:{point['start']}", timestamp=int(datetime.fromisoformat(point["start"].replace("Z", "+00:00")).timestamp()), ) teams.save_usage_cursor(team.id, sent_until=until, carry=carry)Why this counts each unit once:
- Each hour goes once. The job stores where it stopped and starts
there next time. If it fails part way, the next run sends those hours
again, and Stripe drops a repeated
identifier. - A finished hour doesn’t change. Limitry records a reservation’s final cost in the hour it settles, never in an earlier hour.
- Given-back units aren’t lost.
usedis net: an hour in which reservations released more than they took can come out below zero. The job carries that into the next hour it sends, because a meter event can’t be negative.
Send from the live key only, so test checks never reach a live invoice. To try the job in Stripe’s test mode, run it once with your test key against a test customer.
Related: Sell prepaid credits, Test and live, Idempotency, Usage and reports.