Usage and billing
Gate records what your product's subjects consume, connects each environment to its own Stripe account, and tracks what each subject is entitled to. A subject is your product's identifier for whoever is being billed — Gate treats it as an opaque string.
Usage events
/projects/{projectId}/usage-events{
"eventId": "session-018f3a2c-completed",
"environmentId": "b47a1f3e-9c22-4d18-8a71-5e3f0b2c9d44",
"subjectId": "usr_2f9c1e",
"meter": "shell_commands",
"quantity": 142,
"occurredAt": "2026-08-10T04:32:11.000Z",
"metadata": { "host": "laptop", "shell": "fish" }
}| Field | Rule |
|---|---|
eventId | 1–200 chars. Your idempotency key |
environmentId | Optional UUID |
subjectId | 1–200 chars |
meter | ^[a-z][a-z0-9_.-]{0,126}$ |
quantity | Positive integer, at most 1,000,000,000 |
occurredAt | ISO-8601 timestamp |
metadata | String, number, or boolean values |
Idempotency
Events are unique on (projectId, eventId). A replay is not an error:
{ "data": { "accepted": false, "duplicate": true, "id": "..." } }It returns 200, not 409. Treat duplicate: true as success and stop retrying.
export async function recordUsage(event: UsageEvent): Promise<void> {
const { accepted, duplicate } = await gate<{ accepted: boolean; duplicate: boolean }>(
`/projects/${GATE_PROJECT_ID}/usage-events`,
{ method: "POST", body: JSON.stringify(event) },
);
// duplicate means a previous attempt already landed — nothing to do.
if (!accepted && !duplicate) throw new Error("usage event rejected");
}type UsageEvent struct {
EventID string `json:"eventId"`
EnvironmentID string `json:"environmentId,omitempty"`
SubjectID string `json:"subjectId"`
Meter string `json:"meter"`
Quantity int `json:"quantity"`
OccurredAt time.Time `json:"occurredAt"`
Metadata map[string]string `json:"metadata,omitempty"`
}
func (c *Client) RecordUsage(ctx context.Context, e UsageEvent) error {
var out struct {
Data struct {
Accepted bool `json:"accepted"`
Duplicate bool `json:"duplicate"`
} `json:"data"`
}
if err := c.post(ctx, "/projects/"+c.projectID+"/usage-events", e, &out); err != nil {
return err
}
if !out.Data.Accepted && !out.Data.Duplicate {
return errors.New("usage event rejected")
}
return nil
}curl -sS -X POST \
"https://evonia-gate.annatarhe.com/api/v1/projects/$PROJECT_ID/usage-events" \
-H "content-type: application/json" \
-H "x-api-key: $GATE_API_KEY" \
-d '{
"eventId": "session-018f3a2c-completed",
"subjectId": "usr_2f9c1e",
"meter": "shell_commands",
"quantity": 142,
"occurredAt": "2026-08-10T04:32:11.000Z"
}'Analytics
Four read routes, each requiring both permissions and each accepting an optional environmentId
query parameter. Every window is UTC, and every one ends at the current instant — today is a partial
bucket.
Summary
/projects/{projectId}/analytics/summaryReturns yesterday's distinct active subjects plus gross revenue over rolling 7, 15, 90, and 365-day
windows. Only livemode invoices count.
Totals are grouped by currency — a project taking both USD and EUR gets one entry per currency, never a summed figure.
Activity
/projects/{projectId}/analytics/activityDaily trends over a days window of 7–90, default 30. series and revenue are gap-filled on
the server, so each holds exactly days entries in ascending order whether or not anything happened
that day.
engagement reports dailyActive (yesterday), weeklyActive (7 days), monthlyActive (28 days),
and stickiness — dailyActive ÷ monthlyActive, or 0 when nobody was active. All three counts end
yesterday, so today's partial day never drags the ratio down.
Because environmentId is optional on a usage event, events recorded without one appear only in the
project-wide view. The per-environment responses do not sum to it.
Subjects
/projects/{projectId}/analytics/subjects| Parameter | Rule |
|---|---|
days | 7–365, default 30 |
q | 2–200 chars. Matches subject id, user email, or user name |
sort | lastSeen (default), firstSeen, events, quantity |
limit | 1–200, default 50 |
offset | 0–50,000, default 0 |
Answers with { total, limit, offset, subjects } rather than a bare array, because the result size
scales with your product's data instead of with what an operator typed in. Each entry carries the
window's events, quantity, meters, firstEventAt, and lastEventAt, plus the subject's
current plan, subscriptionStatus, cancelAtPeriodEnd, and currentPeriodEnd.
name and email are filled in only when subjectId happens to be a Gate user id. For an opaque
third-party subject both are null, which is the normal case.
One subject
/projects/{projectId}/subjects/{subjectId}/activityOne subject over a days window of 7–365, default 30: a gap-filled daily series, a per-meter
breakdown, billing, and the 50 most recent events. totals.firstEventAt is lifetime rather than
window-scoped — it is how you tell a long-standing subject from a new one. Percent-encode
subjectId; it is arbitrary text, not a UUID.
Stripe connection
/environments/{environmentId}/stripe-connection/environments/{environmentId}/stripe-connectionCredentials are per environment, so test and live keys never share a row. They are encrypted at rest with the same AES-256-GCM scheme as config secrets.
{
"secretKey": "sk_live_...",
"webhookSecret": "whsec_...",
"publishableKey": "pk_live_...",
"accountId": "acct_...",
"livemode": true
}Prefixes are validated: sk_, whsec_, pk_, and acct_. GET never returns secret material.
Plans
/projects/{projectId}/billing/plans/projects/{projectId}/billing/plans{
"key": "pro",
"name": "Pro",
"description": "Unlimited history and priority sync",
"stripePriceId": "price_1QabcdEfGh",
"entitlements": { "history_days": 3650, "priority_sync": true }
}key is unique per project and is what you reference at checkout.
Checkout
/projects/{projectId}/billing/checkoutRequires an idempotency-key header of 16–255 characters. Omitting it is a 400.
curl -sS -X POST \
"https://evonia-gate.annatarhe.com/api/v1/projects/$PROJECT_ID/billing/checkout" \
-H "content-type: application/json" \
-H "idempotency-key: checkout-usr_2f9c1e-pro-20260810" \
-H "x-api-key: $GATE_API_KEY" \
-d '{
"environmentId": "'"$ENVIRONMENT_ID"'",
"subjectId": "usr_2f9c1e",
"planKey": "pro",
"successUrl": "https://shelltime.xyz/billing/done",
"cancelUrl": "https://shelltime.xyz/billing"
}'Returns 201 with { "data": { "id": "cs_...", "url": "https://checkout.stripe.com/..." } }.
Entitlements
/projects/{projectId}/subjects/{subjectId}/entitlements/projects/{projectId}/subjects/{subjectId}/entitlement-grantsReading returns the merged view of everything the subject currently has, with a version you can use
for cache invalidation.
Grants let you add entitlements from outside Stripe — Apple in-app purchase, a manual support override, a promotion, or a migration:
{
"feature": "priority_sync",
"value": true,
"source": "apple_iap",
"sourceReference": "1000000123456789",
"expiresAt": "2027-01-01T00:00:00.000Z"
}source is one of apple_iap, manual, promotion, or migration. Grants are unique on
(projectId, subjectId, feature, source, sourceReference), so replaying the same Apple transaction
is safe.
Webhooks
/webhooks/stripe/{environmentId}The only unauthenticated write endpoint. Gate verifies the stripe-signature header against the
webhook secret stored for that environment, so the environment id in the path selects which secret to
check against.
Processing is durable rather than inline:
-
The signature is verified and the raw event is stored, unique on
(provider, externalId). -
A
stripe.eventjob is enqueued and Gate responds immediately. -
A worker claims the job with
FOR UPDATE SKIP LOCKED, retrying up to 10 times and recovering jobs stuck inprocessingfor more than five minutes. -
customer.subscription.*updates the subscription;invoice.paidrecords revenue.
Because Gate acknowledges before processing, a 200 means "received and stored", not "applied". Read
the resulting state rather than assuming the effect is immediate.
Point one Stripe endpoint at each environment, and keep a job runner alongside every application replica — concurrent replicas are safe.