Home / Learn / GTM Engineering / Stage 5: Usage-Based Pricing (UBP) and Monetization

Stage 5 of 1219 min readUpdated 2026-10-07

Stage 5: Usage-Based Pricing (UBP) and Monetization

Learn to price by usage: value metrics, Stripe Billing Meters, credits, idempotent metering and spend caps. Then build a credit burn-down ledger for AI agents.

  • Build: Design and build a pricing architecture that charges per agent execution and per token processed, with prepaid credits, overages and a spend cap, instead of per seat.
  • Free, no signup

The short answer

Usage-based pricing (UBP) means customers pay for what they consume, such as agent runs or tokens processed, instead of paying per user. A GTM engineer owns the plumbing behind it: the events that count usage, the rules that turn usage into money, the credits and caps that protect both sides, and the data that sales and finance read afterward. Learn the concepts first and the vendor second, because in 2026 the vendors are changing owners faster than the concepts change.

Why this matters

When software does the work, seat count stops predicting value or cost. One agent can do the work of many users, and each run costs real money. The Growth Unhinged "2026 State of B2B SaaS and AI Monetization" survey (230 software and AI companies, April to May 2026) reports a median target margin of about 50% for AI products, against 70% to 80% or more for classic SaaS. A flat seat price cannot protect a margin that moves with every run.

The roadmap says: "SaaS pricing has permanently shifted from charging for access to charging for outcomes." Test that claim against the evidence.

  • The direction is real. On July 1, 2026 Gartner said up to $234 billion of enterprise application spending is exposed to "agentic arbitrage" between now and 2030, roughly 20% of enterprise SaaS spending by 2030. Gartner says agentic systems "deliver outcomes directly, bypassing traditional user experience (UX)-heavy applications". That is a forecast of revenue at risk for seat vendors, not a measure of today's prices. See Gartner's press release and CIO Dive's summary.
  • The shift is mostly to hybrid and credits, not outcomes. In the survey, 37% of companies use a hybrid model (subscription plus usage), 29% already sell AI credits and 33% more plan to within 6 to 12 months. The report gives no adoption share for outcome-based pricing. It splits respondents by company size, deal size and product type, but does not say how they were recruited, so read it as a snapshot of one audience.
  • Outcome pricing exists but is narrow. Intercom charges $0.99 per Fin "outcome". Salesforce sells Agentforce by action (credits), by conversation ($2) and by user ($125 per month) at the same time.
  • "Permanently" has no evidence behind it. The same survey says big vendors changed their pricing again in early 2026.

Verdict: usage and credits are spreading, and seats alone are under pressure. Pure outcome pricing is rare because both sides must accept how to measure the result. Plan for hybrid.

One more signal, and this is my reading, not proof: payment, API and CRM companies bought four billing infrastructure firms between September 2025 and July 2026. The tools table has the dates. Sources: Kong and OpenMeter, Stripe and Metronome, Salesforce and m3ter, Adyen and Orb.

Core concepts

Four ways to charge

ModelYou charge forExampleWeak point
SeatNamed usersIntercom Advanced: $85 per seat per month, billed annuallyRevenue ignores how much work agents do
UsageUnits consumedOpenAI: price per 1M tokensCustomer bills are hard to predict
HybridBase fee plus usage or creditsSeat plus overage (usage billed after the included credits run out)More rules to explain and build
OutcomeA result a rule confirmsFin: $0.99 per outcomeYou must define and defend "result"

Worked example. One $85 seat equals about 86 Fin outcomes (85 / 0.99). An agent that resolves 400 conversations in a month earns $396 on usage pricing. One that resolves 20 earns $19.80. A seat price ignores that gap. Source: Intercom pricing.

The value metric

A value metric is the unit you bill, chosen so the bill grows when the customer gets more value. Test a candidate with four questions.

  1. Does the customer understand it without a manual? "Resolutions" passes. "Normalized compute units" fails.
  2. Does it rise when the customer succeeds? Tokens rise with usage, not always with value.
  3. Can you measure it exactly in your own system and defend the number in a dispute?
  4. Does your cost rise with it? If not, margin problems come later.
