docs / guides
Profit per customer
Send revenue per end user and see which customers lose money after AI costs.
noburn already knows what each of your end users costs you in AI spend, as long as you pass endUserId / end_user_id to check() and record(). Send what each end user pays you too, and the Customers page of your project shows revenue, AI cost and margin per customer for any month. The customers losing the most money are listed first.
noburn stores only amounts, timestamps and ids. It never sees prompt or completion text.
How margin is calculated
For each end user, over one calendar month (UTC):
| Field | Meaning |
|---|---|
| Revenue | Sum of every revenue amount you sent. Refunds count as negative amounts. |
| AI cost | Sum of cost_usd for non-blocked calls recorded with that end user id. |
| Margin $ | Revenue minus AI cost. |
| Margin % | Margin divided by revenue. Shown as n/a when revenue is zero or negative, because a percentage of that is meaningless. |
A customer with AI usage but no revenue (a free user, or someone whose payment you have not sent yet) still appears, with a negative margin. A customer who paid but made no AI calls also appears, at 100% margin.
Use the same end user id for revenue as you use for check() and record(). If your AI calls use your internal user id, map the Stripe customer to that id before you send revenue, as in the webhook examples below.
Sending revenue
await guard.recordRevenue({
endUserId: 'user_abc123',
amountUsd: 49.0, // negative for refunds
occurredAt: '2026-10-01T09:30:00Z', // optional, defaults to now
externalId: 'in_1PqXyZ', // optional idempotency key
});guard.record_revenue(
end_user_id="user_abc123",
amount_usd=49.0, # negative for refunds
occurred_at="2026-10-01T09:30:00Z", # optional, defaults to now
external_id="in_1PqXyZ", # optional idempotency key
)recordRevenue() / record_revenue() works like record(). It returns at once and the SDK delivers the revenue in the background, retrying on network errors. It never throws, with one exception: once the server has rejected a delivery because the SDK key is invalid, the next call throws NoburnAuthError. In Node it is thrown synchronously, at the call, so void guard.recordRevenue(...) cannot cause an unhandled promise rejection. These are dropped and counted in droppedEventCount / dropped_event_count:
- invalid input, such as an empty end user id or an amount that is not a finite number;
- a revenue event the server rejects with a
4xx.
Call flush() before a short-lived process exits. Pass a time limit if you don't want to wait indefinitely: await guard.flush({ timeoutMs: 5000 }) in Node, guard.flush(timeout=5.0) in Python. Either returns without throwing when the time is up, even if events are still queued.
Idempotency
Always pass externalId / external_id when you have one: a Stripe invoice id, a refund id, your own payment id. If noburn has already recorded that id for the project, the request returns 200 with "duplicate": true and nothing is added. Webhook retries and SDK retries then never double-count revenue.
Currency
Amounts are in US dollars. noburn does not convert currencies. If you charge in another currency, convert to USD yourself before sending, using whatever exchange rate you book revenue at. Watch out for Stripe amounts: they are in the currency's smallest unit. That is cents for USD, but zero-decimal currencies such as JPY or KRW have no subunit, so dividing by 100 would be wrong for them. The Stripe examples below record USD invoices only and skip anything else.
End user ids must match exactly
end_user_id is stored exactly as sent, the same as for AI calls. Revenue joins to AI cost only when the id is identical to the value you pass to check() / record(), including case and any leading or trailing whitespace. "alice" and "alice " count as two different customers. Empty or whitespace-only ids are rejected.
Stripe: record revenue on invoice.paid
Stripe sends invoice.paid each time a subscription or one-off invoice is paid. The handler below:
- verifies the webhook signature,
- skips invoices that are not in USD (see Currency),
- maps the Stripe customer to your end user id (here from
metadata.app_user_idon the Stripe customer, which you set when you create the customer), - calls
recordRevenuewith the invoice id asexternalId, so Stripe retries are safe.
It also handles refunds through charge.refunded (see Refunds).
import express from 'express';
import Stripe from 'stripe';
import { NoburnGuard } from '@noburn/sdk';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const guard = new NoburnGuard({
apiKey: process.env.NOBURN_API_KEY!,
projectId: process.env.NOBURN_PROJECT_ID!,
});
/** Map a Stripe customer to the id you pass to check()/record(). */
async function endUserIdFor(
customer: string | Stripe.Customer | Stripe.DeletedCustomer | null,
): Promise<string | undefined> {
if (!customer) return undefined;
// Replace with a lookup in your own database if you store the mapping there.
const c = typeof customer === 'string' ? await stripe.customers.retrieve(customer) : customer;
return c.deleted ? undefined : c.metadata.app_user_id;
}
const app = express();
// Stripe needs the raw body to verify the signature.
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'] as string,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).send('Invalid signature');
}
try {
if (event.type === 'invoice.paid') {
const invoice = event.data.object;
// Amounts are in the currency's smallest unit; only USD is cents → dollars.
if (invoice.currency === 'usd' && invoice.amount_paid > 0) {
const endUserId = await endUserIdFor(invoice.customer);
if (endUserId) {
await guard.recordRevenue({
endUserId,
amountUsd: invoice.amount_paid / 100,
occurredAt: new Date(event.created * 1000).toISOString(),
externalId: invoice.id, // idempotent across Stripe retries
});
}
}
}
if (event.type === 'charge.refunded') {
// The event carries a Charge. List its refunds and record each one as a
// negative amount; refund ids make re-deliveries and repeat refunds safe.
const charge = event.data.object;
if (charge.currency === 'usd') {
const endUserId = await endUserIdFor(charge.customer);
if (endUserId) {
const refunds = await stripe.refunds.list({ charge: charge.id, limit: 100 });
for (const refund of refunds.data) {
if (refund.status !== 'succeeded') continue;
await guard.recordRevenue({
endUserId,
amountUsd: -(refund.amount / 100),
occurredAt: new Date(refund.created * 1000).toISOString(),
externalId: refund.id,
});
}
}
}
}
} catch (err) {
// Only NoburnAuthError (bad SDK key) or a Stripe API error can land here.
// Answer 500 so Stripe retries after you fix it; external ids prevent double-counting.
console.error('stripe webhook: failed to record revenue', err);
return res.status(500).send('Failed to record revenue');
}
res.json({ received: true });
});import asyncio
import os
from datetime import datetime, timezone
from typing import Optional
import stripe
from fastapi import FastAPI, HTTPException, Request
from noburn import NoburnAuthError, NoburnGuard
stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
guard = NoburnGuard(
api_key=os.environ["NOBURN_API_KEY"],
project_id=os.environ["NOBURN_PROJECT_ID"],
)
app = FastAPI()
def iso(ts: int) -> str:
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
def end_user_id_for(customer_id: Optional[str]) -> Optional[str]:
"""Map a Stripe customer to the id you pass to check()/record().
Blocking network call: run it with asyncio.to_thread from async code.
Replace with a lookup in your own database if you store the mapping there.
"""
if not customer_id:
return None
customer = stripe.Customer.retrieve(customer_id)
if getattr(customer, "deleted", False):
return None
return getattr(customer.metadata, "app_user_id", None)
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
payload = await request.body() # raw body, needed for the signature check
try:
event = stripe.Webhook.construct_event(
payload,
request.headers.get("stripe-signature"),
os.environ["STRIPE_WEBHOOK_SECRET"],
)
except (ValueError, stripe.SignatureVerificationError):
raise HTTPException(status_code=400, detail="Invalid signature")
try:
if event.type == "invoice.paid":
invoice = event.data.object
# Amounts are in the currency's smallest unit; only USD is cents → dollars.
if invoice.currency == "usd" and invoice.amount_paid > 0:
end_user_id = await asyncio.to_thread(end_user_id_for, invoice.customer)
if end_user_id:
guard.record_revenue( # non-blocking: queued for the background worker
end_user_id=end_user_id,
amount_usd=invoice.amount_paid / 100,
occurred_at=iso(event.created),
external_id=invoice.id, # idempotent across Stripe retries
)
elif event.type == "charge.refunded":
# The event carries a Charge. List its refunds and record each one
# as a negative amount; refund ids make re-deliveries safe.
charge = event.data.object
if charge.currency == "usd":
end_user_id = await asyncio.to_thread(end_user_id_for, charge.customer)
if end_user_id:
refunds = await asyncio.to_thread(
stripe.Refund.list, charge=charge.id, limit=100
)
for refund in refunds.data:
if refund.status != "succeeded":
continue
guard.record_revenue(
end_user_id=end_user_id,
amount_usd=-(refund.amount / 100),
occurred_at=iso(refund.created),
external_id=refund.id,
)
except (NoburnAuthError, stripe.StripeError):
# Answer 500 so Stripe retries after you fix it; external ids prevent
# double-counting.
raise HTTPException(status_code=500, detail="Failed to record revenue")
return {"received": True}Refunds
A refund is a negative amount with the refund id as the external id. charge.refunded delivers the Charge, not the refund, and recent Stripe API versions don't embed the refund list in it. That is why the examples call refunds.list for the charge. Re-recording refunds that were already sent is harmless, because their ids are idempotency keys. If you subscribe to refund.created instead, the event object is the Refund. Its charge field is an id, so retrieve the charge to find the customer.
REST API
The SDK methods call POST /api/v1/revenue. You can call it directly with your SDK key.
curl -X POST https://noburn.dev/api/v1/revenue \
-H "Authorization: Bearer sk-nb-xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"project_id": "your-project-id",
"end_user_id": "user_abc123",
"amount_usd": 49.0,
"external_id": "in_1PqXyZ"
}'| Field | Type | Required | Description |
|---|---|---|---|
project_id | string | ✓ | Must match the project of the SDK key |
end_user_id | string | ✓ | 1–255 characters, exactly the id you pass to check() / record() (not trimmed) |
amount_usd | number | ✓ | Finite number. Negative for refunds |
occurred_at | string | — | ISO 8601 timestamp. Defaults to now. May be in the past, not in the future |
external_id | string | — | Idempotency key, 1–255 characters |
source | string | — | Short label such as stripe. Defaults to api |
| Status | Meaning |
|---|---|
201 | Recorded. Body: { "ok": true, "duplicate": false, "id": "..." } |
200 | external_id already recorded, nothing added. Body: { "ok": true, "duplicate": true, "external_id": "..." } |
400 | Invalid body |
401 | Missing or invalid SDK key |
403 | project_id does not match the SDK key |