Skip to content
On this site

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"
}'

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=S256

The browser comes back to your redirect URI with a code and your state:

https://invoices.acme.example/rasket/callback?code=rkc_…&state=af0ifjsldkj

Check 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"
}'

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, with WWW-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.

The OAuth tokens
PrefixWhatLivesPresented twice
rkc_Authorization code10 minutes, used onceRevokes the whole grant (code_reuse)
rko_Access token1 hourNothing: use it until it expires
rkr_Refresh token30 days, replaced on every use; the chain ends 90 days after the code exchangeRevokes the whole grant (refresh_reuse)
rkor_Registration access tokenUntil the client is deletedNothing: it manages the client

Scopes

One scope per group of endpoints. Ask for the fewest your app needs.

The OAuth scopes
ScopeWhat it reaches
emails:sendSend an email or a batch.
emails:readRetrieve, list, update and cancel sent email; attachments, shares and metrics; read received mail.
domains:readEvery read under /domains.
domains:writeAdd, verify, update and delete a domain.
templates:readRetrieve and list templates.
templates:writeCreate, update, publish, duplicate and delete templates.
contacts:readRetrieve and list contacts, contact properties and imports.
contacts:writeCreate, update and delete contacts and their properties.
segments:readRetrieve and list segments and their members.
segments:writeCreate, update and delete segments; add and remove members.
topics:readRetrieve and list topics.
topics:writeCreate, update and delete topics.
broadcasts:readRetrieve and list broadcasts and their reports.
broadcasts:writeCreate, update, send, cancel and delete broadcasts.
suppressions:readRetrieve and list suppressions.
suppressions:writeAdd, remove and batch-change suppressions. Reading them needs suppressions:read.
webhooks:readRetrieve and list webhooks, the events delivered to them and the events parked while one was off.
webhooks:writeCreate, update, rotate and delete webhooks; replay an event and deliver a parked backlog. Reading them needs webhooks:read.
logs:readRetrieve and list request logs.
automations:readRetrieve and list automations, their versions and their runs.
automations:writeCreate, update, publish, duplicate, stop and delete automations.
events:readRetrieve and list custom event definitions.
events:writeCreate, update and delete custom event definitions.
events:sendSend a custom event, which can start an automation.
team:readRead the team: its plan and limits, sender identity, AI assist and SSO summary.
team:writeChange the team's sender identity, and turn AI assist on or off.
billing:readRead the plan, usage, add-ons and invoices.
ai:useSuggest 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-resource

A 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.