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

API examples

The main endpoints an app uses, with real requests and responses. The full endpoint list is in the generated API reference.

Base URL: https://granitegranted.com. Bodies are JSON (Content-Type: application/json); times are RFC 3339 in UTC; ids are UUIDs. Examples use an app credential; see Authentication for the headers. $AUTH below stands for:

AUTH=(-H "Authorization: Bearer $GRANITE_APP_SECRET"
      -H "x-granite-app-id: acme"
      -H "x-user-id: sub_8Q3K2M11XZ")

File a request

POST /v1/approval-requests

curl https://granitegranted.com/v1/approval-requests "${AUTH[@]}" \
  -H "Content-Type: application/json" \
  -d '{
    "request_type": "storage_grant",
    "title": "Read your project folder",
    "summary": "Acme wants read access to one folder.",
    "risk_level": "medium",
    "requested_action": "read files",
    "requested_resource": "storage:drive:folder_123",
    "requested_scopes": ["drive.read", "drive.write"],
    "expires_at": "2027-01-01T00:00:00Z",
    "callback_url": "https://acme.example/granite/callback",
    "callback_secret": "<random secret you keep>"
  }'

Body fields:

FieldRequiredRules
titleYes1–160 characters
summaryYes1–1000 characters
requested_actionYes1–160 characters
requested_resourceYes1–500 characters; storage:{provider}:{resource} for storage_grant
request_typeNoone_time_action (default), storage_grant, capability_grant, delegation_grant
risk_levelNolow, medium (default), high. Your assessment, shown to the person.
requested_scopesNoUp to 50, each up to 160 characters. Blank and repeated (ignoring case) entries are dropped.
requested_resourcesNoThe full set of resources one grant covers, each with the same rules as requested_resource. When given, the grant covers exactly this set, so for a grant request it must include requested_resource, and a storage grant’s entries must all name the same provider. A grant that needs a Warden passkey must cover one resource only, because the signed approval names a single resource.
proposed_limitsNoAny JSON. Copied to the grant’s limits unless the person sets their own. Granite stores it; it does not enforce it.
expires_atNoMust be in the future. The deadline to decide, and the grant’s expiry.
requester_chirp_subNoUp to 120 characters: the ChirpAuth subject of the agent that will use the grant. Needed for grants that require a Warden passkey. Granite does not check it against your credential.
trace_idNoUp to 160 characters, echoed back and in the webhook
callback_urlNohttps:// only, up to 500 characters, no user or password, not localhost or a private address
callback_secretNoKey for signing the webhook. Stored encrypted; never returned.
detail_urlNohttps:// link to your own page about this request; the Granite web app links to it
return_urlNohttps://; after the person approves on the consent page (/consent/{id}), the browser is sent there with ?request_id={id} added
structured_detailNoAny JSON up to 16 KiB; the Granite web app shows it to the person

Text fields are trimmed and may not contain control characters. requester_app_id is set from your credential.

Response, 201 Created. This person had already claimed an earlier request from this app under the same x-user-id, so the request is bound at once:

{
  "id": "750894c1-8966-4c50-84ac-0ee94c89891d",
  "owner_uid": "gr_sub_alice",
  "owner_bound": true,
  "requester_user_ref": "sub_8Q3K2M11XZ",
  "requester_app_id": "acme",
  "environment": "Prod",
  "request_type": "storage_grant",
  "title": "Read your project folder",
  "summary": "Acme wants read access to one folder.",
  "risk_level": "medium",
  "requested_action": "read files",
  "requested_resource": "storage:drive:folder_123",
  "requested_scopes": ["drive.read", "drive.write"],
  "expires_at": "2027-01-01T00:00:00Z",
  "status": "pending",
  "callback_url": "https://acme.example/granite/callback",
  "created_at": "2026-11-05T19:19:06.532554343Z",
  "updated_at": "2026-11-05T19:19:06.532554343Z"
}

owner_uid is Granite’s id for the person once the request is bound. It is the same for every app that person uses through Granite, so use requester_user_ref (the id you sent) for your records.

Read a request

GET /v1/approval-requests/{id} returns the request in the shape above, without owner_bound. You can read only requests your app filed. After a decision it also carries:

{
  "status": "approved",
  "decision_id": "f9634e08-aa1e-4174-95cc-6c9596daceb2",
  "decided_at": "2026-11-05T19:19:06.532727277Z",
  "grant_id": "b6ddef35-fe77-43be-a871-bb80d528906a"
}

grant_id is present only when the approval created a grant. decision_reason is present when the person gave one. A withdrawn request has withdrawn_at instead.

Withdraw a request

POST /v1/approval-requests/{id}/withdraw (needs write, no body) takes back a pending request your app filed. It returns the request with "status": "withdrawn". It returns 409 if the request is no longer pending. No decision is recorded and no webhook is sent.

Verify a grant

POST /v1/grants/verify

curl https://granitegranted.com/v1/grants/verify "${AUTH[@]}" \
  -H "Content-Type: application/json" \
  -d '{
    "grant_id": "b6ddef35-fe77-43be-a871-bb80d528906a",
    "resource": "storage:drive:folder_123",
    "scopes": ["drive.read"]
  }'