VendorMetricStated price (fetched 2026-10-07)
OpenAITokens: input, cached input, outputgpt-6-astra: $10.00 input, $1.00 cached, $50.00 output per 1M tokens
SnowflakeCompute credits, per second, 60 second minimumXS warehouse (Gen1) 1 credit per hour, doubling per size
SalesforceAgentforce Flex Credits$500 per 100,000 credits, 20 credits per standard action

The details are the product. Fin bills a resolution when a customer confirms it or when "no further help is requested after the last AI answer", an assumed resolution that favors the vendor. Sources: OpenAI, Snowflake, Fin outcomes, Agentforce.

Credits

A credit is a private currency between the thing you charge for and dollars. You can change the real price of an action without changing the price list, and sell many actions from one balance.

Agentforce shows the arithmetic. A credit costs $0.005 ($500 / 100,000), so a 20 credit action costs $0.10. The per-user add-on is $125 per user per month for unmetered use. Break-even is 1,250 actions per user per month ($125 / $0.10).

Credits come in kinds your system must tell apart: prepaid (bought, a liability on the books until used), promotional (free, usually expiring) and included (part of a plan, refilled each period). With several grants you need a burn order, which is the rule for which grant pays first. Stripe's rule is a good default: lower priority number first, then earliest expiry, then promotional before paid, then earliest effective date, then earliest creation. Worked example: a customer holds 15 promotional credits that expire in 5 days and 100 paid credits. A 10 credit run burns 10 promotional credits, and the next 10 credit run burns the last 5 promotional credits and then 5 paid credits.

The metering pipeline

  1. Emit: the product writes one event per billable action.
  2. Ingest: a service validates the event and stores it once.
  3. Aggregate: events are summed or counted per customer per window.
  4. Rate: usage is multiplied by price, with tiers and credits applied.
  5. Invoice: the result becomes an invoice line.

A good event is small and carries its own idempotency key:

{
  "transaction_id": "run_8f3a9c",
  "customer_id": "acme",
  "event_type": "agent_execution",
  "timestamp": 1791120747,
  "quantity": 1
}

Idempotent ingestion means sending an event twice has the same effect as sending it once. Networks retry, so duplicates are certain. Vendors use a client-supplied key, with different memory:

SystemKey fieldDedupe windowTimestamp window
Stripe meter eventsidentifier"at least 24 hours"Past 35 days, 5 minutes ahead
Metronome ingesttransaction_id34 daysBackdate 34 days, no future
Lago eventstransaction_idOne event per keyDefaults to receipt time

A retry that arrives 30 hours later can double count in Stripe, because the key is forgotten. Keep your own ledger with a permanent unique key, as the project does.

Late events arrive after the usage happened, for example from an offline client. Decide how old an event may be, which month it belongs to (the usage time, not the arrival time), and what happens when that month's invoice is final. Worked example: an event stamped September 30 at 23:58 UTC arrives on October 1 at 00:10. It belongs to September. If the September invoice is already final, Stripe will not correct a finalized invoice for canceled usage, so plan a manual adjustment or a next-invoice line.

Aggregation windows turn raw events into billable numbers. Stripe meters aggregate with sum (add the values), count (count the events) or last (use the newest value). They take events raw or pre-aggregated per hour or day, where the newest event in a window overwrites older ones. Worked example: three events with values 5, 3 and 2 give sum 10, count 3 and last 2. Hourly roll-ups also feed dashboards cheaply:

SELECT customer_id, event_type,
       strftime('%Y-%m-%d %H:00', ts, 'unixepoch') AS hour_utc,
       COUNT(*) AS events, SUM(credits) AS credits
FROM usage_events
WHERE ts >= CAST(strftime('%s', 'now', '-1 day') AS INTEGER)
GROUP BY customer_id, event_type, hour_utc
ORDER BY hour_utc DESC;

