Authentication
Scopes define which services and APIs a key may call.
A managed key defaults to full scope, meaning it may read and write every service in the catalogue. Scopes narrow that surface per key, so a leaked or misused key can never do more than you granted it.
A scope is <providerId>:read or <providerId>:write. The <providerId> part is the service id, matching the first path segment in the API reference, for example catch-table or baemin. You set scopes when issuing a key, and one key can combine several. The read and write split follows the HTTP method: GET is read, and mutating calls such as POST and DELETE are write.
<providerId>:read allows the service's read APIs.<providerId>:write allows the mutating APIs and implies read for the same service. A key with only write can still look things up.*:read is the only wildcard. It allows reads on every service, current and future, and never allows a write. There is no write wildcard.| Scope set | Allows | Denies |
|---|---|---|
catch-table:read | Catch Table reads such as Search restaurants and Get availability | Catch Table writes and every other service |
catch-table:write | All of Catch Table, including Create reservation, since write implies read | Every other service |
*:read | Reads on every service | Every write |
*:read, catch-table:write | Reads everywhere plus Catch Table writes | Writes on any other service |
| (none set) | Full scope: reads and writes on every service | Nothing |
When a key calls outside its scopes, the gateway answers 403 before anything reaches the service. The error envelope carries code scope_denied and retryable is false, so retrying the same request with the same key never changes the outcome. The response has no provider field because this error originates at the gateway, not at a service.
# This key only carries catch-table:read.
curl -X POST "https://api.frism.dev/v1/catch-table/reservations" \
-H "Authorization: Bearer frism_test_k3xample" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: idem_b1f7c2d0" \
-d '{
"restaurant_id": "res_01J8Q9W3TE",
"slot_id": "slt_1900_2",
"party_size": 2,
"contact_name": "Han Jiwoo"
}'{
"error": {
"code": "scope_denied",
"message": "This key does not carry catch-table:write.",
"request_id": "req_01J8QGX2M4",
"retryable": false
}
}Treat scope_denied as a configuration bug, not a runtime condition. Log the request_id, then fix either the key's scopes or the code path. Always branch on error.code; never parse the message.
const response = await fetch(
"https://api.frism.dev/v1/catch-table/reservations",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FRISM_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "idem_b1f7c2d0",
},
body: JSON.stringify({
restaurant_id: "res_01J8Q9W3TE",
slot_id: "slt_1900_2",
party_size: 2,
contact_name: "Han Jiwoo",
}),
},
);
if (!response.ok) {
const { error } = await response.json();
if (error.code === "scope_denied") {
// Configuration problem: check the key's scopes instead of retrying.
throw new Error(`Missing scope for catch-table (${error.request_id})`);
}
throw new Error(`${error.code}: ${error.message}`);
}
const reservation = await response.json();In the sandbox a full scope key is fine for experiments. On a live key, the blast radius of a leak is exactly the scope you granted. Before going live, apply the following.
*:read; a booking backend fits catch-table:write.read until a code path actually mutates something. Adding write later is a dashboard change plus a , not a code change.The service ids used in scopes appear in each service's API reference, for example . The 403 mapping and every other status code live in the .