Documentation for v2.10.0. Read the latest, v2.12.0.
Operator
Operator recipes
One task per section, each with the command, the answer a 2.6.0 instance gave, and where to read
more. The answers are the recordings of An operator's story; where a task
is not in the story, the expected answer is quoted from the
Maintenance API reference. Administrative routes take
a session token (SESSION from POST /auth/login), never an API token,
with one exception noted below.
Log in for a session#
SESSION=$(curl -s -X POST $SANSHAIN/auth/login -H "Content-Type: application/json" \
-d '{"username":"root","password":"…"}' | jq -r .token)
Recorded (story, step 2)
POST /auth/login HTTP/1.1
Content-Type: application/json
{
"username": "root",
"password": "walkthrough-admin"
}
HTTP/1.1 200 OK
content-type: application/json
content-length: 76
{
"token": "<session token>"
}
Change the admin password#
The answer is a new session; every other session of the account ends.
curl -s -X POST $SANSHAIN/auth/change-password \
-H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" \
-d '{"old_password":"…","new_password":"…"}'
Recorded (story, step 2)
POST /auth/change-password HTTP/1.1
Authorization: Bearer <session token>
Content-Type: application/json
{
"old_password": "walkthrough-admin",
"new_password": "walkthrough-admin-2026"
}
HTTP/1.1 200 OK
content-type: application/json
content-length: 76
{
"token": "<session token>"
}
Approve a user#
Find the id in GET /admin/users, then approve. The
Auto-acknowledge new users switch (Local mode, next to Registration) skips this
for new registrations.
curl -s -X POST $SANSHAIN/admin/users/2/approve -H "Authorization: Bearer $SESSION"
Recorded (story, step 4)
POST /admin/users/2/approve HTTP/1.1
Authorization: Bearer <session token>
HTTP/1.1 200 OK
content-length: 0
Getting started: letting users in · POST /admin/users/{id}/approve
Grant or revoke a role#
Roles: admin, releaser, user_manager,
viewer. Revoking is DELETE /admin/users/{id}/roles/{role}, answered
200.
curl -s -X POST $SANSHAIN/admin/users/2/roles \
-H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" -d '{"role":"releaser"}'
Recorded (story, step 4)
POST /admin/users/2/roles HTTP/1.1
Authorization: Bearer <session token>
Content-Type: application/json
{
"role": "releaser"
}
HTTP/1.1 201 Created
content-length: 0
Make someone a maintainer of a Producer#
Exactly one of user_id or group_id. The effect shows in that user's
GET /auth/me as maintains.
curl -s -X POST $SANSHAIN/admin/producers/payments/maintainers \
-H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" -d '{"user_id":2}'
Recorded (story, step 4)
POST /admin/producers/payments/maintainers HTTP/1.1
Authorization: Bearer <session token>
Content-Type: application/json
{
"user_id": 2
}
HTTP/1.1 201 Created
content-length: 0
GET /auth/me HTTP/1.1
Authorization: Bearer <maria's session token>
HTTP/1.1 200 OK
content-type: application/json
content-length: 134
{
"approved": true,
"id": 2,
"is_root": false,
"maintains": [
"payments"
],
"permissions": [
"release_ga"
],
"roles": [
"releaser"
],
"username": "maria"
}
Roles and permissions: maintainers · POST /admin/producers/{name}/maintainers
Set the webhook allow-list#
Replaces the list. http:// in front of a pattern admits plain HTTP to that host.
curl -s -X POST $SANSHAIN/admin/settings/webhook-allowed-hosts \
-H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" \
-d '{"hosts":["hooks.slack.com","http://n8n.corp.internal"]}'
Recorded (story, step 5)
POST /admin/settings/webhook-allowed-hosts HTTP/1.1
Authorization: Bearer <session token>
Content-Type: application/json
{
"hosts": [
"hooks.slack.com",
"http://receiver.sanshain.svc"
]
}
HTTP/1.1 200 OK
content-type: application/json
content-length: 48
{
"hosts": [
"hooks.slack.com",
"http://receiver.sanshain.svc"
]
}
Webhooks: the allow-list · POST /admin/settings/webhook-allowed-hosts
Register and test a hook#
The secret is in the registration answer only. For a Producer's or Consumer's own hook use
/admin/producers/{name}/webhooks or
/admin/consumers/{name}/webhooks with the same body.
curl -s -X POST $SANSHAIN/admin/webhooks \
-H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" \
-d '{"url":"https://hooks.slack.com/services/…","format":"slack","event_types":["version.published","provide.rejected"]}'
curl -s -X POST $SANSHAIN/admin/webhooks/1/test -H "Authorization: Bearer $SESSION"
Recorded (story, step 6)
POST /admin/webhooks HTTP/1.1
Authorization: Bearer <session token>
Content-Type: application/json
{
"url": "http://receiver.sanshain.svc/sanshain",
"format": "slack"
}
HTTP/1.1 201 Created
content-type: application/json
content-length: 344
{
"webhook": {
"id": 1,
"scope": {
"kind": "global"
},
"url": "http://receiver.sanshain.svc/sanshain",
"format": "slack",
"event_types": [],
"producer_glob": null,
"secret_hint": "7303e6f9",
"paused": false,
"allow_list_exempt": true,
"created_by": "root",
"created_at": "2026-10-22T18:24:50Z"
},
"secret": "whsec_5c42bd134c538dd0f6154cb57799c7de88999c40c1ecd1faf658dbdd7f5e5adf"
}
POST /admin/webhooks/1/test HTTP/1.1
Authorization: Bearer <session token>
HTTP/1.1 202 Accepted
content-type: application/json
content-length: 51
{
"event_id": "a7eedc47-e86b-412a-9f1f-d262fa304c13"
}
Verify a delivery's signature#
HMAC-SHA256 over <t>.<raw body> with the hook's secret, compared in
constant time; reject t older than five minutes.
Recorded (story, step 6)
POST /sanshain HTTP/1.1
Content-Type: application/json
Sanshain-Event: webhook.test
X-Sanshain-Delivery: 1
Sanshain-Signature: t=1792693491,v1=3aaabd25f9590609d13d0f7f4138e849a4df4fa233936eee3f7781f4b10b9dbe
User-Agent: Sanshain/2.10.0
{
"text": "*webhook.test* — Test event from Sanshain for hook 1: This is a test event from Sanshain. If you can read this, the receiver, the signature secret and the allow-list are all in order. <https://sanshain.corp/admin.html|Open in Sanshain>"
}
HTTP/1.1 200 OK
# The receiver's check, with the secret shown once at creation
$ python3 verify.py delivery.json whsec_…
signature header: t=1792693491,v1=3aaabd25f9590609d13d0f7f4138e849a4df4fa233936eee3f7781f4b10b9dbe
secret from the create answer: whsec_…
HMAC-SHA256 of '<t>.<raw body>': 3aaabd25f9590609d13d0f7f4138e849a4df4fa233936eee3f7781f4b10b9dbe
valid: True
Rotate a webhook secret#
Not in the story. A new secret, answered once, signs every later delivery; audited as
WEBHOOK_SECRET_ROTATED.
curl -s -X POST $SANSHAIN/admin/webhooks/1/rotate-secret -H "Authorization: Bearer $SESSION"
Expected answer
HTTP/1.1 200 OK
{ "webhook": { "id": 1, "secret_hint": "…", … }, "secret": "whsec_…" }
Redeliver a delivery#
Not in the story. Find the delivery in the log; redeliver resets it, a dead one included, for
one more attempt with the same event id.
curl -s "$SANSHAIN/admin/webhooks/1/deliveries?limit=50" -H "Authorization: Bearer $SESSION"
curl -s -X POST $SANSHAIN/admin/webhooks/1/deliveries/1/redeliver -H "Authorization: Bearer $SESSION"
The log, recorded (story, step 6)
GET /admin/webhooks/1/deliveries?limit=5 HTTP/1.1
Authorization: Bearer <session token>
HTTP/1.1 200 OK
content-type: application/json
content-length: 351
[
{
"id": 1,
"hook_id": 1,
"event_id": "a7eedc47-e86b-412a-9f1f-d262fa304c13",
"event_type": "webhook.test",
"occurred_at": "2026-10-22T18:24:50Z",
"status": "delivered",
"attempts": 1,
"next_attempt_at": "2026-10-22T18:34:11Z",
"last_response_code": 200,
"last_error": null,
"delivered_at": "2026-10-22T18:24:51Z",
"redelivered_at": null,
"updated_at": "2026-10-22T18:24:51Z"
}
]
Expected answer of the redeliver
HTTP/1.1 202 Accepted
{ "id": 1, "hook_id": 1, "event_id": "…", "status": "pending", "attempts": 0, "redelivered_at": "…", … }
Webhooks: retries and redelivery · POST …/deliveries/{delivery_id}/redeliver
Pause, resume or delete a hook#
Not in the story. A paused hook's deliveries stay pending and go out on resume; deleting removes the hook and its delivery log.
curl -s -X PATCH $SANSHAIN/admin/webhooks/1 \
-H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" -d '{"paused":true}'
curl -s -X DELETE $SANSHAIN/admin/webhooks/1 -H "Authorization: Bearer $SESSION"
Expected answers
HTTP/1.1 200 OK (PATCH: the hook, with "paused": true)
HTTP/1.1 204 No Content (DELETE)
Set retention and run the cleanup#
curl -s -X POST $SANSHAIN/admin/settings/snapshot-max-age \
-H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" -d '{"days":30}'
curl -s -X POST $SANSHAIN/admin/cleanup/snapshots \
-H "Authorization: Bearer $SESSION" -H "Content-Type: application/json" -d '{}'
Recorded (story, step 7)
POST /admin/cleanup/snapshots HTTP/1.1
Authorization: Bearer <session token>
Content-Type: application/json
{}
HTTP/1.1 200 OK
content-type: application/json
content-length: 13
{
"deleted": 0
}
Administration: retention and cleanup · POST /admin/settings/snapshot-max-age
Delete a version#
Not in the story. The audited escape hatch for administrators and the Producer's maintainers;
the answer lists the Consumers that pinned it, whose pins now dangle until the number is
re-provided. Check them first with …/dependents.
curl -s "$SANSHAIN/admin/producers/payments/versions/openapi/2.1.0/dependents" -H "Authorization: Bearer $SESSION"
curl -s -X DELETE $SANSHAIN/admin/producers/payments/versions/openapi/2.1.0 -H "Authorization: Bearer $SESSION"
Expected answer of the delete
HTTP/1.1 200 OK
{ "deleted": "2.1.0", "dependents": [] }
Administration: deleting a version · DELETE /admin/producers/{name}/versions/{api_type}/{version}
Download a database snapshot (SQLite)#
The one administrative route that takes a full API token, so a CronJob can call it.
curl -fsS -H "Authorization: Bearer $SANSHAIN_TOKEN" -o sanshain-backup.db "$SANSHAIN/admin/backup/sqlite"
Recorded (story, step 8)
GET /admin/backup/sqlite HTTP/1.1
Authorization: Bearer san_…
HTTP/1.1 200 OK
content-type: application/vnd.sqlite3
content-length: 389120
content-disposition: attachment; filename="sanshain-2.10.0-20261022T182452Z.db"
<389120 bytes of SQLite database, saved as sanshain-backup.db>
Configure OpenID Connect#
API only in 2.6.0. Send "client_secret": "****" to keep the stored secret on a
later edit.
Recorded (story, step 3)
PUT /admin/auth-config HTTP/1.1
Authorization: Bearer <session token>
Content-Type: application/json
{
"auth_mode": "oidc",
"oidc_config": {
"issuer_url": "https://login.acme.corp/realms/corp",
"client_id": "sanshain",
"client_secret": "oidc-client-secret-from-the-provider",
"redirect_url": "https://sanshain.corp/auth/oidc/callback",
"username_claim": "preferred_username",
"groups_claim": "groups",
"admin_group": "sanshain-admins"
}
}
HTTP/1.1 200 OK
content-length: 0
Read the audit log#
The newest entries (limit up to 200);
/admin/observability/audit-logs/export is the whole log.
curl -s "$SANSHAIN/admin/observability/audit-logs?limit=12" -H "Authorization: Bearer $SESSION"
Recorded (story, step 9)
GET /admin/observability/audit-logs?limit=12 HTTP/1.1
Authorization: Bearer <session token>
HTTP/1.1 200 OK
content-type: application/json
content-length: 2913
[
{
"id": 28,
"timestamp": "2026-10-22T18:24:52.076181072+00:00",
"username": "root",
"action": "DATABASE_SNAPSHOT",
"details": "Produced database snapshot sanshain-2.10.0-20261022T182452Z.db (389120 bytes)",
"service": null,
"version": null,
"action_type": "ADMIN",
"diff": null,
"stream": null
},
{
"id": 27,
"timestamp": "2026-10-22T18:24:52.036848908+00:00",
"username": "root",
"action": "SNAPSHOT_CLEANUP",
"details": "Manually triggered snapshot cleanup, removed 0 snapshots",
"service": null,
"version": null,
"action_type": "ADMIN",
"diff": null,
"stream": null
},
{
"id": 26,
"timestamp": "2026-10-22T18:24:51.972549147+00:00",
"username": "root",
"action": "SET_SNAPSHOT_MAX_AGE",
"details": "Set snapshot max age to 30 days",
"service": null,
"version": null,
"action_type": "ADMIN",
"diff": null,
"stream": null
},
{
"id": 25,
"timestamp": "2026-10-22T18:24:50.828153313+00:00",
"username": "root",
"action": "WEBHOOK_CREATED",
"details": "Registered webhook 1 (global instance-wide, slack format, host receiver.sanshain.svc)",
"service": null,
"version": null,
"action_type": "ADMIN",
"diff": null,
"stream": null
},
{
"id": 24,
"timestamp": "2026-10-22T18:24:50.767441343+00:00",
"username": "root",
"action": "SET_WEBHOOK_ALLOWED_HOSTS",
"details": "Set the webhook allow-list to: hooks.slack.com, http://receiver.sanshain.svc",
"service": null,
"version": null,
"action_type": "ADMIN",
"diff": null,
"stream": null
},
{
"id": 23,
"timestamp": "2026-10-22T18:24:50.676511707+00:00",
"username": "root",
"action": "ASSIGN_MAINTAINER",
"details": "User 2 now maintains 'payments'",
"service": null,
"version": null,
"action_type": "ADMIN",
"diff": null,
"stream": null
},
{
"id": 22,
"timestamp": "2026-10-22T18:24:50.650798207+00:00",
"username": "root",
"action": "GRANT_ROLE",
"details": "Granted role 'releaser' to user 2",
"service": null,
"version": null,
"action_type": "ADMIN",
"diff": null,
"stream": null
},
{
"id": 21,
"timestamp": "2026-10-22T18:24:50.619748587+00:00",
"username": "root",
"action": "APPROVE_USER",
"details": "Approved user 'maria'",
"service": null,
"version": null,
"action_type": "ADMIN",
"diff": null,
"stream": null
},
{
"id": 20,
"timestamp": "2026-10-22T18:24:50.560242596+00:00",
"username": "DevMode/Anonymous",
"action": "REGISTER_USER",
"details": "Registered user 'maria'",
"service": null,
"version": null,
"action_type": "ADMIN",
"diff": null,
"stream": null
},
{
"id": 19,
"timestamp": "2026-10-22T18:24:50.512898157+00:00",
"username": "root",
"action": "SET_REGISTRATION",
"details": "Set registration to true",
"service": null,
"version": null,
"action_type": "ADMIN",
"diff": null,
"stream": null
},
{
"id": 18,
"timestamp": "2026-10-22T18:24:50.476581056+00:00",
"username": "root",
"action": "UPDATE_SETTINGS",
"details": "Updated auth mode to 'local' and LDAP configurations",
"service": null,
"version": null,
"action_type": "ADMIN",
"diff": null,
"stream": null
},
{
"id": 17,
"timestamp": "2026-10-22T18:24:50.424675044+00:00",
"username": "root",
"action": "UPDATE_SETTINGS",
"details": "Updated auth mode to 'oidc' and LDAP configurations",
"service": null,
"version": null,
"action_type": "ADMIN",
"diff": null,
"stream": null
}
]
Administration: the audit log · GET /admin/observability/audit-logs
Scrape the metrics#
Unauthenticated by default, always at /metrics.
curl -s $SANSHAIN/metrics | grep '^sanshain_'
Recorded (story, step 10)
$ curl -s https://sanshain.corp/metrics | grep '^sanshain_'
sanshain_branches 1
sanshain_branch_updates_total{branch="release-2026.10"} 1
sanshain_catalog_pushes_total 1
sanshain_mcp_result_bytes_total{tool="describe_service"} 2038
sanshain_mcp_tool_calls_total{tool="describe_service",outcome="ok"} 1
sanshain_version_rejected_total{reason="breaking_without_major",producer="payments"} 1
sanshain_version_rejected_total{reason="immutable_ga",producer="payments"} 1
sanshain_webhook_deliveries_total{outcome="delivered"} 1
sanshain_webhook_outbox_pending 0
Recorded against v2.6.0.