Rotate the policy gate signing key

Embed
Preview of the card
<a href="https://forumulate.com/reference/forumulate-api-policygateadmin-rotate"><img src="https://forumulate.com/reference/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/8042f004-e3d9-445f-b7a4-b6c1f86836e6/pages/19e37020-b615-48a7-bb05-75289d80574c/social-card.svg?v=3e7ae33eec8b" alt="Rotate the policy gate signing key" width="600" height="315"></a>
POSThttps://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/policy-gate/keys/rotate

Mints a new signing key and returns its secret ONCE (only an encrypted copy is kept). The previous key keeps verifying tickets and tokens for overlapHours (default 24, 0–168), so you can deploy the new secret without a single failed verification. Configure your app with both kid:secret pairs during the overlap. Needs an admin-scope token: a secret that signs tickets can accept in any user's name.

Requires token scope: admin — see "Token scopes" under Authentication.

Parameters

orgIdstringpathrequired
The organization: its uuid, or its slug. One you are not a member of is a 404.
spaceIdstringpathrequired
The space: its uuid, or its slug within the organization. An id further down the path that belongs to a DIFFERENT space is a 404, not a cross-space read.
forumulate-versionstringheader
API version negotiation. Send forumulate-version: 2027-03-01 on every request from a new integration; it is optional only so that integrations written before versioning keep working. Each version includes everything before it:
Idempotency-Keystringheader
Makes this write safe to retry. Optional — send any unique string (a UUID is ideal) and the first request runs while its response is remembered under that key for 24 hours; an identical retry replays that same status and body instead of acting twice, so a timeout or a 5xx from the edge can be retried without creating a second row. A retry sent while the first request is still in flight is refused with 409. The key is scoped to the method and path, so the same key on a different endpoint is a different request. Error responses of 5xx are never cached — those retries re-run. A key is bound to the BODY it was first used with: reusing it with a different body is a 409 idempotency.key_reused, not a replay, because replaying would tell you a write landed that never ran. Generate one key per request, never per process or per session.
dryRunbooleanquery
true: authenticate, authorize and validate this request (body, query and path), then answer 200 {dryRun: true, ok: true, operation, checks} WITHOUT carrying it out — nothing is written, queued, mailed or announced. It does not compute a diff, and a write that passes may still be refused by a state check at write time (a slug taken in between). Anything other than true/false is a 422.

Request body

application/json
overlapHoursinteger
{
  "overlapHours": 0
}

Code samples

cURL
curl -X POST "https://forumulate.com/api/orgs/{orgId}/spaces/{spaceId}/policy-gate/keys/rotate" \
  -H "Authorization: Bearer $FORUMULATE_API_KEY" \
  -H "forumulate-version: <value>" \
  -H "Idempotency-Key: <value>" \
  -H "Content-Type: application/json" \
  -d '{"overlapHours":0}'
TypeScript
Python
Go
PHP
Ruby
Java

Responses

201 Created. This 201 is a RESULT, not a create: no addressable resource was made, so there is no data mirror and nothing to GET back. An acknowledgement, a computed answer, a tally or a one-time credential — read the keys this response documents.
Example
{
  "keys": [
    {
      "active": true,
      "createdAt": "string",
      "kid": "string",
      "primary": true,
      "retiresAt": "string",
      "revokedAt": "string"
    }
  ],
  "kid": "string",
  "secret": "string",
  "warnings": [
    "tags: \"Launch Day\" is stored as \"launch-day\""
  ]
}
Schema
keysPolicyGateKeySummary[]required
Show child parameters
activebooleanrequired
createdAtstringrequired
kidstringrequired
primarybooleanrequired
retiresAtstringrequired
revokedAtstringrequired
kidstringrequired
secretstringrequired
warningsstring[]
Values we ACCEPTED and then rewrote, one sentence each — a slugified tag, a normalised colour, a clamped range, a slug that was taken. Absent when nothing was rewritten, never []. The same sentences are on the X-Field-Warnings response header. If this is present, something you sent is not what is stored; read the resource back rather than assuming your own value.
400 The request could not be understood — a malformed header, path or query value (request.malformed). A value that was understood but is not acceptable is a 422 validation.failed.
Example
{
  "error": {
    "code": "string",
    "message": "string",
    "details": {},
    "docs": "string",
    "reason": "string",
    "slugs": [
      "string"
    ],
    "conflict": {
      "etag": "string",
      "ops": [
        {}
      ]
    }
  },
  "requestId": "string"
}
Schema
errorobjectrequired
Show child parameters
codestringrequired
Stable, machine-readable error code, e.g. rbac.forbidden.
messagestringrequired
Human-readable explanation.
detailsobject
Field-level validation problems, keyed by field path (422 only).
docsstring
Where the vocabulary or format this error is about is published — an absolute URL, or an API route such as GET /space-types/{type}/presets. Present on errors over a controlled list, so the answer to "what am I allowed to send?" is in the error itself.
reasonstring
Why a credential was refused, as one stable word, where one status has several causes — e.g. an external_jwt redeem answers iat_too_old, iat_in_future, bad_signature, bad_issuer, bad_audience, expired and more. Branch on this, not on message.
slugsstring[]
The exact slugs that collided, on a slug-conflict 409 — space.slug_taken, page.slug_taken, collection.slug_taken, site.slug_taken or badge.slug_taken.
conflictobject
On a 409 from a content entry field write (PATCH …/entries/{entryId}/fields) whose If-Match was stale.
Show child parameters
etagstringrequired
The entry's current fields etag.
opsarrayrequired
The field operations since your If-Match that touch a path your own operations touch. Empty: none did — retry the batch unchanged under etag. null: the history no longer reaches your tag — re-read the entry first.
requestIdstring
The id of this request — the same value as the x-request-id and x-forumulate-request-id response headers. Quote it in a bug report: it is the only thing that finds your exact request in our logs. It is in the body because that is the part that survives — an SDK that throws the parsed body, a serverless log that keeps the body and drops the headers, a ticket with the JSON pasted in.
401 No credential, or one that is expired or revoked (auth.required). Send a Bearer token or the session cookie.

Same as 400 · POST /orgs/{orgId}/spaces/{spaceId}/policy-gate/keys/rotate

403 Authenticated, and not allowed — always rbac.forbidden, whether what is missing is the capability, the token scope, or a resource the token is pinned away from. The message names the capability and, for a scope refusal, the scopes that would grant it. A refusal because of the PLAN is a 402 with quota.exceeded, not this.

Same as 400 · POST /orgs/{orgId}/spaces/{spaceId}/policy-gate/keys/rotate

404 No such thing — one of the ids in the path does not resolve, or resolves to something this caller may not see (*.not_found).

Same as 400 · POST /orgs/{orgId}/spaces/{spaceId}/policy-gate/keys/rotate

422 The body was understood and a value is unacceptable (validation.failed). error.details names the field paths.

Same as 400 · POST /orgs/{orgId}/spaces/{spaceId}/policy-gate/keys/rotate

429 Rate limited — see the RateLimit-* headers.

Same as 400 · POST /orgs/{orgId}/spaces/{spaceId}/policy-gate/keys/rotate

default Error — the stable { error: { code, message } } envelope.

Same as 400 · POST /orgs/{orgId}/spaces/{spaceId}/policy-gate/keys/rotate

The Forumulate teamUpdated