Stripe Billing Meters, and what changed

A meter defines an event name and an aggregation. A meter event is one usage record. A metered price attaches to the meter and goes on a subscription. After you create a meter, only its display name can change.

curl https://api.stripe.com/v1/billing/meters \
  -u "$STRIPE_API_KEY:" \
  -d "display_name=Agent credits" \
  -d event_name=agent_credits \
  -d "default_aggregation[formula]=sum" \
  -d "customer_mapping[type]=by_id" \
  -d "customer_mapping[event_payload_key]=stripe_customer_id" \
  -d "value_settings[event_payload_key]=value"

You send events to POST /v1/billing/meter_events with event_name, a payload (customer and value), an optional identifier and an optional timestamp. Live mode allows 1,000 calls per second per account and one concurrent call per customer per meter. Processing is asynchronous, and bad events surface later as thin events (small webhook messages that carry an ID, not the full data) named v1.billing.meter.error_report_triggered, so subscribe to them. A webhook is an HTTP request that Stripe sends to your server when something happens. Always verify its Stripe-Signature header: the SDK's parse_thin_event does this for thin events and Webhook.construct_event for ordinary ones. A meter event adjustment can cancel an event, but only within 24 hours of sending.

Many tutorials still teach the old API. API version 2025-03-31.basil removed legacy usage records: the UsageRecord endpoints, aggregate_usage and billing_thresholds are gone, and a metered price needs a meter. Old code works only by pinning an older API version through a raw request. See the changelog entry.

The bigger change is the advice. As of October 7, 2026, Stripe's docs call Metronome, now part of Stripe, its primary usage-based platform "recommended for all new integrations", and say Billing Meters stays "fully supported" for existing users. Billing Meters lacks real-time prepaid drawdown, commits and ramps, dimensional pricing and real-time usage visibility. Its credits are "only reconciled at invoice time", so "Customers can exceed their balance during the cycle". Prices are in the tools table. Sources: overview, comparison, Stripe pricing, Metronome pricing.

Credit grants, bill shock and caps

In Stripe, prepaid or promotional credits are a credit grant: POST /v1/billing/credit_grants with customer, amount (type monetary), category (paid or promotional), applicability_config, optional expires_at and optional priority (default 50, 0 is highest). Grants apply only when an invoice finalizes, only to meter-backed prices, and credit notes do not restore them. Read Billing credits before you promise finance anything.

Bill shock is the surprise invoice. Use three layers:

  1. Visibility: show live usage and a forecast in the product.
  2. Alerts: notify at 50%, 80% and 100% of a budget. A Stripe usage alert (POST /v1/billing/alerts, alert_type=usage_threshold) fires at one threshold in meter units (usage_threshold[gte]), so create one alert per level. It sends a billing.alert.triggered webhook, with up to 25 alerts per meter and customer.
  3. Caps: a hard stop your product enforces before the work runs.

You build the third layer yourself. Stripe's monitoring page lists "remove access when they exceed the limit" as something you do after an alert. A cap check is one query, so run it before every run, and give customers a way to raise the cap. Worked example: a cap of 2,000 credits per month, with 1,990 used. A 10 credit run passes (2,000 is not above the cap). An 11 credit run is refused before it starts. One limit remains: the check counts only usage already recorded, so two runs that start at the same moment can both pass. Stricter systems reserve the credits first and settle after the run.

Revenue recognition basics

Finance counts revenue on a different clock than billing. Two ideas are enough to start. An accountant must confirm them for any real company.

  • Prepaid credits are not revenue yet. The cash is a contract liability (deferred revenue) and becomes revenue as the customer uses the credits. Worked example: a customer pays $1,000 for credits and uses $100 worth in October. October revenue is about $100, and $900 stays a liability. Credits that expire unused need a "breakage" policy.
  • A flat rate per unit is the easy case. ASC 606, the US accounting standard for revenue from customer contracts, lets a company recognize what it has the right to invoice when that amount "corresponds directly with the value to the customer of the entity's performance completed to date", such as "a fixed amount for each hour of service provided". Varying unit prices qualify only if they reflect the value of each incremental unit. See Deloitte DART section 8.5.

