Developers
Granite · integration

File approval requests, learn the outcome

Your app or agent asks the user for permission through Granite. The user decides; you receive the outcome correlated by the requester_user_ref you sent. Granite never learns their email or name, so it never gives you either.

The model

01

Register an app

Operator-issued today — email support@ to get set up.
support@
02

Receive an app credential

A bearer secret, scoped to your app — sent with x-granite-app-id.
app_key
03

File approval requests

One request per decision you need from the user.
POST /v1/approval-requests
04

The user decides in Granite

Approve or deny — high risk takes a deliberate hold.
in-app
05

Learn the outcome

Decision webhook to your callback URL, or poll the request.
webhook · poll
06

Verify standing grants before acting

Confirm a grant is still active each time you rely on it.
POST /v1/grants/verify

File a request

Describe what you want, where it reaches, and the risk you assess. Granite renders it to the user and holds the pending decision until they act or it expires. Name the user in x-user-id by the ChirpAuth subject id (sub) your app received when they signed in to it; only that ChirpAuth account can claim the request. A request starts unbound until the user opens https://granitegranted.com/consent/{id} signed in to Granite. Once they claim one, your later requests with the same x-user-id are bound when you file them.

Request · curl
POST /v1/approval-requestsrequest
# your app credential + the user's ChirpAuth sub, as your app received it
curl https://granitegranted.com/v1/approval-requests \
  -H "Authorization: Bearer $GRANITE_APP_SECRET" \
  -H "x-granite-app-id: atlas" \
  -H "x-user-id: sub_8Q3K2M11XZ" \
  -H "Content-Type: application/json" \
  -d '{
    "request_type":       "storage_grant",
    "title":              "Read your project folder",
    "summary":            "Atlas wants read access to one folder.",
    "risk_level":         "medium",
    "requested_action":   "read files",
    "requested_resource": "storage:drive:folder_123",
    "requested_scopes":   ["drive.read"],
    "callback_url":       "https://atlas.example/granite/callback"
  }'
201 Createdresponse
// abridged
{
  "id":                 "2c8be6d4-d657-48ef-b3f5-923c4ed975df",
  "owner_uid":          null,                // unbound until the user claims it
  "owner_bound":        false,
  "requester_user_ref": "sub_8Q3K2M11XZ", // your x-user-id, echoed
  "requester_app_id":   "atlas",           // from your credential, not the body
  "request_type":       "storage_grant",
  "risk_level":         "medium",
  "status":             "pending",
  "created_at":         "2026-06-09T14:21:07Z"
}

Learn the outcome

When the user decides, Granite POSTs to your callback URL (register a callback_secret and the body is HMAC-signed). Delivery is a single attempt with no retry, so treat the webhook as a nudge and poll as the source of truth. Polling reads the request as its requester: only the app that filed it can read it this way, whoever owns it, and x-user-id does not change what you can see. Only the user can decide it, on their own Granite session.

Decision webhook · received at your callback
POST https://atlas.example/granite/webhookpayload
{
  "event":              "approval.decided",
  "request_id":         "2c8be6d4-d657-48ef-b3f5-923c4ed975df",
  "decision":           "approved",
  "status":             "approved",
  "requester_user_ref": "sub_8Q3K2M11XZ",
  "requester_app_id":   "atlas",
  "trace_id":           null,
  "decided_at":         "2026-06-09T14:23:40Z"
}
Or poll · curl
GET /v1/approval-requests/{id}request
curl https://granitegranted.com/v1/approval-requests/2c8be6d4-d657-48ef-b3f5-923c4ed975df \
  -H "Authorization: Bearer $GRANITE_APP_SECRET" \
  -H "x-granite-app-id: atlas" \
  -H "x-user-id: sub_8Q3K2M11XZ"
# → { "status": "approved", "decision_id": "…", "grant_id": "…", ... }
# grant_id is there only if the approval created a grant; a one_time_action has none

Verify before acting

A standing grant can be revoked by the user at any time. Verify it is still active immediately before you rely on it — do not cache the answer. A one_time_action creates no grant: verify it by request_id and name your app, { "request_id": "…", "subject_app_id": "atlas" }.

Verify a grant · curl
POST /v1/grants/verifyrequest
curl https://granitegranted.com/v1/grants/verify \
  -H "Authorization: Bearer $GRANITE_APP_SECRET" \
  -H "x-granite-app-id: atlas" \
  -H "x-user-id: sub_8Q3K2M11XZ" \
  -H "Content-Type: application/json" \
  -d '{ "grant_id": "7c01…", "resource": "storage:drive:folder_123", "scopes": ["drive.read"] }'
200 OKresponse
{
  "approved": true,
  "grant": {
    "id":     "7c01…",
    "status": "active",
    "scopes": ["drive.read"]
  }
}
# revoked / expired / scope-mismatch → { "approved": false, "reason": "…" }

Identity you receive

You getThe requester_user_ref you sent, the decision, the granted scopes, and your trace_id echoed back. Once the user claims a request, reading it and verifying its grant also return owner_uid: Granite's id for the person, the same for every app they use through Granite. Key your records on requester_user_ref.
You neverNo email and no real name: Granite does not learn them. The decision webhook omits owner_uid.

Get credentials

Operator-issued today

Credentials are issued by hand while Granite is invitation-stage. Email support@granitegranted.com to register your app — there is no self-serve signup yet; a person reads and replies.