Skip to content

Use Limitry with Stripe

Stripe takes the payments and Limitry does the counting. This guide covers the three ways the two meet:

  1. A customer’s plan decides their limits.
  2. Customers buy credits with Stripe Checkout, and a refund takes them back.
  3. 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;

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.

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),
);

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 .deleted

What this does in each case:

  • Upgrade or downgrade: the next check uses the new plan’s limits.
  • Trial: a trialing subscription 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, leave past_due out of the list.
  • Cancellation: a canceled or unpaid subscription drops the team to free.

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.

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(),
});
}

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.

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",
});

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 later

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.created

A 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.

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.

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 });
}

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. used is 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.