Your job is a usage ledger finance can trust: unique events, frozen prices, and a record of which grant paid for which event.

Key terms

The tools

ToolWhat it doesPricing modelNotes
Stripe Billing MetersMeters, meter events, metered prices, credit grants0.7% of billing volume, pay-as-you-goFully supported for existing users, new builds point to Metronome. Free to try in a Stripe sandbox.
MetronomeReal-time metering, rating, prepaid drawdown, commitsStartup plan: 0.8% of billing volume plus $0.04 per 1,000 eventsPart of Stripe since January 13, 2026. Stripe's docs link a self-serve sandbox sign-up.
OrbUsage billing with SQL-defined metricsCustom, from billings and eventsPart of Adyen since July 1, 2026, though its homepage does not say so. No free tier listed. Has a demo environment.
LagoOpen-source metering, plans and invoicesAGPLv3 open source plus paid PremiumFree to self-host with Docker Compose. Cloud prices need a sales call.
OpenMeterOpen-source metering, entitlements and billingApache 2.0, plus hosted OpenMeter CloudKong announced the purchase September 3, 2025, and plans to fold it into Konnect. Open source stays Apache 2.0.
m3terMetering and rating for consumption billingNot listed on its siteSalesforce signed the deal June 11, 2026. Its site says it is now part of Salesforce. No free tier listed.

Vendor status moves fast. Re-check each vendor's site before you recommend one.

Build the project

You will build a pricing architecture that charges per agent execution and per token processed, with prepaid credits, overages and a hard spend cap. The core is a local SQLite ledger and needs no accounts. An optional last step mirrors usage into Stripe in test mode.

The rate card: one agent execution costs 10 credits, every 1,000 tokens cost 1 credit, a prepaid credit costs 1 cent, and overage after credits run out costs 1.5 cents per credit. The numbers are invented. A real company sets them from its cost per run and target margin.

LayerWhat it doesWhere
EmitProduct sends events with a unique transaction_idsimulate.py
IngestValidate, dedupe, price in credits, burn, all in one transactionmeter.py
LedgerAppend-only credit burns stamped with usage timecredit_ledger
CapBlock a run before it startsallowed()
StatementCredits used and overage in centsstatement()
ForwardMirror credits to a Stripe meterstripe_sync.py
  1. Write the value metric in one sentence and test it with the four questions above.
  2. Create meter.py from the code below, on Python 3.9 or newer.
  3. Create test_meter.py from the code below and run python3 test_meter.py. It must print ok.
  4. Create simulate.py from the code below. It generates 300 events with retries, late arrivals and two customers.
  5. Run python3 simulate.py and confirm that, for each customer, credits from grants plus overage credits equal the total usage credits.
  6. Read the last line of the output and confirm that a 1,000,000 credit run is refused for acme. In a real product, put the same allowed() call before the code that starts an agent run.
  7. Write a one-page pricing note: metric, rate card, prepaid price, overage price, cap policy, and one way a customer could be surprised.
  8. Optional: with a Stripe test-mode key, run the meter curl command from the Stripe section once, because an event name can have only one meter.
  9. Optional: link a Stripe test customer with set-customer, run python3 stripe_sync.py to forward events, and view the usage in the Dashboard.

The ledger: meter.py

import math
import sqlite3
import time
from datetime import datetime, timezone