FieldRules
grant_id or request_idOne is required (400 otherwise). grant_id wins if both are sent.
resourceOptional. Must be one the grant covers.
scopesOptional. Every one must be held by the grant.
subject_app_id, subject_chirp_sub, subject_client_idOptional. The actor; see Checking a grant.

Response, 200 OK:

{
  "approved": true,
  "grant": {
    "id": "b6ddef35-fe77-43be-a871-bb80d528906a",
    "request_id": "750894c1-8966-4c50-84ac-0ee94c89891d",
    "owner_uid": "gr_sub_alice",
    "subject_app_id": "acme",
    "service_id": "storage:drive",
    "resource": "storage:drive:folder_123",
    "scopes": ["drive.read"],
    "expires_at": "2027-01-01T00:00:00Z",
    "status": "active",
    "environment": "Prod",
    "created_at": "2026-11-05T19:19:06.532727277Z",
    "revoked_at": null
  }
}

Here the person approved only drive.read, so asking for drive.write returns 200 with:

{
  "approved": false,
  "reason": "grant does not cover requested scopes",
  "grant": { "...": "as above" }
}

A “no” is 200 with "approved": false and a reason meant for people; don’t parse it. An unknown grant_id is 404. Verifying by request_id can also return the request.

Revoke a grant

POST /v1/grants/{grant_id}/revoke (needs write, no body). Your app can revoke grants whose subject is your app. Returns the grant with "status": "revoked" and revoked_at set. Revoking a revoked grant returns it unchanged.

Give up every grant you hold for a person

POST /v1/grants/relinquish (needs write) revokes every active grant whose subject is your app (and, for a machine token, your token’s subject) for one person, without their grant ids. Use it when a person leaves your app. Prove which person with their ChirpAuth ID token for your app’s own sign-in, not x-user-id:

{ "user_token": "<the person's ID token for your sign-in client>" }

It returns the ids it revoked, empty if there were none:

{ "revoked_grant_ids": ["5f0c3f5e-1d43-4d1e-9a51-0c6ab1f1e2a4"] }

Granite must know your sign-in client first; to have it added, email support@granitegranted.com. Until then, and for a token issued to any other client, the call is 403. Other apps’ grants for the person are untouched.

Create a connection

POST /v1/connections (needs write, body {}) lets a person link your x-user-id to their Granite account before you file anything:

{
  "id": "913c13ec-cd44-4e1f-b76a-80cf604b1162",
  "app_id": "acme",
  "requester_user_ref": "sub_8Q3K2M11XZ",
  "environment": "Prod",
  "status": "pending",
  "created_at": "2026-11-05T19:19:06.540000000Z",
  "confirmed_at": null
}

Send the person to https://granitegranted.com/consent/connections/{id}. Only the ChirpAuth account you named in x-user-id can confirm it. Once they confirm, your requests with that x-user-id are bound to them when filed.

Decision webhook

When the owner approves or denies a request with a callback_url, Granite sends:

POST https://acme.example/granite/callback
Content-Type: application/json
X-Granite-Signature: sha256=<hex>
X-Granite-Timestamp: <unix seconds>
X-Granite-Nonce: <uuid>
{
  "event": "approval.decided",
  "request_id": "750894c1-8966-4c50-84ac-0ee94c89891d",
  "decision": "approved",
  "status": "approved",
  "requester_user_ref": "sub_8Q3K2M11XZ",
  "requester_app_id": "acme",
  "trace_id": null,
  "decided_at": "2026-11-05T19:19:06.532727277Z"
}

The three X-Granite-* headers are sent only if you set callback_secret. The signature is sha256= followed by the hex HMAC-SHA256, keyed with your callback_secret, of {timestamp}.{nonce}.{raw body}. Check it against the raw bytes you received, reject timestamps far from your clock, and drop a nonce you have already seen. For example:

expected="sha256=$(printf '%s.%s.%s' "$TIMESTAMP" "$NONCE" "$BODY" \
  | openssl dgst -sha256 -hmac "$CALLBACK_SECRET" | cut -d' ' -f2)"
[ "$expected" = "$SIGNATURE" ] && echo valid

The payload never contains owner_uid. Delivery is one attempt; see Concepts.

For people: the Granite apps’ endpoints

These take a person’s Granite sign-in and are what the Granite web and Android apps call. Your app cannot call them.

EndpointWhat it does
GET /v1/approval-requests?status=pendingThe person’s requests; status and since (RFC 3339, by updated_at) are optional filters
GET /v1/approval-requests/{id}Open a request from its link. A request your app filed for this person becomes theirs here, as on the consent page
POST /v1/approval-requests/{id}/approveApprove. Optional body: approved_scopes (a subset), approved_limits, reason, warden_assertion
POST /v1/approval-requests/{id}/denyDeny. Optional body: reason
GET /v1/grantsThe person’s grants
GET /v1/connections/{id}The app asking to connect, and whether it is connected yet
POST /v1/connections/{id}/confirmConnect the app to this person
GET /v1/me/exportThe person’s requests, decisions, grants, devices and notification settings, as JSON
DELETE /v1/meDelete the person’s Granite data; see Data and privacy

Public endpoints

curl -fsS https://granitegranted.com/health
curl -fsS https://granitegranted.com/v1/transparency/jwks