Documentation for v2.6.1. 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>"
}

Using the API: sessions and API tokens

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>"
}

Authentication: the initial admin and root

Approve a user#

Find the id in GET /admin/users, then approve. The Auto-approve users switch 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: approving users · 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

Roles and permissions: roles · POST /admin/users/{id}/roles

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": "733f1a00",
    "paused": false,
    "allow_list_exempt": true,
    "created_by": "root",
    "created_at": "2026-10-18T16:08:04Z"
  },
  "secret": "whsec_dff8fd17480e18ffe2c1326c0505c52a7f108164672679b0bd38f6b745af9560"
}
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": "236134cc-4cfb-474c-8ae9-d98fc9623024"
}

Webhooks: hooks in three scopes · POST /admin/webhooks

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=1792339684,v1=8f44fc22dafc56fcbf840795d318e3f188019728c64d31b7a9df5e1ca73b9e08
User-Agent: Sanshain/2.6.1

{
  "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_dff8fd17480e18ffe2c1326c0505c52a7f108164672679b0bd38f6b745af9560
signature header: t=1792339684,v1=8f44fc22dafc56fcbf840795d318e3f188019728c64d31b7a9df5e1ca73b9e08
secret from the create answer: whsec_dff8fd17480e18ffe2c1326c0505c52a7f108164672679b0bd38f6b745af9560
HMAC-SHA256 of '<t>.<raw body>': 8f44fc22dafc56fcbf840795d318e3f188019728c64d31b7a9df5e1ca73b9e08
valid: True

Webhooks: verifying the signature

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_…" }

POST /admin/webhooks/{id}/rotate-secret

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": "236134cc-4cfb-474c-8ae9-d98fc9623024",
    "event_type": "webhook.test",
    "occurred_at": "2026-10-18T16:08:04Z",
    "status": "delivered",
    "attempts": 1,
    "next_attempt_at": "2026-10-18T16:17:24Z",
    "last_response_code": 200,
    "last_error": null,
    "delivered_at": "2026-10-18T16:08:04Z",
    "redelivered_at": null,
    "updated_at": "2026-10-18T16:08:04Z"
  }
]

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)

PATCH /admin/webhooks/{id} · DELETE /admin/webhooks/{id}

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: 385024
content-disposition: attachment; filename="sanshain-2.6.1-20261018T160805Z.db"

<385024 bytes of SQLite database, saved as sanshain-backup.db>

Backup: SQLite · Backup: restore · GET /admin/backup/sqlite

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

Authentication: OpenID Connect · PUT /admin/auth-config

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

[
  {
    "id": 25,
    "timestamp": "2026-10-18T16:08:05.620855031+00:00",
    "username": "root",
    "action": "DATABASE_SNAPSHOT",
    "details": "Produced database snapshot sanshain-2.6.1-20261018T160805Z.db (385024 bytes)",
    "service": null,
    "version": null,
    "action_type": "ADMIN",
    "diff": null,
    "stream": null
  },
  {
    "id": 24,
    "timestamp": "2026-10-18T16:08:05.579860513+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": 23,
    "timestamp": "2026-10-18T16:08:05.510968713+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": 22,
    "timestamp": "2026-10-18T16:08:04.314122960+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": 21,
    "timestamp": "2026-10-18T16:08:04.228415162+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": 20,
    "timestamp": "2026-10-18T16:08:04.100843100+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": 19,
    "timestamp": "2026-10-18T16:08:04.065233157+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": 18,
    "timestamp": "2026-10-18T16:08:04.021717238+00:00",
    "username": "root",
    "action": "APPROVE_USER",
    "details": "Approved user 'maria'",
    "service": null,
    "version": null,
    "action_type": "ADMIN",
    "diff": null,
    "stream": null
  },
  {
    "id": 17,
    "timestamp": "2026-10-18T16:08:03.947200140+00:00",
    "username": "DevMode/Anonymous",
    "action": "REGISTER_USER",
    "details": "Registered user 'maria'",
    "service": null,
    "version": null,
    "action_type": "ADMIN",
    "diff": null,
    "stream": null
  },
  {
    "id": 16,
    "timestamp": "2026-10-18T16:08:03.877597934+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": 15,
    "timestamp": "2026-10-18T16:08:03.803321289+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
  },
  {
    "id": 14,
    "timestamp": "2026-10-18T16:08:03.681745512+00:00",
    "username": "root",
    "action": "CHANGE_PASSWORD",
    "details": "Successfully changed user password",
    "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_catalog_pushes_total 1
sanshain_mcp_result_bytes_total{tool="describe_service"} 1682
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

Observability: Prometheus metrics

Recorded against v2.6.0.