| Server IP : 35.80.110.71 / Your IP : 216.73.216.221 Web Server : Apache/2.4.58 (Ubuntu) System : Linux ip-172-31-21-44 6.17.0-1019-aws #19~24.04.1-Ubuntu SMP Tue Jun 23 18:53:06 UTC 2026 x86_64 User : ubuntu ( 1000) PHP Version : 8.3.31 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /var/www/splitstream-web/public/ |
Upload File : |
openapi: 3.1.0
info:
title: Splitstream API
version: 0.1.0
summary: A/B testing and experimentation API with sticky bucketing, Bayesian credible intervals, and SRM detection.
description: |
Splitstream is the sibling to Pennant. Where Pennant decides *whether* a
feature ships, Splitstream decides *which variant* a user sees and
*whether the variant moved the metric*. Define experiments in a
workspace, configure variants + audience + metrics, let SDKs call
`/v1/assign` for sticky bucketing, and stream conversion events into
`/v1/events`. The analysis worker computes Bayesian credible intervals
every 4 hours; the results endpoint returns the latest snapshot.
This spec is the source of truth. Laravel controllers conform to it; SDKs
are generated from it. Run `npm run spectral` to validate locally.
Calibrated decision-rule defaults (Phase 0):
| Parameter | Default |
|---|---|
| posterior_threshold | 0.995 |
| min_sample_per_variant | 20000 |
| snapshot_cadence_minutes | 240 |
Plan's draft defaults (0.95 / 1000 / 15min) delivered 66% empirical false-
positive rate under continuous peeking. See `/docs/concepts/bayesian-inference`.
contact:
name: Philip Rehberger
url: https://splitstream.philiprehberger.com
license:
name: MIT
identifier: MIT
servers:
- url: https://api.splitstream.philiprehberger.com
description: Production
- url: http://localhost:8000
description: Local development
security:
- ApiKeyAuth: []
tags:
- name: Health
description: Liveness check.
- name: Workspaces
description: Tenant boundary; one workspace per customer.
- name: Environments
description: Per-workspace environments (e.g. dev / staging / prod).
- name: Experiments
description: Experiment definitions and lifecycle.
- name: Metrics
description: Reusable metric definitions referenced by experiments.
- name: Mutex Groups
description: Mutually exclusive experiment cohorts — a unit assigned to one experiment in the group cannot be assigned to another.
- name: Segments
description: Reusable audience filters (shared expression-tree format with Pennant).
- name: Assignment
description: SDK-facing sticky bucketing.
- name: Events
description: Conversion and exposure event ingest.
- name: Results
description: Posterior + decision-rule snapshots per experiment.
- name: API Keys
description: Server and client keys for SDK authentication.
- name: Audit
description: Read-only mutation + peek-event history.
paths:
/v1/healthz:
get:
summary: Liveness check
description: Returns 200 if the API is up. Does not require auth.
operationId: healthz
tags: [Health]
security: []
responses:
"200":
description: Service is up.
content:
application/json:
schema: { $ref: "#/components/schemas/Health" }
/v1/workspaces/current:
get:
summary: Get the workspace the current API key belongs to
operationId: getCurrentWorkspace
tags: [Workspaces]
responses:
"200":
description: The workspace.
content:
application/json:
schema: { $ref: "#/components/schemas/Workspace" }
"401": { $ref: "#/components/responses/Unauthorized" }
/v1/environments:
get:
summary: List environments
operationId: listEnvironments
tags: [Environments]
responses:
"200":
description: Page of environments.
content:
application/json:
schema: { $ref: "#/components/schemas/EnvironmentList" }
"401": { $ref: "#/components/responses/Unauthorized" }
post:
summary: Create an environment
operationId: createEnvironment
tags: [Environments]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/EnvironmentInput" }
responses:
"201":
description: Environment created.
content:
application/json:
schema: { $ref: "#/components/schemas/Environment" }
"401": { $ref: "#/components/responses/Unauthorized" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/experiments:
get:
summary: List experiments
operationId: listExperiments
tags: [Experiments]
parameters:
- in: query
name: status
schema: { type: string, enum: [draft, running, stopped, archived] }
- in: query
name: cursor
schema: { type: string }
responses:
"200":
description: Page of experiments.
content:
application/json:
schema: { $ref: "#/components/schemas/ExperimentList" }
"401": { $ref: "#/components/responses/Unauthorized" }
post:
summary: Create an experiment
operationId: createExperiment
tags: [Experiments]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ExperimentInput" }
responses:
"201":
description: Experiment created (status `draft`).
content:
application/json:
schema: { $ref: "#/components/schemas/Experiment" }
"401": { $ref: "#/components/responses/Unauthorized" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/experiments/{id}:
parameters:
- { in: path, name: id, required: true, schema: { type: string }, description: "Experiment ULID" }
get:
summary: Get an experiment
operationId: getExperiment
tags: [Experiments]
responses:
"200":
description: The experiment.
content:
application/json:
schema: { $ref: "#/components/schemas/Experiment" }
"404": { $ref: "#/components/responses/NotFound" }
patch:
summary: Update an experiment
operationId: updateExperiment
tags: [Experiments]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ExperimentInput" }
responses:
"200":
description: Updated.
content:
application/json:
schema: { $ref: "#/components/schemas/Experiment" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
summary: Archive an experiment
operationId: archiveExperiment
tags: [Experiments]
responses:
"204": { description: Archived (no body). }
/v1/experiments/{id}/start:
parameters:
- { in: path, name: id, required: true, schema: { type: string } }
post:
summary: Start an experiment (draft → running)
operationId: startExperiment
tags: [Experiments]
responses:
"200":
description: Started.
content:
application/json:
schema: { $ref: "#/components/schemas/Experiment" }
"409":
description: Experiment not in `draft` status.
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
/v1/experiments/{id}/stop:
parameters:
- { in: path, name: id, required: true, schema: { type: string } }
post:
summary: Stop an experiment
operationId: stopExperiment
tags: [Experiments]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/StopExperimentInput" }
responses:
"200":
description: Stopped.
content:
application/json:
schema: { $ref: "#/components/schemas/Experiment" }
/v1/experiments/{id}/results:
parameters:
- { in: path, name: id, required: true, schema: { type: string } }
get:
summary: Latest analysis snapshot
description: |
Returns the most recent snapshot computed by the analysis worker.
Snapshots run every 4 hours by default; the production cadence is
controlled by `snapshot_cadence_minutes` per experiment.
Opening this endpoint via the admin UI logs a peek event; SDK access
via API key does not (the SDK reads this for SRM monitoring, not for
the decision).
operationId: getResults
tags: [Results]
responses:
"200":
description: Latest snapshot.
content:
application/json:
schema: { $ref: "#/components/schemas/AnalysisSnapshot" }
"404":
description: No snapshots yet.
/v1/experiments/{id}/results/history:
parameters:
- { in: path, name: id, required: true, schema: { type: string } }
get:
summary: Snapshot history
description: Time series of snapshots — for the "watch the credible interval tighten" chart.
operationId: getResultsHistory
tags: [Results]
responses:
"200":
description: Snapshot time series.
content:
application/json:
schema: { $ref: "#/components/schemas/AnalysisSnapshotHistory" }
/v1/metrics:
get:
summary: List metric definitions
operationId: listMetrics
tags: [Metrics]
responses:
"200":
description: Page of metrics.
content:
application/json:
schema: { $ref: "#/components/schemas/MetricList" }
post:
summary: Create a metric definition
operationId: createMetric
tags: [Metrics]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/MetricInput" }
responses:
"201":
description: Created.
content:
application/json:
schema: { $ref: "#/components/schemas/Metric" }
/v1/mutex-groups:
get:
summary: List mutex groups
operationId: listMutexGroups
tags: [Mutex Groups]
responses:
"200":
description: Page of mutex groups.
content:
application/json:
schema: { $ref: "#/components/schemas/MutexGroupList" }
post:
summary: Create a mutex group
operationId: createMutexGroup
tags: [Mutex Groups]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/MutexGroupInput" }
responses:
"201":
description: Created.
content:
application/json:
schema: { $ref: "#/components/schemas/MutexGroup" }
/v1/assign:
post:
summary: Sticky bucket assignment for one experiment
description: |
Returns the variant assigned to `unit_id` for `experiment_key`.
First call writes to the `assignments` table (sticky-forever); later
calls read the row.
Side effect: the first call logs an exposure event for the variant.
Subsequent calls do not double-log.
**Latency target:** 20ms p99 cache-hit, 50ms p99 cache-miss. Endpoint
runs on Laravel Octane (RoadRunner) — Apache mod_proxy carves out the
path to `127.0.0.1:8000`.
**Holdout responses:** if the experiment is in a mutex group and
`unit_id` is already claimed by another experiment in the group, the
response is `{ variant: null, reason: "mutex_holdout" }`.
operationId: assign
tags: [Assignment]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AssignInput" }
responses:
"200":
description: Assignment.
content:
application/json:
schema: { $ref: "#/components/schemas/Assignment" }
"404":
description: Experiment not found or not in `running` status.
/v1/assign/batch:
post:
summary: Sticky bucket assignment across many experiments in one round-trip
description: |
Returns a map of `experiment_key → assignment`. Max 50 experiments per
request.
operationId: assignBatch
tags: [Assignment]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/AssignBatchInput" }
responses:
"200":
description: Per-experiment assignments.
content:
application/json:
schema: { $ref: "#/components/schemas/AssignBatchResult" }
/v1/assignments/{unit_id}:
parameters:
- { in: path, name: unit_id, required: true, schema: { type: string }, description: "Bucketed unit identifier (1–256 bytes UTF-8)" }
get:
summary: All assignments for a unit
description: SDK reconciliation endpoint — returns every assignment the unit currently has.
operationId: getAssignmentsForUnit
tags: [Assignment]
responses:
"200":
description: All assignments.
content:
application/json:
schema: { $ref: "#/components/schemas/AssignmentList" }
/v1/assignments/{experiment}/{unit_id}:
parameters:
- { in: path, name: experiment, required: true, schema: { type: string } }
- { in: path, name: unit_id, required: true, schema: { type: string } }
delete:
summary: Force re-bucket (atomic, audit-logged)
description: |
Deletes the assignment row **and** sets `superseded_at = now()` on
every `exposure_events` row for the unit-experiment pair. The
analysis worker filters tombstoned rows. Use sparingly — breaks the
experiment's data for that unit.
operationId: forceRebucket
tags: [Assignment]
responses:
"204": { description: Rebucketed. }
/v1/events:
post:
summary: Track a conversion or arbitrary metric event
description: |
Synchronous endpoint, asynchronous persistence. Body is validated,
deduped via Redis SETNX on `client_event_id`, written to a Redis
list, and `202 Accepted` returned. The ingest worker drains the list
every second via `COPY ... FROM STDIN`.
**Latency target:** 30ms p99 (Octane).
**Late-event policy:** events with `occurred_at` 7–30 days old are
accepted and counted into `late_event_count`; older than 30 days are
rejected with `412 Precondition Failed`.
operationId: trackEvent
tags: [Events]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/EventInput" }
responses:
"202":
description: Accepted (or idempotent replay).
content:
application/json:
schema: { $ref: "#/components/schemas/EventAck" }
"412":
description: Event too late (occurred_at > 30 days ago).
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
"429":
description: Backpressure — Redis buffer exceeded 100k entries.
headers:
Retry-After: { schema: { type: integer }, description: "Seconds to wait" }
"503":
description: Redis unreachable.
/v1/events/batch:
post:
summary: Track up to 500 events in one round-trip
operationId: trackEventBatch
tags: [Events]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/EventBatchInput" }
responses:
"202":
description: Accepted.
content:
application/json:
schema: { $ref: "#/components/schemas/EventBatchAck" }
/v1/exposures:
post:
summary: Log an exposure (for SDKs that decouple assignment from exposure)
description: |
Server-side and mobile SDKs call this when the variant is actually
shown to the user. Browser SDKs typically don't need it — the
`/v1/assign` first-call already logged exposure.
operationId: logExposure
tags: [Events]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ExposureInput" }
responses:
"202":
description: Accepted.
/v1/api-keys:
get:
summary: List API keys
operationId: listApiKeys
tags: [API Keys]
responses:
"200":
description: Page of keys (secrets redacted; only `last_four` returned).
content:
application/json:
schema: { $ref: "#/components/schemas/ApiKeyList" }
post:
summary: Issue an API key
description: |
Returns the full key value exactly once — the server stores
`HMAC-SHA256(key, APP_KEY_PEPPER)` for fast lookup and an Argon2id
hash as defense-in-depth.
operationId: createApiKey
tags: [API Keys]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ApiKeyInput" }
responses:
"201":
description: Key created.
content:
application/json:
schema: { $ref: "#/components/schemas/ApiKeyWithSecret" }
/v1/audit:
get:
summary: Audit log
operationId: listAudit
tags: [Audit]
responses:
"200":
description: Audit entries.
content:
application/json:
schema: { $ref: "#/components/schemas/AuditList" }
components:
securitySchemes:
ApiKeyAuth:
type: http
scheme: bearer
bearerFormat: "ss_{srv|clt}_{env}_{secret}"
responses:
Unauthorized:
description: Missing or invalid API key.
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
NotFound:
description: Resource not found.
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
ValidationError:
description: Body validation failed.
content:
application/problem+json:
schema: { $ref: "#/components/schemas/Problem" }
schemas:
Health:
type: object
properties:
status: { type: string, const: "ok" }
version: { type: string }
required: [status]
Problem:
type: object
description: RFC 7807 problem details.
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer }
detail: { type: string }
instance: { type: string }
required: [type, title, status]
Workspace:
type: object
properties:
id: { type: string, description: ULID }
name: { type: string }
slug: { type: string }
created_at: { type: string, format: date-time }
required: [id, name, slug, created_at]
Environment:
type: object
properties:
id: { type: string }
workspace_id: { type: string }
name: { type: string }
slug: { type: string }
created_at: { type: string, format: date-time }
required: [id, workspace_id, name, slug, created_at]
EnvironmentInput:
type: object
properties:
name: { type: string }
slug: { type: string }
required: [name, slug]
EnvironmentList:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Environment" }
next_cursor: { type: string, nullable: true }
required: [data]
Experiment:
type: object
properties:
id: { type: string }
workspace_id: { type: string }
environment_id: { type: string }
key: { type: string }
name: { type: string }
hypothesis: { type: string }
unit_type: { type: string, enum: [user, account, session, device, custom_attribute] }
status: { type: string, enum: [draft, running, stopped, archived] }
variants:
type: array
items: { $ref: "#/components/schemas/Variant" }
audience: { $ref: "#/components/schemas/AudienceExpression" }
decision_rule: { $ref: "#/components/schemas/DecisionRule" }
primary_metric_id: { type: string, nullable: true }
guardrail_metric_ids:
type: array
items: { type: string }
mutex_group_id: { type: string, nullable: true }
started_at: { type: string, format: date-time, nullable: true }
stopped_at: { type: string, format: date-time, nullable: true }
stop_reason: { type: string, enum: [won, lost, inconclusive, bug, business], nullable: true }
created_at: { type: string, format: date-time }
required: [id, workspace_id, environment_id, key, name, hypothesis, unit_type, status, variants, decision_rule, created_at]
ExperimentInput:
type: object
properties:
environment_id: { type: string }
key: { type: string, pattern: "^[a-z0-9._-]{1,128}$" }
name: { type: string }
hypothesis: { type: string, minLength: 1 }
unit_type: { type: string, enum: [user, account, session, device, custom_attribute] }
variants:
type: array
minItems: 2
items: { $ref: "#/components/schemas/VariantInput" }
audience: { $ref: "#/components/schemas/AudienceExpression" }
decision_rule: { $ref: "#/components/schemas/DecisionRule" }
primary_metric_id: { type: string, nullable: true }
guardrail_metric_ids:
type: array
items: { type: string }
mutex_group_id: { type: string, nullable: true }
required: [environment_id, key, name, hypothesis, unit_type, variants]
ExperimentList:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Experiment" }
next_cursor: { type: string, nullable: true }
required: [data]
StopExperimentInput:
type: object
properties:
reason: { type: string, enum: [won, lost, inconclusive, bug, business] }
outcome_notes: { type: string }
required: [reason]
Variant:
type: object
properties:
id: { type: string }
key: { type: string }
weight: { type: integer, minimum: 0, maximum: 100 }
description: { type: string }
is_control: { type: boolean }
required: [id, key, weight, is_control]
VariantInput:
type: object
properties:
key: { type: string, pattern: "^[a-z0-9._-]{1,64}$" }
weight: { type: integer, minimum: 0, maximum: 100 }
description: { type: string }
is_control: { type: boolean, default: false }
required: [key, weight]
AudienceExpression:
type: object
description: |
JSON expression tree — reuses the Pennant rule format. Either
`{operator, operands}` for compound, or `{attribute, op, value}` for
leaf.
nullable: true
DecisionRule:
type: object
properties:
method: { type: string, enum: [bayesian.posterior_threshold, bayesian.always_valid_evalue, frequentist.sequential_msprt], default: bayesian.posterior_threshold }
posterior_threshold: { type: number, minimum: 0.5, maximum: 0.9999, default: 0.995 }
minimum_detectable_effect: { type: number, default: 0.02 }
min_sample_per_variant: { type: integer, minimum: 1, default: 20000 }
max_duration_days: { type: integer, default: 28 }
snapshot_cadence_minutes: { type: integer, default: 240 }
Metric:
type: object
properties:
id: { type: string }
workspace_id: { type: string }
key: { type: string }
name: { type: string }
event_key: { type: string }
kind: { type: string, enum: [binary, count, revenue, duration] }
aggregation: { type: string, enum: [sum, mean, count] }
unit_dedup_window: { type: string, description: "ISO-8601 duration; default P7D" }
property_path: { type: string, nullable: true, description: "Strict JSON-Path subset (dot-chain, bracketed string keys) — no recursive descent, no filters." }
required: [id, key, name, event_key, kind]
MetricInput:
type: object
properties:
key: { type: string, pattern: "^[a-z0-9._-]{1,128}$" }
name: { type: string }
event_key: { type: string }
kind: { type: string, enum: [binary, count, revenue, duration] }
aggregation: { type: string, enum: [sum, mean, count], default: count }
unit_dedup_window: { type: string, default: "P7D" }
property_path: { type: string, nullable: true }
required: [key, name, event_key, kind]
MetricList:
type: object
properties:
data: { type: array, items: { $ref: "#/components/schemas/Metric" } }
next_cursor: { type: string, nullable: true }
required: [data]
MutexGroup:
type: object
properties:
id: { type: string }
key: { type: string }
name: { type: string }
experiment_count: { type: integer }
required: [id, key, name]
MutexGroupInput:
type: object
properties:
key: { type: string }
name: { type: string }
required: [key, name]
MutexGroupList:
type: object
properties:
data: { type: array, items: { $ref: "#/components/schemas/MutexGroup" } }
next_cursor: { type: string, nullable: true }
required: [data]
AssignInput:
type: object
properties:
experiment_key: { type: string }
unit_id: { type: string, minLength: 1, maxLength: 256 }
context:
type: object
additionalProperties: true
description: "User attributes for audience targeting; ≤4KB JSON."
required: [experiment_key, unit_id]
Assignment:
type: object
properties:
experiment_key: { type: string }
unit_id: { type: string }
variant: { type: string, nullable: true, description: "null when mutex_holdout" }
assignment_id: { type: string }
reason: { type: string, enum: [bucketed, forced, mutex_holdout, audience_excluded] }
exposure_logged_at: { type: string, format: date-time, nullable: true }
required: [experiment_key, unit_id, variant, reason]
AssignBatchInput:
type: object
properties:
experiments:
type: array
minItems: 1
maxItems: 50
items: { type: string }
unit_id: { type: string }
context:
type: object
additionalProperties: true
required: [experiments, unit_id]
AssignBatchResult:
type: object
properties:
assignments:
type: object
additionalProperties: { $ref: "#/components/schemas/Assignment" }
required: [assignments]
AssignmentList:
type: object
properties:
unit_id: { type: string }
assignments:
type: array
items: { $ref: "#/components/schemas/Assignment" }
required: [unit_id, assignments]
EventInput:
type: object
properties:
event_key: { type: string, pattern: "^[a-z0-9._-]{1,128}$" }
unit_id: { type: string, minLength: 1, maxLength: 256 }
properties:
type: object
additionalProperties: true
description: "≤16KB JSON, max nesting depth 8."
occurred_at: { type: string, format: date-time }
client_event_id: { type: string, maxLength: 128 }
required: [event_key, unit_id]
EventAck:
type: object
properties:
accepted: { type: boolean }
idempotent_replay: { type: boolean, description: "true if client_event_id was already seen within 30d" }
required: [accepted]
EventBatchInput:
type: object
properties:
events:
type: array
minItems: 1
maxItems: 500
items: { $ref: "#/components/schemas/EventInput" }
required: [events]
EventBatchAck:
type: object
properties:
accepted_count: { type: integer }
rejected:
type: array
items:
type: object
properties:
index: { type: integer }
reason: { type: string }
required: [accepted_count]
ExposureInput:
type: object
properties:
experiment_key: { type: string }
unit_id: { type: string }
variant: { type: string }
required: [experiment_key, unit_id, variant]
AnalysisSnapshot:
type: object
properties:
id: { type: string }
experiment_id: { type: string }
computed_at: { type: string, format: date-time }
per_variant:
type: array
items: { $ref: "#/components/schemas/VariantPosterior" }
srm_chi_squared_p: { type: number, nullable: true }
srm_warning: { type: boolean }
decision_rule_satisfied: { type: boolean }
peek_count_at_computation: { type: integer }
late_event_count: { type: integer }
weights_changed_since_start: { type: boolean }
required: [id, experiment_id, computed_at, per_variant, decision_rule_satisfied]
AnalysisSnapshotHistory:
type: object
properties:
experiment_id: { type: string }
snapshots:
type: array
items: { $ref: "#/components/schemas/AnalysisSnapshot" }
required: [experiment_id, snapshots]
VariantPosterior:
type: object
properties:
variant_key: { type: string }
is_control: { type: boolean }
sample_size: { type: integer }
conversions: { type: integer }
observed_rate: { type: number }
posterior:
type: object
properties:
mean: { type: number }
credible_interval_95:
type: array
minItems: 2
maxItems: 2
items: { type: number }
required: [mean, credible_interval_95]
prob_best: { type: number, description: "P(this variant is best)" }
expected_loss_if_stop_now: { type: number, description: "Expected lift forgone if we stop now and this variant turns out to be inferior" }
required: [variant_key, sample_size, conversions, posterior]
ApiKey:
type: object
properties:
id: { type: string }
workspace_id: { type: string }
environment_id: { type: string, nullable: true }
name: { type: string }
kind: { type: string, enum: [server, client] }
prefix: { type: string, description: "e.g. ss_srv_live" }
last_four: { type: string }
last_used_at: { type: string, format: date-time, nullable: true }
created_at: { type: string, format: date-time }
required: [id, kind, prefix, last_four, created_at]
ApiKeyInput:
type: object
properties:
name: { type: string }
kind: { type: string, enum: [server, client] }
environment_id: { type: string, nullable: true }
required: [name, kind]
ApiKeyWithSecret:
allOf:
- $ref: "#/components/schemas/ApiKey"
- type: object
properties:
key: { type: string, description: "Full key — shown once at creation only." }
required: [key]
ApiKeyList:
type: object
properties:
data: { type: array, items: { $ref: "#/components/schemas/ApiKey" } }
next_cursor: { type: string, nullable: true }
required: [data]
AuditEntry:
type: object
properties:
id: { type: string }
actor_id: { type: string, nullable: true }
action: { type: string }
target_type: { type: string }
target_id: { type: string }
payload: { type: object, additionalProperties: true }
created_at: { type: string, format: date-time }
required: [id, action, target_type, target_id, created_at]
AuditList:
type: object
properties:
data: { type: array, items: { $ref: "#/components/schemas/AuditEntry" } }
next_cursor: { type: string, nullable: true }
required: [data]