LATE_WINDOW_S = 35 * 86400
FUTURE_SKEW_S = 300
OVERAGE_CENTS_PER_CREDIT = 1.5
RATE_CARD = {
    "agent_execution": lambda qty: 10.0 * qty,
    "tokens_processed": lambda qty: qty / 1000.0,
}
SCHEMA = """
CREATE TABLE IF NOT EXISTS customers (
  id TEXT PRIMARY KEY, stripe_customer_id TEXT, monthly_cap_credits REAL);
CREATE TABLE IF NOT EXISTS usage_events (
  transaction_id TEXT PRIMARY KEY,
  customer_id TEXT NOT NULL REFERENCES customers(id),
  event_type TEXT NOT NULL, ts INTEGER NOT NULL, quantity REAL NOT NULL,
  credits REAL NOT NULL, received_at INTEGER NOT NULL,
  forwarded INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS credit_grants (
  id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id TEXT NOT NULL,
  credits REAL NOT NULL, remaining REAL NOT NULL,
  expires_at INTEGER, category TEXT NOT NULL DEFAULT 'paid');
CREATE TABLE IF NOT EXISTS credit_ledger (
  id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id TEXT NOT NULL,
  grant_id INTEGER, transaction_id TEXT NOT NULL,
  delta REAL NOT NULL, ts INTEGER NOT NULL);
"""


def connect(path=":memory:"):
    conn = sqlite3.connect(path, isolation_level=None)
    conn.row_factory = sqlite3.Row
    conn.executescript(SCHEMA)
    return conn


def add_customer(conn, cid, stripe_id=None, cap=None):
    conn.execute("INSERT OR REPLACE INTO customers VALUES (?, ?, ?)", (cid, stripe_id, cap))


def grant_credits(conn, cid, credits, expires_at=None, category="paid"):
    conn.execute("INSERT INTO credit_grants (customer_id, credits, remaining, expires_at,"
                 " category) VALUES (?, ?, ?, ?, ?)",
                 (cid, credits, credits, expires_at, category))


def ingest(conn, event, now=None):
    """Return 'accepted', 'duplicate' or 'rejected:<reason>'."""
    now = int(now if now is not None else time.time())
    try:
        txn, cust = str(event["transaction_id"]), str(event["customer_id"])
        etype, ts = event["event_type"], int(event["timestamp"])
        qty = float(event["quantity"])
    except (KeyError, TypeError, ValueError, OverflowError):
        return "rejected:malformed"
    if not txn or etype not in RATE_CARD or not math.isfinite(qty) or qty < 0:
        return "rejected:invalid"
    if ts < now - LATE_WINDOW_S:
        return "rejected:too_old"
    if ts > now + FUTURE_SKEW_S:
        return "rejected:future"
    credits = RATE_CARD[etype](qty)
    conn.execute("BEGIN IMMEDIATE")
    try:
        if not conn.execute("SELECT 1 FROM customers WHERE id=?", (cust,)).fetchone():
            conn.execute("ROLLBACK")
            return "rejected:unknown_customer"
        cur = conn.execute(
            "INSERT OR IGNORE INTO usage_events (transaction_id, customer_id, event_type,"
            " ts, quantity, credits, received_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
            (txn, cust, etype, ts, qty, credits, now))
        if cur.rowcount == 0:
            conn.execute("ROLLBACK")
            return "duplicate"
        _burn(conn, cust, txn, credits, ts)
        conn.execute("COMMIT")
        return "accepted"
    except Exception:
        conn.execute("ROLLBACK")
        raise


def _burn(conn, cust, txn, credits, ts):
    """Spend grants valid at usage time, soonest expiry first. The rest is overage."""
    left = credits
    grants = conn.execute(
        "SELECT id, remaining FROM credit_grants WHERE customer_id=? AND remaining > 0"
        " AND (expires_at IS NULL OR expires_at > ?)"
        " ORDER BY expires_at IS NULL, expires_at, id", (cust, ts)).fetchall()
    sql = "INSERT INTO credit_ledger (customer_id, grant_id, transaction_id, delta, ts) VALUES (?,?,?,?,?)"
    for g in grants:
        take = min(left, g["remaining"])
        if take <= 0:
            break
        conn.execute("UPDATE credit_grants SET remaining = remaining - ? WHERE id=?",
                     (take, g["id"]))
        conn.execute(sql, (cust, g["id"], txn, -take, ts))
        left -= take
    if left > 1e-9:
        conn.execute(sql, (cust, None, txn, -left, ts))


