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:
| Field | Required | Rules |
|---|---|---|
title | Yes | 1–160 characters |
summary | Yes | 1–1000 characters |
requested_action | Yes | 1–160 characters |
requested_resource | Yes | 1–500 characters; storage:{provider}:{resource} for storage_grant |
request_type | No | one_time_action (default), storage_grant, capability_grant, delegation_grant |
risk_level | No | low, medium (default), high. Your assessment, shown to the person. |
requested_scopes | No | Up to 50, each up to 160 characters. Blank and repeated (ignoring case) entries are dropped. |
requested_resources | No | The 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_limits | No | Any JSON. Copied to the grant’s limits unless the person sets their own. Granite stores it; it does not enforce it. |
expires_at | No | Must be in the future. The deadline to decide, and the grant’s expiry. |
requester_chirp_sub | No | Up 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_id | No | Up to 160 characters, echoed back and in the webhook |
callback_url | No | https:// only, up to 500 characters, no user or password, not localhost or a private address |
callback_secret | No | Key for signing the webhook. Stored encrypted; never returned. |
detail_url | No | https:// link to your own page about this request; the Granite web app links to it |
return_url | No | https://; after the person approves on the consent page (/consent/{id}), the browser is sent there with ?request_id={id} added |
structured_detail | No | Any 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"]
}'
| Field | Rules |
|---|---|
grant_id or request_id | One is required (400 otherwise). grant_id wins if both are sent. |
resource | Optional. Must be one the grant covers. |
scopes | Optional. Every one must be held by the grant. |
subject_app_id, subject_chirp_sub, subject_client_id | Optional. 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.
| Endpoint | What it does |
|---|---|
GET /v1/approval-requests?status=pending | The 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}/approve | Approve. Optional body: approved_scopes (a subset), approved_limits, reason, warden_assertion |
POST /v1/approval-requests/{id}/deny | Deny. Optional body: reason |
GET /v1/grants | The person’s grants |
GET /v1/connections/{id} | The app asking to connect, and whether it is connected yet |
POST /v1/connections/{id}/confirm | Connect the app to this person |
GET /v1/me/export | The person’s requests, decisions, grants, devices and notification settings, as JSON |
DELETE /v1/me | Delete 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