Orders & Audit
Order
Proposed — confirm (DECISIONS.md #20). order_id as an opaque, developer-supplied reference (no entity, table, or endpoint) follows from the billing system of record. billing.manage and billing.refund are re-pointed at the one billing relationship Substratal actually has, platform subscriptions. billing.manage lets support view a Tenant’s subscription and usage and open its billing portal. billing.refund is reserved for Stripe credits and refunds, which are done in the Stripe dashboard today, so it has no endpoint yet.
An Order isn’t an entity in this API. There’s no orders table, no ord_ resource, and no Orders endpoint. The commerce record an Entitlement traces back to lives in the developer’s own billing system (see Billing system of record below). What this API stores is a reference to it:
| Field (on Entitlement) | Type | Notes |
|---|---|---|
order_id |
string, nullable, ≤ 255 characters | Opaque. Typically the developer’s own Stripe subscription id (sub_…), invoice id, or internal order number. It isn’t validated beyond its length, never dereferenced, and never used to drive state on its own. It’s settable once, so it stays a stable reconciliation key, and it’s filterable on GET /v1/entitlements?order_id=…. |
Its one behavioral effect: an Entitlement with order_id set can’t be hard-deleted (entitlement_order_linked), because it represents a real financial event rather than a mistake.
When the developer’s billing changes, for example a refund or a lapsed subscription, the developer’s backend calls this API to change the Entitlement: PATCH /v1/entitlements/{id} with status: revoked, or by setting ends_at so it lapses on schedule, using an app-scoped API Key. This API never learns about the payment itself. See Workflows → Purchase → access.
Billing system of record
Substratal Apps is not a payment processor or marketplace billing engine for any Application, ever, including after the marketplace phase ships. Each Application’s developer owns the billing relationship with their own end users (their own Stripe or equivalent), entirely outside this API, and calls Entitlements to reflect the outcome. Order/order_id is reference metadata the developer supplies for their own reconciliation. It is never a record that a Substratal-run billing system pushes webhooks about. When the marketplace ships, it makes Applications discoverable, each with its developer’s own pricing and billing. It is not a checkout that Substratal runs.
The only payment flow that runs through Substratal Apps itself is its own platform subscription: what an Application owner pays Substratal for Starter/Team/Enterprise. See Pricing → How the subscription is charged.
Audit Event
An immutable record of who changed what access, when. Never edited, never deleted — including after the record it describes is itself deleted.
| Field | Type | Notes |
|---|---|---|
id |
string | evt_ prefix. |
action |
string | See Action catalog below. |
actor |
object | Who made the change: { "type": "user" \| "api_key" \| "system", "id": "usr_…" \| "key_…" \| "system", "via_api_key_id": null }. A change made by a User through an MCP or agent session that used their own token is type: user. A change made by an API Key is type: api_key, with the key’s id. System-initiated changes, such as a trial expiring, an erasure cascade, or a Stripe sync, are type: system, id: "system". |
target |
object | What changed: { "type": "user" \| "entitlement" \| "role" \| "role_assignment" \| "application" \| "organization" \| "tenant" \| "tier_change_request" \| "api_key" \| "webhook", "id": "…" }. |
target_user_id |
string, nullable | Whose access or data changed, when there is such a User. It’s null for events with no user subject, such as application.updated, api_key.created, or tenant.plan_changed. For an org-wide Entitlement change it’s also null. The per-member effect shows up as access.* webhooks, not as one Audit Event per member. |
application_id, organization_id, tenant_id |
string, nullable | Scope, where relevant. tenant_id is denormalized from the Application. |
before / after |
object, nullable | A snapshot of the changed fields only, not the whole record. Secrets and hashes never appear, even as before/after values. |
request_id |
string, nullable | The X-Request-Id of the API call that caused it, or null for system events. It joins audit to request logs. |
timestamp |
timestamp | The commit time of the change. |
Example
{
"id": "evt_01JAG7X3P8QY1L0M9N8O7P6Q5R",
"action": "entitlement.disabled",
"actor": { "type": "user", "id": "usr_01JAG9SUPPORT0000000000000", "via_api_key_id": null },
"target": { "type": "entitlement", "id": "ent_01JAG9F4Q1W2E3R4T5Y6U7I8O9" },
"target_user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"application_id": "app_invoicer",
"organization_id": null,
"tenant_id": "tnt_01JAG1SUBSTRATAL0000000000",
"before": { "status": "active" },
"after": { "status": "disabled", "disabled_reason": "billing_dispute" },
"request_id": "req_7c1e9a2f4b",
"timestamp": "2026-09-30T16:22:41Z"
}
Action catalog
Every value action can take. It’s mirrored exactly by the AuditAction enum in openapi.yaml, and by the check constraint on audit_events.action (see Database Schema). Entries marked admin-only never fire for a self-service change. See What triggers an Audit Event below.
| Action | Fires when | target.type |
|---|---|---|
user.created |
POST /v1/users, POST /v1/auth/signup, or an invite via org membership |
user |
user.updated |
users.manage changes a field other than status or email (for example invited → active) — admin-only |
user |
user.email_changed |
An email change completes (self, after verification; or admin, immediately) | user |
user.suspended |
POST /v1/users/{id}/suspend, or PATCH status: suspended — admin-only |
user |
user.reactivated |
PATCH status: active on a suspended user — admin-only |
user |
user.deleted |
DELETE /v1/users/{id}, or the soft-delete step of an erasure request |
user |
user.erasure_requested |
POST /v1/users/{id}/erasure-requests |
user |
user.erasure_cancelled |
DELETE /v1/users/{id}/erasure-requests/current — admin-only |
user |
user.erased |
The hard-delete cascade completes (actor system) |
user |
user.password_changed |
POST /v1/users/{id}/password |
user |
user.password_reset |
POST /v1/auth/password/reset |
user |
user.mfa_enabled / user.mfa_disabled |
TOTP confirmed / disabled by the user | user |
user.mfa_reset |
DELETE /v1/users/{id}/mfa/totp — admin-only |
user |
entitlement.granted |
An Entitlement is created, or returns to active |
entitlement |
entitlement.disabled |
status set to disabled |
entitlement |
entitlement.revoked |
status set to revoked |
entitlement |
entitlement.expired |
Automatic transition to expired (actor system) |
entitlement |
entitlement.updated |
ends_at, source, or order_id changes without a status change |
entitlement |
entitlement.member_scope_changed |
An org grant’s member_scope/member_overrides changes — see Entitlements → Org-wide entitlements |
entitlement |
entitlement.deleted |
DELETE /v1/entitlements/{id} (error correction) |
entitlement |
role.created / role.updated / role.deleted |
Role definition changes, including Roles seeded or removed via an Application’s available_app_roles |
role |
role.assigned / role.removed |
POST/DELETE /v1/users/{id}/roles/{roleId} (only when something actually changed) |
role_assignment |
profile.updated |
An admin changes another user’s Profile or AppProfile — admin-only | user |
settings.updated |
An admin changes another user’s Settings or AppSettings — admin-only | user |
application.created |
POST /v1/applications |
application |
application.updated |
PATCH /v1/applications/{id} (configuration fields) |
application |
application.review_status_changed |
A reviewer approves, rejects, suspends, or reinstates; or a rejected app is resubmitted — see Applications → The review lifecycle | application |
organization.created / organization.updated |
POST/PATCH /v1/organizations… |
organization |
organization.member_added / organization.member_removed |
Membership created or removed, including leaving | organization |
organization.member_role_changed |
PATCH /v1/organizations/{id}/members/{userId} |
organization |
tenant.plan_changed |
plan changes via a Stripe sync (actor system) |
tenant |
tenant.subscription_status_changed |
subscription_status or restricted changes via a Stripe sync (actor system) |
tenant |
tenant.tier_change_requested |
POST /v1/tenants/{id}/tier-change-requests — the request itself, not yet the migration |
tier_change_request |
tenant.tier_change_request_updated |
PATCH on a tier-change request (scheduled, started, cancelled) |
tier_change_request |
tenant.tier_changed |
A tier-change request reaches completed: tier/region change and status returns to active — see Tenancy → How a tier change happens today |
tenant |
api_key.created / api_key.updated / api_key.rotated / api_key.revoked |
API Key lifecycle | api_key |
api_key.restrict_destructive_disabled |
restrict_destructive explicitly set to false on an intended_use: "agent" key (written in addition to api_key.created/updated) — see API Keys → Agent keys |
api_key |
webhook.created / webhook.updated / webhook.deleted / webhook.secret_rotated |
Webhook subscription lifecycle | webhook |
This list is the authoritative source for action values. If an endpoint’s page describes a write that isn’t represented here, that’s a spec bug; file it the same way as any other inconsistency.
What triggers an Audit Event
Every write to an Entitlement, every Role definition or assignment change, every change to a credential (API Key, webhook secret, password, MFA), every Application, Organization, and Tenant change, and every admin-initiated (not self-service) change to a user’s Profile or Settings. Self-service changes a user makes to their own Profile/Settings aren’t audited at this level of detail. That’s ordinary account activity, not an access-control event.
Not Audit Events: logins, failed logins, token refreshes, and reads. Those go to the security log (structured application logs with X-Request-Id, retained 1 year in CloudWatch Logs; see Non-Functional Requirements → Security logging). The Audit log records changes to state, and the security log records access attempts. See Non-Functional Requirements → Audit.