OAuth
Let another app act on a team's account without anyone handing it an API key. The app registers itself, sends a person to approve it, and gets a token for one team, carrying only the scopes they approved.
The flow, step by step
This is OAuth 2.1's authorization code flow with PKCE. Every client is a public client: there is no client secret to keep, and token_endpoint_auth_method is always none. An OAuth library that supports PKCE and dynamic client registration does all of this for you; the steps are what it does.
1. Register the client
Once per app, unauthenticated. The answer carries your rkoc_ client id and a rkor_ registration access token, shown exactly once — it is the only credential that can read, replace or delete the client later.
curl -X POST "https://api.rasket.com/oauth/register" \
-H "User-Agent: acme-billing/1.0" \
-H "Content-Type: application/json" \
-d '{
"client_name": "Acme Invoices",
"redirect_uris": ["https://invoices.acme.example/rasket/callback"],
"scope": "emails:send emails:read"
}'const response = await fetch("https://api.rasket.com/oauth/register", {
method: "POST",
headers: {
"User-Agent": "acme-billing/1.0",
"Content-Type": "application/json",
},
body: JSON.stringify({
client_name: "Acme Invoices",
redirect_uris: ["https://invoices.acme.example/rasket/callback"],
scope: "emails:send emails:read"
}),
});
const data = await response.json();import os
import requests
response = requests.post(
"https://api.rasket.com/oauth/register",
headers={
"User-Agent": "acme-billing/1.0",
},
json={
"client_name": "Acme Invoices",
"redirect_uris": ["https://invoices.acme.example/rasket/callback"],
"scope": "emails:send emails:read"
},
)
print(response.json())2. Make a PKCE pair
A fresh random verifier for every authorization, and its SHA-256 hash as the challenge. The method is S256; plain is refused.
import { createHash, randomBytes } from "node:crypto";
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const state = randomBytes(16).toString("base64url");
// Keep verifier and state in the person's session until the browser comes back.3. Send the person to authorize
Open this URL in their browser. They sign in, choose one team, and approve the scopes you asked for.
https://api.rasket.com/oauth/authorize?response_type=code&client_id=rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C&redirect_uri=https%3A%2F%2Finvoices.acme.example%2Frasket%2Fcallback&scope=emails%3Asend+emails%3Aread&state=af0ifjsldkj&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256The browser comes back to your redirect URI with a code and your state:
https://invoices.acme.example/rasket/callback?code=rkc_…&state=af0ifjsldkjCheck that state is the one you stored before doing anything else. A refusal comes back the same way with error=access_denied.
4. Exchange the code
Within 10 minutes, with the verifier from step 2 and the same redirect URI. The code works once.
curl -X POST "https://api.rasket.com/oauth/token" \
-H "User-Agent: acme-billing/1.0" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"client_id": "rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
"code": "rkc_…",
"redirect_uri": "https://invoices.acme.example/rasket/callback",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}'const response = await fetch("https://api.rasket.com/oauth/token", {
method: "POST",
headers: {
"User-Agent": "acme-billing/1.0",
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
client_id: "rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
code: "rkc_…",
redirect_uri: "https://invoices.acme.example/rasket/callback",
code_verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}),
});
const data = await response.json();import os
import requests
response = requests.post(
"https://api.rasket.com/oauth/token",
headers={
"User-Agent": "acme-billing/1.0",
},
json={
"grant_type": "authorization_code",
"client_id": "rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C",
"code": "rkc_…",
"redirect_uri": "https://invoices.acme.example/rasket/callback",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
},
)
print(response.json())The token endpoint also accepts application/x-www-form-urlencoded, which is what a conforming OAuth client sends.
5. Call the API
The access token goes where an API key goes, and every endpoint its scopes reach answers exactly as it would to a key. There is no team parameter: the token already belongs to the team the person chose.
POST https://api.rasket.com/emails
Authorization: Bearer rko_…
User-Agent: acme-billing/1.0
Content-Type: application/json- A scope the grant does not hold is
403 invalid_permission, withWWW-Authenticate: Bearer error="insufficient_scope", naming the scope needed when there is one. - An expired or revoked token is
401 missing_api_key. Refresh, or send the person through authorize again.
6. Refresh before the hour is up
An access token lasts 1 hour. Trade the refresh token for a new pair, and store the new refresh token: the one you presented stops working at that moment.
POST https://api.rasket.com/oauth/token
User-Agent: acme-billing/1.0
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&client_id=rkoc_7Fq2Lm9pXw3Kd8Vn1Zt5Rb4C&refresh_token=rkr_…Present an old refresh token again and the whole grant is revoked, every token under it with it. That is how a stolen refresh token is caught — so a client that retries a refresh with the token it already spent will lock itself out.
A refresh may ask for a narrower scope, never a wider one. However often you refresh, the chain ends 90 days after the code was first exchanged; then the person approves again.
7. Revoke when you are done
When the person signs out of your app, send the refresh token to POST /oauth/revoke: it and every access token of the grant stop working. The team's own switch is bigger — revoking the grant, from Connected apps in the dashboard or with DELETE /oauth/grants/{oauth_grant_id}, disconnects your app entirely.
Tokens
Every token is shown once, stored only as a hash, and recognisable by its prefix — the same rules as an rk_ key.
| Prefix | What | Lives | Presented twice |
|---|---|---|---|
rkc_ | Authorization code | 10 minutes, used once | Revokes the whole grant (code_reuse) |
rko_ | Access token | 1 hour | Nothing: use it until it expires |
rkr_ | Refresh token | 30 days, replaced on every use; the chain ends 90 days after the code exchange | Revokes the whole grant (refresh_reuse) |
rkor_ | Registration access token | Until the client is deleted | Nothing: it manages the client |
Scopes
One scope per group of endpoints. Ask for the fewest your app needs.
| Scope | What it reaches |
|---|---|
emails:send | Send an email or a batch. |
emails:read | Retrieve, list, update and cancel sent email; attachments, shares and metrics; read received mail. |
domains:read | Every read under /domains. |
domains:write | Add, verify, update and delete a domain. |
templates:read | Retrieve and list templates. |
templates:write | Create, update, publish, duplicate and delete templates. |
contacts:read | Retrieve and list contacts, contact properties and imports. |
contacts:write | Create, update and delete contacts and their properties. |
segments:read | Retrieve and list segments and their members. |
segments:write | Create, update and delete segments; add and remove members. |
topics:read | Retrieve and list topics. |
topics:write | Create, update and delete topics. |
broadcasts:read | Retrieve and list broadcasts and their reports. |
broadcasts:write | Create, update, send, cancel and delete broadcasts. |
suppressions:read | Retrieve and list suppressions. |
suppressions:write | Add, remove and batch-change suppressions. Reading them needs suppressions:read. |
webhooks:read | Retrieve and list webhooks, the events delivered to them and the events parked while one was off. |
webhooks:write | Create, update, rotate and delete webhooks; replay an event and deliver a parked backlog. Reading them needs webhooks:read. |
logs:read | Retrieve and list request logs. |
automations:read | Retrieve and list automations, their versions and their runs. |
automations:write | Create, update, publish, duplicate, stop and delete automations. |
events:read | Retrieve and list custom event definitions. |
events:write | Create, update and delete custom event definitions. |
events:send | Send a custom event, which can start an automation. |
team:read | Read the team: its plan and limits, sender identity, AI assist and SSO summary. |
team:write | Change the team's sender identity, and turn AI assist on or off. |
billing:read | Read the plan, usage, add-ons and invoices. |
ai:use | Suggest subject lines, draft a body and diagnose an email with AI assist, spending the team's AI credits. |
No scope reaches API keys, the member list, any billing change, deleting received mail, or the OAuth grants themselves. A connected app cannot mint a credential, buy or change a plan, see who is on the team, or see what else is connected, whatever the person approved.
Discovery
Clients that discover rather than hard-code read two documents from the API host: RFC 8414 authorization-server metadata and RFC 9728 protected-resource metadata.
https://api.rasket.com/.well-known/oauth-authorization-server
https://api.rasket.com/.well-known/oauth-protected-resourceA 401 to an OAuth token carries WWW-Authenticate: Bearer realm="rasket", error="invalid_token" and a resource_metadata URL pointing at the second document, which is how an MCP client finds this server without being told — MCP server depends on it.
Where the details are
- Every parameter, response and refusal of the ten routes: the OAuth reference.
- The token and registration routes answer RFC 6749's
{ error, error_description }, not the Rasket error body: Errors lists both vocabularies.