def month_bounds(year, month):
    lo = datetime(year, month, 1, tzinfo=timezone.utc)
    hi = datetime(year + (month == 12), month % 12 + 1, 1, tzinfo=timezone.utc)
    return int(lo.timestamp()), int(hi.timestamp())


def allowed(conn, cust, credits_needed, now=None):
    """Call before the agent runs. This is the hard spend cap."""
    d = datetime.fromtimestamp(now if now is not None else time.time(), timezone.utc)
    row = conn.execute("SELECT monthly_cap_credits FROM customers WHERE id=?", (cust,)).fetchone()
    if row is None:
        return False
    if row[0] is None:
        return True
    lo, hi = month_bounds(d.year, d.month)
    used = conn.execute("SELECT COALESCE(SUM(credits), 0) FROM usage_events"
                        " WHERE customer_id=? AND ts >= ? AND ts < ?", (cust, lo, hi)).fetchone()[0]
    return used + credits_needed <= row[0]


def statement(conn, cust, year, month):
    lo, hi = month_bounds(year, month)
    r = conn.execute(
        "SELECT COALESCE(-SUM(CASE WHEN grant_id IS NOT NULL THEN delta END), 0) AS grants,"
        " COALESCE(-SUM(CASE WHEN grant_id IS NULL THEN delta END), 0) AS overage"
        " FROM credit_ledger WHERE customer_id=? AND ts >= ? AND ts < ?", (cust, lo, hi)).fetchone()
    return {"credits_from_grants": round(r["grants"], 4),
            "overage_credits": round(r["overage"], 4),
            "overage_cents": round(r["overage"] * OVERAGE_CENTS_PER_CREDIT)}

Three choices matter more than the syntax. The transaction_id primary key with INSERT OR IGNORE makes ingestion idempotent forever, not for 24 hours. The credit price is frozen into each event row. The ledger stores usage time, so a late event lands in its own month.

The tests: test_meter.py

from datetime import datetime, timezone
import meter

NOW = int(datetime(2026, 10, 15, 12, 0, tzinfo=timezone.utc).timestamp())
DAY = 86400


def ev(txn, qty=1, etype="agent_execution", ts=NOW):
    return {"transaction_id": txn, "customer_id": "acme", "event_type": etype,
            "timestamp": ts, "quantity": qty}


def fresh():
    c = meter.connect()
    meter.add_customer(c, "acme", cap=500)
    return c


def test_duplicates_and_burn_order():
    c = fresh()
    meter.grant_credits(c, "acme", 15, expires_at=NOW + 5 * DAY, category="promotional")
    meter.grant_credits(c, "acme", 100)
    assert meter.ingest(c, ev("a"), NOW) == "accepted"
    assert meter.ingest(c, ev("a"), NOW) == "duplicate"
    meter.ingest(c, ev("b"), NOW)
    left = [r[0] for r in c.execute("SELECT remaining FROM credit_grants ORDER BY id")]
    assert left == [0, 95]                      # expiring promo grant burned first
    meter.ingest(c, ev("big", qty=12), NOW)     # 120 credits, 95 left
    s = meter.statement(c, "acme", 2026, 10)
    assert s["overage_credits"] == 25 and s["credits_from_grants"] == 115


def test_late_event_and_cap():
    c = fresh()
    meter.grant_credits(c, "acme", 50, expires_at=NOW - DAY)    # expired yesterday
    assert meter.ingest(c, ev("late", ts=NOW - 3 * DAY), NOW) == "accepted"
    assert c.execute("SELECT remaining FROM credit_grants").fetchone()[0] == 40
    assert meter.ingest(c, ev("old", ts=NOW - 36 * DAY), NOW) == "rejected:too_old"
    meter.ingest(c, ev("t", qty=480_000, etype="tokens_processed"), NOW)
    assert meter.allowed(c, "acme", 10, NOW) is True
    assert meter.allowed(c, "acme", 11, NOW) is False


