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

POST/projects/{projectId}/usage-eventsusage:write
JSON
{
  "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" }
}
FieldRule
eventId1–200 chars. Your idempotency key
environmentIdOptional UUID
subjectId1–200 chars
meter^[a-z][a-z0-9_.-]{0,126}$
quantityPositive integer, at most 1,000,000,000
occurredAtISO-8601 timestamp
metadataString, number, or boolean values

Idempotency

Events are unique on (projectId, eventId). A replay is not an error:

JSON
{ "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");
}

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

GET/projects/{projectId}/analytics/summaryusage:read AND billing:read

Returns 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

GET/projects/{projectId}/analytics/activityusage:read AND billing:read

Daily 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

GET/projects/{projectId}/analytics/subjectsusage:read AND billing:read
ParameterRule
days7–365, default 30
q2–200 chars. Matches subject id, user email, or user name
sortlastSeen (default), firstSeen, events, quantity
limit1–200, default 50
offset0–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

GET/projects/{projectId}/subjects/{subjectId}/activityusage:read AND billing:read

One 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

GET/environments/{environmentId}/stripe-connectionbilling:read
PUT/environments/{environmentId}/stripe-connectionbilling:manageuser only

Credentials 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.

JSON
{
  "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

GET/projects/{projectId}/billing/plansbilling:read
POST/projects/{projectId}/billing/plansbilling:manage
JSON
{
  "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

POST/projects/{projectId}/billing/checkoutbilling:manage

Requires an idempotency-key header of 16–255 characters. Omitting it is a 400.

curl
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

GET/projects/{projectId}/subjects/{subjectId}/entitlementsbilling:read
POST/projects/{projectId}/subjects/{subjectId}/entitlement-grantsbilling:manage

Reading 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:

JSON
{
  "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

POST/webhooks/stripe/{environmentId}none — signature verified

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:

  1. The signature is verified and the raw event is stored, unique on (provider, externalId).

  2. A stripe.event job is enqueued and Gate responds immediately.

  3. A worker claims the job with FOR UPDATE SKIP LOCKED, retrying up to 10 times and recovering jobs stuck in processing for more than five minutes.

  4. customer.subscription.* updates the subscription; invoice.paid records 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.