Schema Deployment
Deploy your spec and projections with a deploy-scoped API key — same host, same bearer token as reads and writes. No JWT, no separate console login. This is the self-serve path built for CI: push spec.json and your projection definitions with just J17_API_KEY.
The Admin API still offers a JWT-based path on the console API. Use whichever fits — the deploy key collapses everything to one host and one credential.
Deploy keys
A deploy key is a fourth scope, alongside read, write, and admin:
| Scope | Can deploy schema? | Can read/write data? |
|---|---|---|
read |
No | Read only |
write |
No | Read + write |
deploy |
Yes | No |
admin |
Yes | Read + write |
deploy is deliberately orthogonal to write: a key that posts events cannot redefine the schema those events are validated against, and a deploy key cannot write into live event streams. It does own environment provisioning — seeding a fresh environment (cold start) and resetting one — but only on staging and test, never production. Use a dedicated deploy key in CI; use admin only when one credential genuinely needs both.
Create one from the dashboard (Instance settings → API Keys → scope "Deploy"), or via the console API:
curl -X POST https://console.j17.dev/api/instances/$INSTANCE_ID/keys \
-H "Authorization: Bearer $JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "CI Deploy",
"scope": "deploy",
"environment": "test"
}'
Deploy keys are environment-scoped like any other key. A test key only works on *-test.j17.dev and can only ever deploy the test environment — the environment is fixed by the host you call, not by the request body, so a test key cannot touch production.
Deploy a spec
POST /_spec
Authorization: Bearer $DEPLOY_KEY
Content-Type: application/json
{
"spec": {
"aggregate_types": { ... },
"agent_types": [ ... ]
}
}
curl -X POST https://myapp-test.j17.dev/_spec \
-H "Authorization: Bearer $DEPLOY_KEY" \
-H "Content-Type: application/json" \
-d @spec.json
The spec goes inside the "spec" key — the same envelope the dashboard upload and the JWT endpoint expect. The environment comes from the host, not the body.
Response:
{ "ok": true, "environment": "test" }
The deploy takes effect immediately: your next read or write on that host sees the new spec. It reaches that host as a notification from the control plane, and if one is ever lost the host re-checks on its own within five minutes — so the worst case is a delay, not a deploy that silently never applies.
Warnings
A successful deploy (and a successful validate) may carry a warnings array — advisory, never blocking:
{
"ok": true,
"environment": "test",
"warnings": [
"aggregate type 'trip' appends to state in every event type (26 of 26). Its cached state grows with its stream, and keeping it current costs ~2x the state size in Redis traffic on every write. If it is written more often than it is read, list it in modules.never_cache."
]
}
Currently the one warning is the append-everywhere shape: an aggregate type whose every event appends to state grows without bound, and its cache entry costs more to maintain than it saves unless the type is read at least as often as it is written. Listing the type in modules.never_cache resolves it — reads replay the stream instead, which for this shape transfers roughly the same bytes anyway. The field is absent when there is nothing to say.
Validate without deploying
POST /_spec/validate checks a spec and writes nothing:
curl -X POST https://myapp-test.j17.dev/_spec/validate \
-H "Authorization: Bearer $DEPLOY_KEY" \
-H "Content-Type: application/json" \
-d '{ "spec": { ... } }'
{ "ok": true, "environment": "test", "valid": true }
It runs exactly what a real deploy runs — structural validation and the backward-compatibility check against the environment's current spec — and returns the identical error body on failure. Nothing is written, and your deployed spec and its cache are untouched.
A separate endpoint rather than a flag on POST /_spec, so that no spelling of a
parameter can turn a check into a deploy.
This exists because validation otherwise happens only on the deploy path, which makes a spec that cannot ship the one class of error your test suite cannot see: everything passes locally and the release fails. Wire it into a precommit hook or CI step against a non-production environment and the failure moves to where you can fix it cheaply.
Incompatible changes and force
Specs are validated before deployment, and backward-incompatible changes (removing an aggregate or event type, tightening a required field) are rejected. Pass "force": true to waive the compatibility check:
{ "spec": { ... }, "force": true }
force is honored on non-production environments only. On production it is ignored — a prod spec can never be clobbered by a forced deploy.
Deploy a projection
The projection name is in the URL; the definition is the body.
POST /_projections/:name
Authorization: Bearer $DEPLOY_KEY
Content-Type: application/json
{
"projection": {
"for_each": "order",
"sources": { "order": "$key" },
"select": { "total": "$.order.total" }
}
}
curl -X POST https://myapp-test.j17.dev/_projections/order_summary \
-H "Authorization: Bearer $DEPLOY_KEY" \
-H "Content-Type: application/json" \
-d '{"projection": {"for_each": "order", "sources": {"order": "$key"}, "select": {"total": "$.order.total"}}}'
Response:
{ "ok": true, "environment": "test", "name": "order_summary" }
Projections are validated against the deployed spec, so deploy the spec first. See the projections guide for sources, select transforms, and SQL queries.
The name query can't be used for a projection — /_projections/query is the projection SQL endpoint.
Delete a projection
DELETE /_projections/:name
Authorization: Bearer $DEPLOY_KEY
curl -X DELETE https://myapp-test.j17.dev/_projections/order_summary \
-H "Authorization: Bearer $DEPLOY_KEY"
Response:
{ "ok": true, "environment": "test", "name": "order_summary" }
Deleting a projection that doesn't exist returns 404 projection_not_found.
Seed a fresh environment (cold start)
A deploy key can also seed an environment — write a batch of starting events into an environment that has no data yet — with one call. This is how you provision a non-production environment from an export without a console login or a human in the loop.
POST /_cold_start
Authorization: Bearer $DEPLOY_KEY
Content-Type: application/json
{ "events": [ … ] }
curl -X POST https://myapp-test.j17.dev/_cold_start \
-H "Authorization: Bearer $DEPLOY_KEY" \
-H "Content-Type: application/json" \
-d @events.json
Response:
{
"ok": true,
"environment": "test",
"events_written": 128,
"aggregates": 40,
"consistency": {
"estimated_seconds": 2,
"note": "Projections and read caches materialize in the background. Queries against projection tables may be incomplete for about 2s after this call."
}
}
Each event is a { "key", "type", "data", "metadata" } object — the same wire
shape you get from an export, so an exported event stream feeds straight back in.
Deploy the spec first: the events are validated against it, and the whole batch
is rejected if any event fails (no partial seed).
Materialization is asynchronous. The events land synchronously, but
projection tables and read caches are built by a background sweeper. The
response's consistency.estimated_seconds is a rough guide to how long a query
against a projection table may return partial results — scaled to the number of
aggregates seeded. For a large seed the field also carries a warning; wait that
long (or poll a projection count until it settles) before treating the
environment as ready. This matters most right after a _reset + _cold_start
cycle, when every projection table starts empty.
Cold start is once-only and non-production-only:
- It writes only into an empty environment. If the environment already holds data it is refused with 409 — cold start never overwrites. To re-seed, reset first (below), then cold start again.
- With a deploy key it is limited to
stagingandtest; production returns- This mirrors reset: a deploy key can seed or recycle a non-prod environment, but never touch prod data. (An operator with a console login can cold-start any environment — see the Admin API.)
Reset a non-production environment
POST /_reset wipes an environment's event data and projections (the deployed
spec is kept), so a deploy key can recycle a non-prod environment between runs:
curl -X POST https://myapp-test.j17.dev/_reset \
-H "Authorization: Bearer $DEPLOY_KEY"
Like cold start, it is staging/test only — production returns 403. The pair
gives CI a full provision cycle: _reset to clear, _cold_start to seed.
Errors
| Status | Body | Meaning |
|---|---|---|
| 403 | {"ok": false, "error": "Insufficient permissions"} |
Key lacks deploy scope |
| 403 | {"ok": false, "error": "cold_start with a deploy key is limited to non-production environments"} |
_cold_start on prod |
| 404 | {"ok": false, "error": "instance_not_found"} |
No such instance |
| 404 | {"ok": false, "error": "projection_not_found"} |
Deleting an absent projection |
| 409 | {"ok": false, "error": "Environment already initialized"} |
_cold_start on an environment that already holds data |
| 422 | {"ok": false, "error": "validation_failed", "details": [...]} |
Spec or projection rejected — details explains why |
| 422 | {"ok": false, "error": "No spec deployed for this environment"} |
_cold_start before a spec is deployed |
| 502 | {"ok": false, "error": "deploy_unavailable"} |
j17 couldn't record the deploy; retry |
Validation failures return structured details, so a bad spec tells you exactly what's wrong — the surface a CI job can act on.
A CI deploy
#!/usr/bin/env bash
set -euo pipefail
host="https://myapp-test.j17.dev"
# Check before you change anything: a rejected spec fails here rather than
# halfway through the deploy.
curl -fsS -X POST "$host/_spec/validate" \
-H "Authorization: Bearer $J17_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"spec\": $(cat j17/spec.json)}"
# Spec first — projections validate against it.
curl -fsS -X POST "$host/_spec" \
-H "Authorization: Bearer $J17_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"spec\": $(cat j17/spec.json)}"
# Then each projection.
for f in j17/projections/*.json; do
name=$(basename "$f" .json)
curl -fsS -X POST "$host/_projections/$name" \
-H "Authorization: Bearer $J17_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"projection\": $(cat "$f")}"
done
See also
- Authentication - Key scopes and environments
- Admin API - The JWT deploy path on the console API
- Projections - Defining projections
- Spec reference - Spec structure