if __name__ == "__main__":
    test_duplicates_and_burn_order()
    test_late_event_and_cap()
    print("ok")
python3 test_meter.py

The traffic generator: simulate.py

import os
import random
import time

import meter

random.seed(7)
now = int(time.time())
if os.path.exists("ledger.db"):
    os.remove("ledger.db")          # start clean so a re-run does not stack grants
db = meter.connect("ledger.db")
meter.add_customer(db, "acme", cap=2_000)
meter.add_customer(db, "globex")
meter.grant_credits(db, "acme", 500, expires_at=now + 10 * 86400, category="promotional")
meter.grant_credits(db, "acme", 1_000)

results = {}
for i in range(300):
    e = {"transaction_id": f"evt-{i}", "customer_id": random.choice(["acme", "globex"]),
         "timestamp": now - random.choice([0, 0, 0, 60, 3600, 3 * 86400])}
    if random.random() < 0.8:
        e.update(event_type="agent_execution", quantity=1)
    else:
        e.update(event_type="tokens_processed", quantity=random.randint(500, 40_000))
    for _ in range(random.choice([1, 1, 1, 2, 3])):          # client retries
        r = meter.ingest(db, e, now)
        results[r] = results.get(r, 0) + 1

print(results)
d = time.gmtime(now)
lo, hi = meter.month_bounds(d.tm_year, d.tm_mon)
for cust in ("acme", "globex"):
    total = db.execute("SELECT COALESCE(SUM(credits), 0) FROM usage_events"
                       " WHERE customer_id=? AND ts >= ? AND ts < ?", (cust, lo, hi)).fetchone()[0]
    s = meter.statement(db, cust, d.tm_year, d.tm_mon)
    print(cust, "total usage credits:", round(total, 4), s)

# The spend cap: refuse a run before it starts.
print("acme, 10 credit run allowed?", meter.allowed(db, "acme", 10, now))
print("acme, 1,000,000 credit run allowed?", meter.allowed(db, "acme", 1_000_000, now))
python3 simulate.py

Expect accepted: 300 and some duplicate, because the generator retries on purpose. In the first three days of a month some late events fall in the previous month, so a small statement then is not a bug.

Mirror usage into Stripe (optional)

Keep keys out of code. Export a test-mode key as STRIPE_API_KEY and run pip install stripe. This follows Stripe's current StripeClient style.

import os
import sys

from stripe import StripeClient

import meter

client = StripeClient(os.environ["STRIPE_API_KEY"])


def forward(conn, limit=500):
    rows = conn.execute(
        "SELECT e.transaction_id, e.ts, e.credits, c.stripe_customer_id FROM usage_events e"
        " JOIN customers c ON c.id = e.customer_id"
        " WHERE e.forwarded = 0 AND c.stripe_customer_id IS NOT NULL"
        " ORDER BY e.ts LIMIT ?", (limit,)).fetchall()
    for r in rows:
        client.v1.billing.meter_events.create({
            "event_name": "agent_credits",
            "identifier": r["transaction_id"],
            "timestamp": r["ts"],
            "payload": {"stripe_customer_id": r["stripe_customer_id"],
                        "value": str(round(r["credits"], 6))},
        })
        conn.execute("UPDATE usage_events SET forwarded = 1 WHERE transaction_id = ?",
                     (r["transaction_id"],))
    return len(rows)


if __name__ == "__main__":
    args = sys.argv[1:]
    conn = meter.connect(os.environ.get("LEDGER_DB", "ledger.db"))
    if len(args) == 3 and args[0] == "set-customer":
        # python3 stripe_sync.py set-customer acme cus_XXXX
        conn.execute("UPDATE customers SET stripe_customer_id = ? WHERE id = ?",
                     (args[2], args[1]))
    else:
        print("forwarded", forward(conn))

