Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Granite

Beta Base URL: https://granitegranted.com

Overview

Granite asks a person for permission on behalf of an app or agent, and keeps the answer.

  1. Your app files an approval request: what it wants to do, to what, and how risky it thinks that is.
  2. The person signs in to Granite at https://granitegranted.com with their ChirpAuth account, reads the request, and approves or denies it.
  3. Granite records the decision. If the request asked for standing authority, approving it creates a grant.
  4. Before acting, your app (or the service that carries out the action) asks Granite whether the grant still covers what it is about to do. The person can revoke a grant at any time.

Granite does not carry out the action and cannot stop you from acting without asking. It answers “did this person approve this, and does that approval still hold?” Checking that answer is your service’s job.

It is for developers of apps and agents that must get a person’s explicit approval before acting for them, and for services that enforce those approvals by checking a grant before each use.

What beta means here: Granite runs in production, and Stackwell’s own services use it. App credentials are issued by hand: there is no self-serve sign-up. Some features are not finished yet.

Quickstart

This walks one approval through Granite: your app asks, a person decides, and your app reads the answer.

1. Check that Granite is reachable

No credentials needed:

curl -fsS https://granitegranted.com/health
{"service":"granite","status":"ok"}

2. Get an app credential

Email support@granitegranted.com with:

  • the app id you want (a short name such as acme), and
  • the display name people should see on the approval screen.

Stackwell registers the app and sends you a secret that starts with gas_. Granite stores only a hash of it and cannot show it again; if you lose it, ask for a new one, which replaces the old.

There is no self-serve sign-up yet.

3. File an approval request

Name the person in x-user-id by the ChirpAuth subject id (sub) your app received when they signed in to it with ChirpAuth. Granite does not treat it as proof of who the person is: it says which ChirpAuth account may claim the request (see Who owns a request).

curl https://granitegranted.com/v1/approval-requests \
  -H "Authorization: Bearer $GRANITE_APP_SECRET" \
  -H "x-granite-app-id: acme" \
  -H "x-user-id: sub_8Q3K2M11XZ" \
  -H "Content-Type: application/json" \
  -d '{
    "request_type": "one_time_action",
    "title": "Send the weekly report",
    "summary": "Acme wants to email this week'\''s report to your team list.",
    "risk_level": "low",
    "requested_action": "send email",
    "requested_resource": "mailto:team@example.com"
  }'

Expected response, 201 Created:

{
  "id": "ee2bd1ff-f8f6-4eba-ae40-cdc7188a998a",
  "owner_uid": null,
  "owner_bound": false,
  "requester_user_ref": "sub_8Q3K2M11XZ",
  "requester_app_id": "acme",
  "environment": "Prod",
  "request_type": "one_time_action",
  "title": "Send the weekly report",
  "summary": "Acme wants to email this week's report to your team list.",
  "risk_level": "low",
  "requested_action": "send email",
  "requested_resource": "mailto:team@example.com",
  "requested_scopes": [],
  "expires_at": null,
  "status": "pending",
  "created_at": "2026-11-05T19:19:06.527831736Z",
  "updated_at": "2026-11-05T19:19:06.527831736Z"
}

owner_bound: false means no person owns the request yet. requester_app_id comes from your credential, whatever the body says.

4. Send the person to the request

Give the person this link, built from the id above:

https://granitegranted.com/consent/ee2bd1ff-f8f6-4eba-ae40-cdc7188a998a

They sign in with ChirpAuth, see your request, and approve or deny it. Only the ChirpAuth account you named in x-user-id can claim it; anyone else who opens the link is told it was filed for a different account.

After that first request, Granite remembers which person your x-user-id belongs to. Later requests with the same x-user-id are bound to them when you file them (owner_bound: true) and appear in their Granite inbox without a link.

5. Read the outcome

Poll the request with the same credential:

curl https://granitegranted.com/v1/approval-requests/ee2bd1ff-f8f6-4eba-ae40-cdc7188a998a \
  -H "Authorization: Bearer $GRANITE_APP_SECRET" \
  -H "x-granite-app-id: acme" \
  -H "x-user-id: sub_8Q3K2M11XZ"

When the person has decided, status is approved or denied, and decision_id and decided_at are set. To be told instead of polling, set callback_url when you file the request; see API examples.

6. Check before you act

For a request that creates a grant, check the grant every time you rely on it, because the person can revoke it at any time:

curl https://granitegranted.com/v1/grants/verify \
  -H "Authorization: Bearer $GRANITE_APP_SECRET" \
  -H "x-granite-app-id: acme" \
  -H "x-user-id: sub_8Q3K2M11XZ" \
  -H "Content-Type: application/json" \
  -d '{"grant_id": "b6ddef35-fe77-43be-a871-bb80d528906a", "scopes": ["drive.read"]}'

Act only if the response has "approved": true. See Concepts for the rules.

Authentication

An app calls Granite with an app credential (Authorization: Bearer gas_... plus x-granite-app-id), issued by Stackwell on request; see step 2. An agent or service can instead use a ChirpAuth machine token from a ChirpAuth client Granite accepts. Either way, you name the person by their ChirpAuth subject id in x-user-id. People approve and deny with their own Granite sign-in, which apps cannot use. Authentication has the headers and a table of what each credential may do.

API reference

The full endpoint list is in the generated API reference. API examples walks through the endpoints an app uses, with real requests and responses, and Concepts explains the rules they enforce.

Conventions shared by every endpoint:

  • Base URL https://granitegranted.com; every path starts with /v1/.
  • Request and response bodies are JSON; send Content-Type: application/json.
  • Times are RFC 3339 in UTC. Ids are UUIDs.
  • Lists are not paged.
  • There are no idempotency keys: every POST /v1/approval-requests files a new request.

Errors and limits

Errors are JSON, {"error": "<message>"}, except parsing errors, which are plain text. Filing requests is rate-limited per caller, and request bodies are capped at 64 KiB. Errors and limits lists the status codes, the limits, and where each limit applies.

Data and privacy

Granite keeps requests, decisions and grants with no time limit, and it learns no email address or name. Deleting an account marks the person’s records deleted: Granite stops returning them, and their grants stop verifying. Data and privacy says what is stored, who sees it, and what deletion does.