Short answer: for metered logistics invoices, use scoped API keys as cost centres for externally billed calls, use self-reported usage only for events the provider cannot observe, and reconcile both into an immutable customer ledger before invoicing. Attribution accuracy beats collection convenience. A key tells you who was authorized to spend; an event tells you what the customer consumed. Neither alone proves the whole bill.
Start with the decision table. The right pick depends on where the billable action becomes observable and whether one request can serve more than one customer.
| Metering approach | Pick this when | Attribution risk | Control to require |
|---|---|---|---|
| Key as cost centre | One credential maps cleanly to one team or customer billing scope | Shared or stale keys smear spend across accounts | Key registry, rotation history, and rejected-call counters |
| Self-reported usage | The billable event occurs inside your application and is invisible upstream | Retries, dropped events, and mutable quantities distort the invoice | Idempotency key, append-only events, and reconciliation |
| Gateway allocation | Requests cross one controlled ingress but credentials cannot be split further | A bad tenant tag contaminates every downstream total | Auth-derived tags and an unattributed quarantine |
| Hybrid ledger | External API spend and internal business events both matter | Two clocks and two identifiers create gaps or duplicates | Stable customer ID, event time, ingestion time, and daily balancing |
The practical default is the hybrid ledger. Keep the evidence streams separate, then join them with explicit rules. Don't let a dashboard query become the billing algorithm.
How should SaaS teams audit API cost attribution and self-reported usage?
Treat attribution as a chain of evidence, not a label added at the end of the month. In a logistics system, a customer may request a shipment quote, purchase a label, poll tracking, and receive status updates. Some actions create upstream API cost. Others represent billable application usage. A single HTTP request can even trigger several retries or serve a batch containing parcels from different customers. The meter therefore needs to preserve both the technical actor and the commercial subject.
Draw it in words: credential enters the gateway; gateway resolves an internal principal; principal maps to a team and billing account for a bounded time; the application performs work; each billable result emits a usage event; a reconciler compares provider cost records, gateway request records, and application events; only accepted ledger rows reach invoice aggregation. Each arrow needs an identifier and an owner.
Keep at least four IDs distinct: request_id follows an execution attempt, operation_id groups retries of one intended action, customer_id names the billed account, and credential_id names the authorization material without storing the secret itself. This distinction matters. If a label purchase times out at the caller and is retried, two request IDs may still belong to one operation. If a nightly tracking batch contains 600 parcels for 18 customers, one credential and one request are not enough to allocate usage fairly.
Missing attribution should be visible.
Send records with no resolvable customer to an unattributed bucket and stop them from entering an invoice. Alert on its rate and oldest age. A quiet fallback such as customer_id = "unknown" looks harmless in a log, but it converts an instrumentation defect into somebody else's charge. The same rule applies when a key changes owners: retain effective-from and effective-to timestamps instead of rewriting the registry row.
This is also where secret handling and billing controls meet. Keep raw credentials out of logs and ledger rows, centralize their lifecycle, and make rotation observable. The OWASP Secrets Management Cheat Sheet describes lifecycle concerns including creation, rotation, revocation, and expiration; the billing system should retain the credential's stable internal identifier and validity interval, not the credential value.
Pick keys when authorization scope matches the payer
A key is a strong cost-centre signal when it has one accountable owner, one permitted workload, and a time-bounded mapping to the billed entity. It works especially well for a logistics team that can issue separate credentials for production label buying, sandbox quoting, and internal operations. Provider-side request totals can then be reconciled with gateway totals by credential ID and time window.
The catch is cardinality and ownership. Keys are not suitable as the only meter when a shared background worker performs work for many customers, when one customer spans several teams, or when a single request contains mixed-customer items. In those cases, keep the key as evidence of who authorized the spend, then allocate the work with authenticated customer context or item-level events. Never accept a caller-supplied team header as billing truth unless the authenticated principal is allowed to assert that exact value.
Rotation deserves special attention. Suppose key_17 belongs to the dispatch team until 14:00 UTC and key_42 replaces it. A mutable lookup that says only "dispatch uses key_42" loses the explanation for the morning's spend. An effective-dated registry preserves it. Revocation events should also reach the meter quickly; otherwise, valid-looking records can outlive the ownership decision that made them billable.
Keys are clean. Reality isn't.
Pick reported events when the application owns the billable fact
Self-reported usage is appropriate when the charge depends on a business outcome that an upstream API cannot know: a label successfully purchased, a parcel accepted into a manifest, or a tracking subscription active for a billing interval. Emit the event after the authoritative state transition, not when a button is clicked and not merely when work enters a queue.
An event needs a stable identity. Producers will retry. Consumers will replay. Networks will reorder. The ledger should accept an event ID once, retain the original quantity and dimensions, and reject a second payload that reuses the ID with different billing data. Make that rejection a domain result such as IDEMPOTENCY_CONFLICT; it is easier to count and alert on than a generic exception.
I'm not sure a single lateness window fits every logistics workflow. Carrier callbacks can arrive on a different schedule from synchronous label purchases, and the correct cutoff depends on the invoice policy promised to customers. Write that policy down: which event timestamp chooses the billing period, how late corrections appear, and who approves a closed-period adjustment. Then test the boundary on both sides of midnight and month end.
Self-reporting is a poor fit when clients can choose their own quantity, account, or price dimension without server verification. Stick with gateway or provider evidence for facts observed there. For application-owned facts, derive dimensions from stored state and authenticated context. The event producer may report that operation op_8f2 completed; the billing service should resolve the customer and plan from authoritative records.
Build one ledger that preserves both kinds of evidence
The implementation below stays deliberately local: no invented vendor route and no SDK dependency. It accepts a normalized usage event, checks required attribution, makes duplicate delivery harmless, and preserves conflicts for investigation. In production, the Map operations become a transaction with a unique constraint on eventId; the example focuses on the contract that every storage implementation must keep.
type Source = "provider" | "gateway" | "application";
type UsageEvent = {
eventId: string;
operationId: string;
customerId: string;
teamId: string;
credentialId?: string;
source: Source;
meter: "label_purchase" | "tracking_update" | "api_call";
quantity: number;
occurredAt: string;
ingestedAt: string;
};
type IngestResult =
| { status: "accepted" }
| { status: "duplicate" }
| { status: "rejected"; code: "ATTRIBUTION_MISSING" | "INVALID_QUANTITY" | "IDEMPOTENCY_CONFLICT" };
const ledger = new Map<string, UsageEvent>();
function sameBillingFact(a: UsageEvent, b: UsageEvent): boolean {
return a.operationId === b.operationId
&& a.customerId === b.customerId
&& a.teamId === b.teamId
&& a.source === b.source
&& a.meter === b.meter
&& a.quantity === b.quantity
&& a.occurredAt === b.occurredAt;
}
function ingestUsage(event: UsageEvent): IngestResult {
if (!event.customerId || !event.teamId || !event.operationId) {
return { status: "rejected", code: "ATTRIBUTION_MISSING" };
}
if (!Number.isFinite(event.quantity) || event.quantity <= 0) {
return { status: "rejected", code: "INVALID_QUANTITY" };
}
const existing = ledger.get(event.eventId);
if (existing) {
return sameBillingFact(existing, event)
? { status: "duplicate" }
: { status: "rejected", code: "IDEMPOTENCY_CONFLICT" };
}
ledger.set(event.eventId, Object.freeze({ ...event }));
return { status: "accepted" };
}
Now add observability around the boundary. Count accepted, duplicate, and rejected events by source and reason, but avoid putting raw customer IDs or credential IDs into metric labels; those dimensions can produce unbounded series and expose sensitive business identifiers. Put the identifiers in access-controlled logs or traces, then link dashboards to a narrowly scoped investigation view. Alert on ratios and backlog age: rejected attribution divided by total ingestion, idempotency conflicts, records awaiting provider reconciliation, and the oldest unprocessed event.
Reconciliation should be a repeatable job with a durable result, not a spreadsheet ritual. For each closed window, compare three aggregates: provider cost by credential, gateway activity by operation and customer, and application usage by billable meter. Exact one-to-one matching won't always exist, so encode tolerances and allocation rules as versioned policy. A batch request might allocate a fixed request cost evenly, by parcel count, or by measured sub-operation; the defensible choice is the one stated in the customer contract and reproduced from stored inputs.
Use a small fixture before deployment. Customer cust_north buys two labels in operation op_8f2; the application retries the event once with the same event ID; the gateway records three request attempts; the provider reports three calls against the dispatch credential. The expected ledger contains one label event with quantity two, marks the replay as a duplicate, retains all three technical attempts for cost reconciliation, and never turns three attempts into three customer purchases. Then corrupt the second replay's quantity. It must become IDEMPOTENCY_CONFLICT, not an update.
That's the useful before-and-after: before, the invoice total is a query over whichever logs survived; after, every amount points to an immutable event, an attribution decision, and a policy version. Support can explain a line item. Finance can rerun a period. Engineering can see drift before an invoice leaves.
Limits to settle before invoicing
No metering design repairs an ambiguous commercial rule. Decide who pays for retries, failed calls, shared batches, delayed corrections, free internal traffic, and work triggered after a customer cancels. Decide whether team ownership or customer ownership wins when they disagree. Put those decisions in test fixtures and version them beside the reconciler.
The hybrid model costs more to operate than a key-only rollup. It is not suitable when every request already has a dedicated customer credential and the provider's billing unit exactly matches the customer charge; in that narrow case, use the simpler key ledger and audit the ownership timeline. Self-reported events alone are not suitable when an external provider's spend is the amount being allocated; retain provider or gateway evidence and reconcile it.
Watch the exceptions. They tell you more than the happy-path total.












