UsageTap Meter implementation contract
This page is the canonical starting context for a coding agent. The first integration is Meter only. Preserve the application's provider, model, request shape, streaming behavior, and error behavior. Gateway, Compress, sampling, deployments, and Stripe synchronization require a separate explicit request.
One-prompt Meter setup
Implement Meter from this contract
Paste the prompt into a coding agent with repository access. It should inspect the application, implement the smallest safe integration, test it, and leave an exact setup handoff.
Open the canonical agent referenceImplement UsageTap Meter in this repository.
Read https://usagetap.com/llmreference first and treat it as the canonical contract. The initial goal is Meter only. Do not add UsageTap Gateway, UsageTap Compress, prompt compression, provider migration, model changes, or Stripe synchronization unless I explicitly ask.
Work autonomously:
1. Inspect the repository to find every server-side AI/provider call, the framework/runtime, existing provider SDKs, streaming behavior, tests, environment-variable conventions, and the trusted authenticated customer or tenant ID.
2. Choose the smallest safe integration. Prefer @usagetap/sdk for supported server-side JavaScript/TypeScript provider clients. Use the explicit SDK lifecycle when BLOCK or DOWNGRADE must control provider invocation or model selection; provider wrappers do not invent a safe fallback model. Use direct HTTPS for other languages or when the SDK is not a safe fit. Preserve the existing provider, model, request/response shape, and error behavior.
3. Ask one concise clarification only if a stable server-trusted customerId cannot be inferred or there are multiple materially different integration targets. Otherwise proceed.
4. Implement the complete Meter lifecycle. Attribute every call to a stable customerId and feature; begin before provider work; inspect data.allowed before invoking the provider; do not end a denied call because call_begin already finalized it; finalize an allowed call exactly once on success, error, stream completion, cancellation, or abort; report the actual model and usage returned by the provider; sanitize error metadata; and use one unique idempotency key per logical call, reused only for its retries.
5. Keep all credentials server-side. Never read, print, log, or commit secret values. Add placeholders only to the project's example environment file. UsageTap Meter needs USAGETAP_API_KEY (create a utk-… key with usage:write at https://usagetap.com/configure#api-keys). Keep the app's existing provider key such as OPENAI_API_KEY, ANTHROPIC_API_KEY, or OPENROUTER_API_KEY.
6. Add or update focused tests and run the relevant test, lint, and type/build checks. If credentials are unavailable, finish and verify the code without attempting a live provider request.
When done, report:
- what you changed and which calls are now metered;
- the exact environment-variable names the user must set, without values;
- any one remaining user decision or action;
- the command or request that produces the first metered call;
- how to verify it at https://usagetap.com/embed-api?features=meter;
- the next Meter step: define customer plans, allowances, and limits at https://usagetap.com/quickstart.
Do not stop at a plan or code sample. Make the changes when it is safe to do so.1. Required agent behavior
- Find every server-side provider call, including streaming, background jobs, retries, tool loops, and alternate routes.
- Identify a stable, server-trusted customer or tenant ID. Never trust a browser-supplied customer ID without checking it against the authenticated session.
- Prefer the provider wrapper for the smallest JavaScript or TypeScript change. Use the explicit SDK lifecycle when the app must enforce BLOCK or DOWNGRADE itself. Use direct HTTPS outside the supported server-side JavaScript runtime.
- Begin before provider work and finalize once after success, provider error, stream completion, cancellation, or abort. Report the model and usage returned by the provider.
- Preserve the original provider error if UsageTap finalization also fails. Make the finalization failure observable without replacing the primary error.
- Add focused tests and run the repository's relevant test, lint, type, and build checks.
2. Credentials and transport
UsageTap key
Create a Meter call key in Configure. Use a utk- key with usage:write. Store it as USAGETAP_API_KEY on the server. Meter endpoints now require the key's calls capability; admin, embed, gateway, and compression-only credentials are not interchangeable. Wrong-capability keys return CALLS_ACCESS_REQUIRED.
Provider key
Keep the provider key the app already uses, such as OPENAI_API_KEY, ANTHROPIC_API_KEY, or OPENROUTER_API_KEY. UsageTap does not replace it for a Meter integration.
- Never expose either key to browser code, logs, commits, or agent output.
- API authentication accepts
Authorization: Bearer $USAGETAP_API_KEYorx-api-key. - Every API request must send
Accept: application/vnd.usagetap.v1+json. Missing or incompatible Accept headers return HTTP 406. - JSON POST requests should send
Content-Type: application/json. Responses use the versioned UsageTap media type and a standard result envelope.
3. Choose the integration path
Provider wrapper
Server-side JavaScript or TypeScript using OpenAI, OpenRouter, or Anthropic. Best for the first metered call and automatic success, error, and stream finalization.
Explicit SDK lifecycle
Use UsageTapClient.withUsage(), beginCall(), and endCall() when the app must inspect entitlements, select an approved fallback, attach custom usage, or coordinate complex streaming.
Direct HTTPS
Use for Python, Go, Java, other runtimes, unsupported providers, or when wrapping the existing client would change behavior.
UsageTapClient in browser code. The React package is an application helper, not permission to ship a Meter key to the browser.4. JavaScript and TypeScript SDK
Install @usagetap/sdk plus the provider package already used by the application. Import OpenAI helpers from @usagetap/sdk/openai, OpenRouter helpers from @usagetap/sdk/openrouter, and Anthropic helpers from @usagetap/sdk/anthropic.
import OpenAI from "openai";
import { withMetering } from "@usagetap/sdk/openai";
const provider = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export function meteredOpenAI(customerId: string) {
return withMetering(provider, {
apiKey: process.env.USAGETAP_API_KEY,
customerId,
feature: "chat.reply",
});
}
// The OpenAI client shape is preserved.
const response = await meteredOpenAI(currentCustomer.id).responses.create({
model: existingModel,
input,
});The wrapper preserves chat.completions.create() and responses.create(), generates unique idempotency keys, retries eligible UsageTap requests, extracts provider usage, and finalizes consumed or cancelled streams. It does not know the application's safe model fallback. Use the explicit path when BLOCK or DOWNGRADE must control provider selection.
import OpenAI from "openai";
import { UsageTapClient } from "@usagetap/sdk";
const usageTap = new UsageTapClient();
const openai = new OpenAI();
const response = await usageTap.withUsage(
{
customerId: currentCustomer.id,
feature: "chat.reply",
requested: { premium: true },
},
async ({ begin, setUsage }) => {
// A blocked call is already finalized by call_begin.
// If DOWNGRADE is configured, select a known safe fallback here.
if (!begin.data.allowed.premium) {
throw new Error("Premium usage is not allowed for this customer");
}
const result = await openai.responses.create({
model: existingModel,
input,
});
setUsage({
modelUsed: result.model ?? existingModel,
inputTokens: result.usage?.input_tokens ?? 0,
responseTokens: result.usage?.output_tokens ?? 0,
cachedInputTokens:
result.usage?.input_tokens_details?.cached_tokens ?? 0,
});
return result;
},
);Supported SDK surface
Root client: UsageTapClient and alias UsageTap; lifecycle methods beginCall, endCall, withUsage, meter, checkUsage, createCustomer, changePlan, and incrementCustomMeter. Provider adapters include withMetering, explicit wrappers, OpenAI route streaming helpers, Express middleware, and OpenAI-compatible OpenRouter exports.
5. Direct HTTPS lifecycle
const apiKey = process.env.USAGETAP_API_KEY;
const apiBase = process.env.USAGETAP_BASE_URL ?? "https://api.usagetap.com";
async function usageTapPost(path, body) {
const response = await fetch(`${apiBase}/${path}`, {
method: "POST",
headers: {
Accept: "application/vnd.usagetap.v1+json",
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
const envelope = await response.json();
if (!response.ok || envelope.result?.status !== "ACCEPTED") {
throw new Error(
`UsageTap ${path} failed: ${envelope.result?.code ?? response.status}`,
);
}
return envelope;
}
const idempotencyKey = crypto.randomUUID();
const begin = await usageTapPost("call_begin", {
customerId: currentCustomer.id,
feature: "chat.reply",
requested: { premium: true },
idempotencyKey,
});
// call_begin returns HTTP 200 for entitlement denials and has already
// finalized the denied call. Do not invoke the provider or call call_end.
if (!begin.data.allowed.premium) {
throw new Error("Premium usage is not allowed for this customer");
}
let providerResponse;
try {
providerResponse = await openai.responses.create({
model: existingModel,
input,
});
} catch (providerError) {
try {
await usageTapPost("call_end", {
callId: begin.data.callId,
error: {
code: "PROVIDER_ERROR",
message: "Provider request failed",
},
});
} catch {
// Preserve the original provider error. Report finalization separately.
}
throw providerError;
}
await usageTapPost("call_end", {
callId: begin.data.callId,
modelUsed: providerResponse.model ?? existingModel,
inputTokens: providerResponse.usage?.input_tokens ?? 0,
responseTokens: providerResponse.usage?.output_tokens ?? 0,
cachedInputTokens:
providerResponse.usage?.input_tokens_details?.cached_tokens ?? 0,
});6. POST /call_begin
| Field | Required | Contract |
|---|---|---|
| customerId | Yes | Stable server-trusted customer or tenant identifier. Maximum 100 sanitized characters. |
| feature | Recommended | Stable application operation such as chat.reply or document.summarize. |
| tags | No | Low-cardinality labels. Do not include secrets or raw prompts. |
| requested | Recommended | Entitlements the provider call needs: standard, premium, audio, image, search, and reasoningLevel. |
| idempotencyKey | Direct API: yes | Unique per logical call, stable across its retries. The SDK supplies one by default. |
| customerName / customerEmail | No | Optional profile data used when the customer is first provisioned. Avoid sending data the product does not need. |
| batch / pricingMode | No | Batch pricing metadata. pricingMode, when present, is authoritative. |
{
"customerId": "tenant_123",
"feature": "chat.reply",
"tags": ["production"],
"requested": {
"standard": true,
"premium": false,
"audio": false,
"image": false,
"search": false,
"reasoningLevel": "NONE"
},
"idempotencyKey": "one-key-per-logical-call"
}{
"result": {
"status": "ACCEPTED",
"code": "CALL_BEGIN_SUCCESS",
"timestamp": "ISO-8601"
},
"correlationId": "request-correlation-id",
"data": {
"callId": "call-id",
"startTime": "ISO-8601",
"newCustomer": false,
"canceled": false,
"policy": "NONE | BLOCK | DOWNGRADE",
"allowed": {
"standard": true,
"premium": false,
"audio": false,
"image": false,
"search": false,
"reasoningLevel": "NONE | LOW | MEDIUM | HIGH"
},
"entitlementHints": { "...": "advisory details" },
"rateLimits": { "...": "optional rolling-call state" },
"meters": { "...": "remaining, used, limit, unlimited, ratio" },
"remainingRatios": { "...": "0 through 1, or null" },
"subscription": { "...": "current subscription snapshot" },
"plan": { "id": null, "name": null, "version": null },
"balances": { "...": "optional remaining balances" },
"idempotency": {
"key": "one-key-per-logical-call",
"source": "explicit | derived"
}
}
}- A blocked entitlement still returns an accepted HTTP 200 envelope with
allowedset accordingly. The denied call record is already finalized; do not invoke the provider and do not call/call_end. - For DOWNGRADE, choose only a fallback model already approved by the application or user. Never invent a provider or model mapping.
- Treat
entitlementHints,models, and rate-limit snapshots as guidance. Theallowedfields are the decision inputs.
7. POST /call_end
| Field | Required | Contract |
|---|---|---|
| callId | Yes | The exact callId returned by call_begin. |
| modelUsed | Success | Use the provider-returned model when available, otherwise the actual requested model. |
| inputTokens | When available | Total provider input tokens, including cache reads and cache writes. |
| cachedInputTokens | When available | Input tokens read from provider cache. Do not subtract them from inputTokens. |
| cacheWriteInputTokens | When available | Input tokens written to provider cache. They remain included in inputTokens. |
| responseTokens | When available | Provider output or completion tokens. |
| reasoningTokens / searches / audioSeconds | When available | Send actual provider usage only. Omit unavailable metrics rather than estimating them silently. |
| error | Provider failure | A sanitized object with non-empty code and message. Do not include prompts, credentials, or raw provider bodies. |
{
"callId": "call-id",
"modelUsed": "provider-returned-model",
"inputTokens": 1200,
"cachedInputTokens": 400,
"cacheWriteInputTokens": 0,
"responseTokens": 180,
"reasoningTokens": 0,
"searches": 0,
"audioSeconds": 0
}{
"callId": "call-id",
"error": {
"code": "PROVIDER_ERROR",
"message": "Provider request failed"
}
}{
"result": {
"status": "ACCEPTED",
"code": "CALL_END_SUCCESS",
"timestamp": "ISO-8601"
},
"correlationId": "request-correlation-id",
"data": {
"callId": "call-id",
"status": "COMPLETED | FAILED",
"error": { "code": "optional", "message": "optional" },
"costUSD": 0.00123,
"standardCostUSD": 0.00123,
"pricingMultiplier": 1,
"usage": {
"inputTokens": 1200,
"cachedInputTokens": 400,
"cacheWriteInputTokens": 0,
"billableInputTokens": 800,
"responseTokens": 180,
"reasoningTokens": 0
},
"metered": { "...": "usage applied to configured meters" },
"spendVelocity": { "...": "optional hour and day snapshots" },
"meters": { "...": "updated meter snapshot" },
"balances": { "...": "updated balances" }
}
}/call_end is idempotent. A repeated finalization does not decrement meters twice. The SDK error object is a first-class API contract: an accepted provider-error finalization returns data.status = "FAILED" while preserving the normalized error metadata.
8. Streaming, retries, and failure policy
- Finalize after the stream is consumed, not when headers arrive. Include final usage events from the provider when its streaming option must be enabled explicitly.
- Finalize on consumer cancellation and abort. Do not discard a wrapped stream without consuming or cancelling it.
- Retry a UsageTap request only with the same idempotency key and call ID for the same logical call. Never generate a new key inside a retry loop.
- The SDK defaults to three total attempts with bounded backoff and jitter for retryable UsageTap failures. Provider retries remain controlled by the existing provider integration.
- Decide and document fail-open versus fail-closed behavior for a UsageTap outage. Access enforcement generally fails closed; observability-only metering may fail open if the product owner accepts missing usage.
9. Customer and Meter setup after code
- Set
USAGETAP_API_KEYand the existing provider key in the application's server runtime. - Run the exact local command or request identified by the coding agent to produce one provider call.
- Confirm the connection and customer attribution on Code Integration.
- Configure customer plans, call and token allowances, limits, replenishment, and optional custom meters in Meter setup.
- Exercise one allowed call and one limit path before production.
POST /customers and usageTap.createCustomer() are idempotent and can provision a customer before their first model call. GET /customers/{customerId}/usage and usageTap.checkUsage() return the current entitlement and meter snapshot. Plan changes require an admin key and should not be embedded in the initial application integration.
10. Explicitly outside first onboarding
11. Agent completion contract
The task is complete only when:
- the intended server-side provider calls are actually metered;
- customer attribution is stable and authenticated;
- success, failure, retry, streaming, cancellation, and limit behavior are handled;
- keys remain server-only and no values were logged or committed;
- focused tests and repository checks pass;
- the handoff names every required environment variable, the first-call command, the verification page, and the remaining Meter configuration action.