Stripe's identifier is your ledger's transaction_id, so a crash between the send and the forwarded update is safe to retry inside Stripe's dedupe window of at least 24 hours. After a long outage, check the age of unsent events first, because Stripe rejects timestamps older than 35 days. In the Dashboard, create a test customer, a product with a metered price tied to the meter, and a subscription for that customer. Then run set-customer with the customer ID, and add a billing alert.

The no-budget path

Everything above runs on a laptop with the Python standard library. A Stripe test-mode key costs nothing. To put a billing engine in the loop, run Lago (AGPLv3) with Docker Compose and send events to POST /api/v1/events, whose body needs transaction_id, external_subscription_id and code. Or self-host OpenMeter (Apache 2.0). Both share your ledger's design, so you can port it.

What done looks like

  • The tests pass, and the simulation prints two statements that reconcile.
  • Sending one event three times changes nothing after the first.
  • A late event lands in its own month and burns a grant that was valid at usage time.
  • A run that would pass the cap is refused before it starts.
  • The pricing note fits on one page.

Stretch goal: add tiers (the first 1,000 runs at 10 credits, the rest at 8) and an invoice preview before month end. Then add a model dimension, after you read Stripe's limits on dimension combinations.

Common mistakes

  • Choosing the metric from your cost instead of the customer's value. Tokens are your cost. Customers care about work done, so show what a typical task costs.
  • Trusting the vendor's dedupe window as your only idempotency. Stripe remembers an identifier for at least 24 hours, not forever. Keep a permanent key in your own ledger.
  • Billing by arrival time. A late event belongs to the month it describes. Store usage time and arrival time separately.
  • Treating alerts as caps. An alert sends a message. A cap stops the work.
  • Rewriting history when prices change. Freeze the credit price on each event, or invoices stop matching what customers saw.

Check yourself

1. A company sells an AI support agent. Which change best moves it from access pricing toward value-aligned pricing?

2. Your service receives the same event twice, 30 hours apart, after a worker retried following an outage. Which design counts it once?

3. A customer buys a $1,000 prepaid credit pack in October and uses $100 of it. What does a basic ASC 606 view say about October revenue from the pack?

4. A usage-priced customer is shocked by a large invoice. Which control would have prevented the overspend, not just warned about it?

5. Fin counts a resolution when "no further help is requested after the last AI answer". What risk does that create?

How to learn this faster

Learn first: the four-question value metric test, idempotency keys and the five-step pipeline. They transfer to every vendor. Then learn one tool deeply. Stripe Billing Meters is a good first choice because its docs are clear and a sandbox is free. Skip tax, multi-currency, marketplace billing and vendor feature matrices for now. You can learn them on the job.

Deliberate-practice drill: take one public pricing page (OpenAI, Snowflake, Intercom or Salesforce). In 15 minutes write the value metric, the unit price, a typical monthly bill, and one way that customer could be surprised.

  • Day 1: Read the Stripe usage-based overview and recording-usage page. Write the pipeline in your own words.
  • Day 2: Type in meter.py and the tests. Run them, then break one test on purpose and watch it fail.
  • Day 3: Run simulate.py, reconcile both statements and add the hourly SQL roll-up.
  • Day 4: Create a Stripe test meter, forward events, view usage in the Dashboard and set one billing alert.
  • Day 5: Write the one-page pricing note. Then decide in writing how much of the roadmap's "Why" you believe.

Resources

Next module

Stage 6 covers Revenue Operations (RevOps) and Attribution. Usage data shows who is growing, so the next question is which campaigns, reps and product signals earned that growth.

Built the project and passed the quiz? Mark the stage done. Your progress stays in this browser only.

Talk to Claude instead of typing to it.

Ducktate turns what you say into clean text, on your Mac, and pastes it anywhere.

Get early access