Add Paddle usage-based billing (early access) to a Next.js app — early access check, usage balance units, meters, price allowances, usage events, balance gating, and `usage_balance.depleted`, using the Paddle API directly.
84%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
Use this skill when the user wants subscription plans that include prepaid usage — like 10,000 API calls, 1 million tokens, or 500 document exports a month — and wants to report usage to Paddle and restrict features when customers run out.
Pair with:
catalog-setup — create the product. Create prices with allowances using this skill's API helper instead of its seed script, and don't reuse its trial price: prices with allowances can't have a trial period.checkout-web — customers subscribe to a plan with an allowance using a normal checkout.subscription-sync — map your users to Paddle customer IDs, which usage events need.webhooks — receive and verify usage_balance.depleted, and queue work instead of doing it in the handler.subscription-update — plan changes. This skill overrides its proration defaults (see "Renewals and plan changes").sandbox-testing — test cards and checkout in a sandbox account.Read this first. These facts change how you build the integration.
search the MCP for meter, usage event, or usage balance tools, and don't fake metering with custom items or one-time charges. Call the Paddle API with fetch, as shown below.paddle.meters or paddle.usageEvents, and its price types don't include allowances: TypeScript rejects the field on paddle.prices.create(), and the SDK's Price objects leave it out of responses. Use fetch for every call in this skill. Use the SDK only to verify webhooks.ubu_) are what customers hold a balance of, like API calls or credits.mtr_) turn usage into drawdown. Each meter links an action, like api_call, to one usage balance unit, at a rate. A usage event draws down quantity × rate from the customer's balance of that unit. Several meters can draw down the same unit, for example chat_message at "1" and report_export at "25" from one "Credits" unit.ubl_) hold what a customer has used and has left. Paddle creates one for each customer and unit in the background when a subscription with an allowance activates. They belong to the customer, not the subscription: allowances from several subscriptions for the same unit combine into one balance.Across a billing period:
remaining counts down to "0" and never goes negative. After that, usage is recorded as overage, which Paddle doesn't bill during early access (beta).used doesn't include overage, so the allowance is used.total + remaining.total. The subscription amount in each breakdown always equals total during early access (beta), so don't build UI around the split.usage_balance.depleted occurs when usage draws remaining to "0". There's no event when usage is replenished.Confirm these before writing code. Do not guess:
action, like api_call or document_export.calls. If several features should draw down one balance, like a shared pool of credits, use one unit with a meter for each feature and a rate for each.rate other than "1" (for example, "0.001" for one credit per 1,000 tokens).subscription-sync.// utils/paddle/usage-config.ts
export const USAGE = {
apiCalls: {
usageBalanceUnitId: "ubu_...", // from Step 3
action: "api_call",
rate: 1, // matches the meter's rate
},
} as const;CREATE TABLE usage_events (
event_key TEXT PRIMARY KEY, -- your ID for this usage, sent to Paddle as event_key
customer_id TEXT NOT NULL, -- "ctm_01h..."
action TEXT NOT NULL,
quantity TEXT NOT NULL, -- decimal string, like "1" or "2.5"
occurred_at TEXT NOT NULL, -- RFC 3339 UTC, captured once
status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | failed
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX usage_events_pending_idx ON usage_events(status) WHERE status = 'pending';You need:
usage_balance_unit.write, meter.write, price.write, usage_event.write, and usage_balance.read. Each write permission includes read.@paddle/paddle-node-sdk 2.5 or later (v3 recommended) for webhooks. Earlier versions return null for event types they don't know, so usage_balance.depleted is silently lost.NEXT_PUBLIC_PADDLE_ENV=sandbox # or "production"
PADDLE_API_KEY=pdl_sdbx_apikey_... # server-side only; never expose to the browser
PADDLE_NOTIFICATION_WEBHOOK_SECRET=pdl_ntfset_...Run the one-off scripts in this skill with node --env-file=.env.local --import tsx scripts/<name>.ts. tsx on its own doesn't load .env.local.
Every call in this skill goes through one server-side helper. Sandbox and live use different base URLs, and a sandbox key only works against the sandbox API.
// utils/paddle/api.ts
// Server-side only. Never import this from a client component.
const BASE_URL =
process.env.NEXT_PUBLIC_PADDLE_ENV === "production"
? "https://api.paddle.com"
: "https://sandbox-api.paddle.com";
export class PaddleApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
public requestId?: string,
public retryAfterSeconds?: number, // set on 429 responses
) {
super(message);
}
}
export async function paddleApi<T>(
path: string,
options: { method?: "GET" | "POST" | "PATCH"; body?: unknown } = {},
): Promise<T> {
const apiKey = process.env.PADDLE_API_KEY;
if (!apiKey) throw new Error("PADDLE_API_KEY is not set");
const response = await fetch(`${BASE_URL}${path}`, {
method: options.method ?? "GET",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: options.body === undefined ? undefined : JSON.stringify(options.body),
cache: "no-store",
});
const json = await response.json().catch(() => ({}));
if (!response.ok) {
const retryAfter = Number(response.headers.get("Retry-After"));
throw new PaddleApiError(
response.status,
json?.error?.code ?? "unknown",
json?.error?.detail ?? response.statusText,
json?.meta?.request_id,
retryAfter > 0 ? retryAfter : undefined,
);
}
return json as T;
}Before building anything, call GET /meters. The usage-based billing API key permissions only appear in the Paddle dashboard for accounts in early access, so this call tells you whether the account and the key are set up. Paddle returns 403 for both permission and authentication problems, so branch on the error code:
// scripts/check-usage-billing-access.ts
import { paddleApi, PaddleApiError } from "@/utils/paddle/api";
const AUTH_ERRORS = [
"invalid_token",
"authentication_missing",
"authentication_malformed",
"expired_token",
];
async function main() {
try {
await paddleApi("/meters?per_page=1");
console.log("Usage-based billing is available for this account and API key.");
} catch (error) {
if (!(error instanceof PaddleApiError)) throw error;
if (error.code === "forbidden") {
console.log(
"Missing usage-based billing permissions. Add them to the key in Paddle > My account > Settings > " +
"Authentication. If they aren't listed, apply for early access at " +
"https://developer.paddle.com/build/usage-based-billing",
);
} else if (AUTH_ERRORS.includes(error.code)) {
console.log("Invalid API key, or it doesn't match NEXT_PUBLIC_PADDLE_ENV.");
} else {
throw error;
}
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});If the account isn't in early access, tell the user to apply using the tutorial, and stop there.
This is one-time setup. Create prices with allowances using the API helper, not the SDK or catalog-setup's seed script. Create the unit first, then the meter and price that use it:
// scripts/setup-usage-billing.ts
import { paddleApi } from "@/utils/paddle/api";
type Entity = { data: { id: string } };
async function main() {
const unit = await paddleApi<Entity>("/usage-balance-units", {
method: "POST",
body: { name: "API calls", unit_name: "calls" }, // plural: what customers hold a balance of
});
const meter = await paddleApi<Entity>("/meters", {
method: "POST",
body: {
usage_balance_unit_id: unit.data.id,
name: "API calls",
action: "api_call", // what you send with usage events
unit_name: "call", // singular: what the meter measures
rate: "1", // how much of the unit each unit of usage draws down
},
});
// Prices with allowances can't have a trial_period.
const price = await paddleApi<{ data: { id: string; allowances: unknown[] } }>("/prices", {
method: "POST",
body: {
product_id: "pro_...",
description: "Pro plan (monthly), including 10,000 API calls",
name: "Pro",
unit_price: { amount: "2000", currency_code: "USD" },
billing_cycle: { interval: "month", frequency: 1 },
allowances: [{ usage_balance_unit_id: unit.data.id, amount: "10000" }],
},
});
// Save the IDs in utils/paddle/usage-config.ts, and check allowances isn't empty.
console.log({
usageBalanceUnitId: unit.data.id,
meterId: meter.data.id,
priceId: price.data.id,
allowances: price.data.allowances,
});
}
main().catch((error) => {
console.error(error);
process.exit(1);
});action must match ^[a-z][a-z0-9_]*$ and be 2–64 characters. rate is a decimal string greater than zero, and aggregation defaults to sum, the only option during early access (beta).usage_balance_unit_id or aggregation, and two meters can't share the same action and unit (meter_action_usage_balance_unit_duplicate), even if one is archived. There's no delete: archive with PATCH and status: "archived", though archived meters still draw down usage balances.rate or action only affects new usage. Paddle doesn't recalculate usage it already recorded. After you change action, events sent with the old action no longer draw down this meter's unit, so update usage-config.ts at the same time.price_allowance_not_allowed_for_trial_price, price_allowance_not_allowed_for_one_off_price). A price can have up to 20, one for each unit, and Paddle multiplies each by the item quantity. An amount of "0" gives an empty balance, so all usage is recorded as overage.PATCH /prices/{price_id} replaces allowances in full, so send every allowance you want to keep. A price without allowances never creates a usage balance.Open a checkout for the price as normal — see checkout-web. Subscriptions and transactions with these prices must be collected automatically. Manually-collected (invoiced) ones are rejected.
These prices can't have trials, so subscription.created arrives with status: active. That's your signal that Paddle is creating the usage balance. It happens in the background, so don't poll for it inside the webhook handler. Queue a job (see webhooks), and have the job check with backoff:
// utils/paddle/usage.ts
import { paddleApi, PaddleApiError } from "@/utils/paddle/api";
export type UsageBalance = {
id: string;
usage_balance_unit_id: string;
balance: {
used: { total: string };
remaining: { total: string };
overage: { total: string; started_at?: string };
};
};
// Returns null if the customer has no usage balance for this unit. One page is enough:
// customers have one usage balance for each unit.
export async function getUsageBalance(customerId: string, usageBalanceUnitId: string) {
const result = await paddleApi<{ data: UsageBalance[] }>(
`/customers/${customerId}/usage-balances?per_page=200`,
);
return result.data.find((b) => b.usage_balance_unit_id === usageBalanceUnitId) ?? null;
}
// Run from a queued job after subscription.created. Retries with backoff.
export async function confirmUsageBalance(customerId: string, usageBalanceUnitId: string) {
for (let attempt = 0; attempt < 5; attempt++) {
const usageBalance = await getUsageBalance(customerId, usageBalanceUnitId);
if (usageBalance) return usageBalance;
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1000));
}
throw new Error(`No usage balance for ${customerId} and ${usageBalanceUnitId}`);
}For a customer's first subscription with this unit, remaining.total should equal the allowance amount multiplied by the item quantity, and used.total should be "0". If the balance never appears:
GET /prices/{price_id} and check allowances isn't empty.Don't call Paddle in the middle of the customer's request. Instead:
event_key set to your own ID for the usage and occurred_at captured once, when the usage happened. If the work rolls back, so does the usage.pending, since Paddle rejects events more than 60 days old.accepted events sent. Retry network errors, 5xx responses, 429 responses, and events rejected with internal_error, with the same event_key and occurred_at. Mark other rejections failed, with the error code, for review.429. Paddle rate limits API requests by IP address and blocks further requests until Retry-After passes, so leave the remaining rows pending and resume after retryAfterSeconds.Next.js after() on its own isn't durable: if the process stops, the usage is lost. Use it to trigger the worker sooner, not instead of the outbox.
// utils/paddle/usage.ts (continued)
type UsageEventRow = {
eventKey: string;
customerId: string;
action: string;
quantity: string; // decimal string, like "1" or "2.5"
occurredAt: string;
};
type UsageEventResponse = {
data: {
events: Array<{ event_key: string; status: "accepted" | "rejected"; error?: { code: string } }>;
};
};
type SendResult = {
outcome: "sent" | "retry" | "failed";
code?: string;
retryAfterSeconds?: number; // set when rate limited: stop the run until it passes
};
const QUANTITY = /^(0|[1-9]\d{0,34})(\.\d{1,9})?$/;
export async function sendUsageEvent(row: UsageEventRow): Promise<SendResult> {
// Quantities must be greater than zero, with up to 9 decimal places and no leading zeros.
// Don't build them with String(number): 0.1 + 0.2 becomes "0.30000000000000004", which
// Paddle rejects.
if (!QUANTITY.test(row.quantity) || Number(row.quantity) <= 0) {
return { outcome: "failed", code: "invalid_quantity" };
}
try {
const result = await paddleApi<UsageEventResponse>("/usage-events", {
method: "POST",
body: {
// One event per request during early access (beta). More than one returns a 400.
events: [
{
event_key: row.eventKey,
customer_id: row.customerId,
action: row.action,
quantity: row.quantity,
occurred_at: row.occurredAt,
},
],
},
});
const [event] = result.data.events;
if (event.status === "accepted") return { outcome: "sent" };
const code = event.error?.code ?? "rejected";
return { outcome: code === "internal_error" ? "retry" : "failed", code };
} catch (error) {
if (error instanceof PaddleApiError && error.status === 429) {
return {
outcome: "retry",
code: error.code,
retryAfterSeconds: error.retryAfterSeconds ?? 60,
};
}
if (error instanceof PaddleApiError && error.status < 500) {
return { outcome: "failed", code: error.code };
}
return { outcome: "retry" }; // network error or 5xx: resend unchanged
}
}event_key and occurred_at, for a customer and action, which is why the outbox stores both.occurred_at can't be more than 60 days in the past or 24 hours in the future. Paddle rejects events outside this window with usage_event_timestamp_too_old or usage_event_future_timestamp. Retries are rejected once they're more than 60 days old too, but if Paddle accepted an earlier attempt, it still counts.quantity and an event_key for that window. This keeps the worker under the rate limit.Read the balance from your cache, and refresh it from Paddle when it's stale:
// utils/paddle/usage.ts (continued)
export interface BalanceCache {
get(
customerId: string,
unitId: string,
): Promise<{ remaining: number | null; fetchedAt: number } | null>;
set(customerId: string, unitId: string, remaining: number | null): Promise<void>;
decrement(customerId: string, unitId: string, amount: number): Promise<void>;
}
const CACHE_TTL_MS = 60_000;
// Returns null if the customer has no usage balance for this unit.
export async function getRemaining(cache: BalanceCache, customerId: string, unitId: string) {
const cached = await cache.get(customerId, unitId);
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) return cached.remaining;
const usageBalance = await getUsageBalance(customerId, unitId);
const remaining = usageBalance ? Number(usageBalance.balance.remaining.total) : null;
await cache.set(customerId, unitId, remaining);
return remaining;
}getRemaining(). null means the customer isn't subscribed to a plan with this unit. 0 or less means they've used their allowance, so do what you agreed with the user.cache.decrement() with quantity × rate, so the cache stays close between refreshes. If an action has meters for more than one unit, decrement each unit's balance.usage_balance.depleted arrives, and after a subscription renews or changes.used.total + remaining.total. Show overage.total separately.usage_balance.depletedSubscribe your notification destination to usage_balance.depleted. It's only listed for accounts in early access.
The SDK's EventName enum doesn't include this event, so unmarshal() returns it as a generic event, with camelCase data. Compare the event type as a string before the webhooks skill's switch. Keep processEvent's EventEntity parameter, so the switch still narrows the typed events:
// utils/paddle/process-webhook.ts
import { type EventEntity, EventName } from "@paddle/paddle-node-sdk";
export async function processEvent(event: EventEntity) {
// Not in the SDK's EventName enum yet, so compare the string.
const eventType: string = event.eventType;
if (eventType === "usage_balance.depleted") {
const data = event.data as unknown as {
id: string;
customerId: string;
usageBalanceUnitId: string;
balance: { remaining: { total: string } };
};
// No subscription ID in the payload: match on the customer and unit.
// Keep this fast: update the cache and queue anything heavier.
await markBalanceDepleted(data.customerId, data.usageBalanceUnitId); // your function
return;
}
switch (event.eventType) {
// ...cases from the webhooks skill
default:
return;
}
}event.eventId.subscription.updated arrives for a renewal or plan change, queue a job to refresh the cached balance. Paddle grants the new allowance in the background too.| When | What happens to the usage balance |
|---|---|
| Subscription renews | Unused usage expires. Paddle grants the full allowance for the new period and clears overage. |
| Customer upgrades or downgrades | The old allowance expires and the new price's full allowance is granted straight away, with no proration. Overage carries over until renewal. |
| Quantity changes | Paddle grants the allowance again for the new quantity. |
| Subscription is canceled | When the cancellation takes effect, that subscription's unused usage expires. Further usage is recorded as overage. |
| Subscription is paused | The balance doesn't change, and Paddle still records usage. |
| Subscription resumes | Resuming with a new billing period grants the full allowance. Continuing the existing period doesn't change the balance. |
| Customer gets a refund | The balance doesn't change. |
This overrides subscription-update's proration defaults. Before choosing prorationBillingMode, check whether any item's price has allowances (get it with this skill's API helper, since the SDK leaves the field out). If it does, prorated modes are rejected (subscription_prorated_billing_not_allowed_for_allowance):
full_immediately.full_next_billing_period, and warn the customer that the change applies straight away, so they lose what's left of their current allowance.Not available during early access (beta), so don't build for them: trials, one-time prices, or manually-collected subscriptions with allowances; top-ups; overage billing; rollover or custom expiry; webhooks for other thresholds, like 80% used; and usage reports. To warn customers before they run out, compare remaining.total with the allowance in your app.
action. Paddle accepts events for actions that don't match a meter, but they don't draw down any balance, and there's no error. Keep actions in usage-config.ts, and check them against GET /meters.occurred_at on retry. Paddle counts the retry as new usage, so the same usage draws down the balance twice. Don't do it to get a usage_event_timestamp_too_old rejection accepted either.202 as "balance updated." It means Paddle accepted the event. The drawdown happens shortly after.Price objects don't include allowances, so a correct price looks like it has none. Use the API helper.GET /meters succeeds."3", so it's quick to use up. Complete a checkout with a test card (see sandbox-testing).remaining.total of "3" and used.total of "0".quantity: "1". After a few seconds, confirm remaining.total is "2".event_key and occurred_at. Confirm remaining.total doesn't change.usage_balance.depleted arrives at your webhook endpoint and your app applies what you agreed for zero.remaining.total stays "0" and overage.total is "1".usage_balance.depleted — webhook reference.PaddleHQ/paddle-agent-skills
f2e0af8
Also appears in
since Oct 20, 2026
since Oct 20, 2026
since Oct 20, 2026
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.