# OAuth 2.1

SendOps runs an OAuth 2.1 authorization server at `https://auth.sendops.dev`. It issues short-lived access tokens that the Public API accepts exactly like an [API key](/api-reference/authentication) — as a Bearer token against `https://api.sendops.dev`.

Reach for OAuth when an [API key](/api-reference/authentication) is not the right shape:

- **You are building a product that connects to your customers' SendOps organizations.** The authorization-code flow lets a customer grant your product access to *their* data without ever handing you a credential.
- **You want automation with short-lived credentials.** The client-credentials flow gives your own backend a 15-minute token instead of a key that lives until you rotate it.


  If you are only automating your own organization, an API key is simpler and remains fully supported. OAuth earns its complexity when a *third party* — your product, acting for your customer — needs access. See [Authentication](/api-reference/authentication) for keys.


## Which flow

| | `client_credentials` | `authorization_code` |
|---|---|---|
| Token acts as | Your organization | The authorizing user, in the org they chose |
| A person approves | No | Yes — a consent screen |
| PKCE | n/a | Required (S256), even for confidential clients |
| Client type | Confidential only | Confidential or public |
| Use it when | Automating your own org | Your customers connect your product |

A `client_credentials` token is org-bound with no user identity — semantically an API key with a 15-minute leash. An `authorization_code` token is bound to a specific user *and* the organization they picked, and can never do more than that user's own role allows: the consented scopes are a ceiling, the user's live permissions are the floor, re-evaluated on every request.

## Register a client

Clients are registered from the dashboard under **Workspace → OAuth Clients** (Owner or Org Admin). Registration returns:

- a **client ID** — `oc_live_…` or `oc_test_…`, not a secret;
- a **client secret** — `ocs_…`, shown once, stored only as a hash — for confidential clients.

Live and test clients hit the same data; test clients run at 10% of your plan's per-minute rate limit. The end-user walkthrough is in the [help center](https://help.sendops.dev/api-integrations/oauth-clients); this page is the wire protocol.

### CIMD — registering without an admin in the loop

Dashboard registration assumes somebody inside an organization creates a client and hands the credentials to an integration. That does not work when the client is desktop software a customer installed this morning — an org admin would have to file a ticket before anything could connect. It is the [MCP](/api-reference/mcp) case in particular.

So a client may instead present an **`https://` URL as its `client_id`**. We fetch the JSON document published there, and **that document is the registration**. Nothing is allocated, no secret exists, and no organization owns the client.

```json
{
  "client_id": "https://example.com/mcp/client.json",
  "client_name": "Example Agent",
  "redirect_uris": ["https://example.com/oauth/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_method": "none"
}
```

The rules a document must satisfy:

- **`client_id` must equal the URL it was fetched from**, compared canonically. Without this, one document could claim to be any other client.
- **Every `redirect_uris` entry must be same-origin with the document.** That origin is the only thing about the client we verified — we fetched the document from it over TLS. Loopback callbacks (`127.0.0.1`, `::1`, `localhost`) are the documented exception, for native clients.
- **`grant_types` must include `authorization_code`** and must **not** include `client_credentials` — that grant mints a token for the client's own organization, and a CIMD client has none.
- **`token_endpoint_auth_method`, if present, must be `none`.** A URL cannot keep a secret.
- Unknown members are ignored, so a document that also serves another authorization server still works here.

Two consequences to design around:

1. **The document is re-read on every authorization.** `redirect_uris` are *replaced*, not merged, and a declared `scope` list is authoritative. Taking the document down de-registers the client; a withdrawn callback does not live on in our table. A validated document is cached for 1 hour, a refusal for 5 minutes.
2. **A CIMD client's refresh tokens last 30 days**, fixed, and it is always treated as `live`. A dashboard client can be granted never / 30 / 90 / 365 because an admin vetted it and can revoke it in one click; a client nobody vetted gets the shortest lifetime on offer.


  A CIMD client is shared across every organization that consents to it and owned by none. An org admin can revoke its grants and **block** it for their own organization, but cannot deactivate it for everybody — that would let one admin cut off every other customer's users.


## Discovery

These documents are public and let a standard OAuth library configure itself:

```http
GET https://auth.sendops.dev/.well-known/oauth-authorization-server   # RFC 8414
GET https://api.sendops.dev/.well-known/oauth-protected-resource      # RFC 9728
GET https://mcp.sendops.dev/.well-known/oauth-protected-resource      # RFC 9728
```

Point your library at the issuer `https://auth.sendops.dev` and it will find the token, JWKS, and revocation endpoints on its own.

A `401` from either resource server carries a `WWW-Authenticate` header naming its protected-resource document, so a client that has never heard of SendOps can bootstrap from a bare 401.

## Resource indicators (RFC 8707)

One authorization server, **two** resource servers. A token carries exactly one `aud` and is refused at the other.

| Resource | `resource` value | Default? |
|---|---|---|
| The Public API | `https://api.sendops.dev` | **yes** — used when a request names none |
| The [MCP server](/api-reference/mcp) | `https://mcp.sendops.dev` | no |

Pass the one you want as the `resource` parameter on `/oauth/authorize` and `/oauth/token`. The resolved value becomes the token's `aud`.

The rule that catches people out is what **omitting** it means, because it differs by request:

| Request | Omitting `resource` means |
|---|---|
| A fresh one — `/oauth/authorize`, `client_credentials` | the **default**, `https://api.sendops.dev` |
| Exchanging a code, or any refresh | **the resource that grant already covers** — never the default |

A grant can never widen. If your customer authorized you for the MCP server, every rotation stays an MCP token rather than silently becoming a Public API one. An unrecognised value is `invalid_target` and is never substituted with the default.

A single token has one audience, so `resource` may not be given twice with different values — though naming the same resource twice is fine.

## Client credentials

Two steps: exchange your client ID and secret for an access token, then call the API with it.

```bash
curl -s https://auth.sendops.dev/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=oc_live_... \
  -d client_secret=ocs_... \
  -d scope="api.messages.view api.reports.view"
```

```json
{
  "access_token": "eyJhbGciOiJFZERTQS013...",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "api.messages.view api.reports.view"
}
```

`scope` is optional; omit it to receive every scope the client is registered for. You may request a subset, but never a scope the client does not hold.

Then call the API exactly as with a key:

```bash
curl https://api.sendops.dev/v1/messages \
  -H "Authorization: Bearer eyJhbGciOiJFZERTQS013..."
```

There is no refresh token in this flow. When the token expires, request another the same way — the call is cheap and made to be repeated.

## Authorization code + PKCE

The connect flow, for acting on behalf of a user. PKCE is mandatory for every client, confidential or public.


  Authorization and consent are served from the **dashboard** origin (`https://app.sendops.dev/oauth/authorize`) so the user's session cookie stays put. Token exchange and refresh are on the **authorization server** (`https://auth.sendops.dev/oauth/token`). Discovery points libraries at the right host automatically.


**1 — Create a PKCE verifier and challenge.**

```bash
verifier=$(openssl rand -base64 60 | tr -d '\n=+/' | cut -c1-64)
challenge=$(printf '%s' "$verifier" | openssl dgst -binary -sha256 | openssl base64 | tr '+/' '-_' | tr -d '=')
```

**2 — Send the user to authorize.** Redirect their browser to:

```http
GET https://app.sendops.dev/oauth/authorize
  ?response_type=code
  &client_id=oc_live_...
  &redirect_uri=https://yourapp.example.com/oauth/callback
  &scope=api.messages.view%20api.contacts.manage
  &state=<opaque-random>
  &code_challenge=<challenge>
  &code_challenge_method=S256
```

The user signs in if needed, sees a consent screen naming your client and the scopes, picks which of their organizations to grant, and approves. SendOps redirects back to your exact registered `redirect_uri` with `?code=…&state=…`.


  Verify the returned `state` equals the one you sent — this is your CSRF defense. `redirect_uri` is matched exactly against the registration: no wildcards, no trailing-slash slack. `https://` is required except on `localhost`.


**3 — Exchange the code for tokens.**

```bash
curl -s https://auth.sendops.dev/oauth/token \
  -d grant_type=authorization_code \
  -d code=<code-from-redirect> \
  -d redirect_uri=https://yourapp.example.com/oauth/callback \
  -d client_id=oc_live_... \
  -d client_secret=ocs_... \
  -d code_verifier="$verifier"
```

Confidential clients send `client_secret`; public clients omit it and rely on the `code_verifier` alone. The response carries both an access token and a refresh token:


  A public client can be a browser app. `https://auth.sendops.dev` answers cross-origin requests from any origin, so a single-page app can run this exchange directly from the browser — no proxy of your own required. Because the endpoint authenticates from the request itself, cookies are never sent and `Access-Control-Allow-Credentials` is deliberately off; do not set `credentials: 'include'` on the fetch.

  `WWW-Authenticate`, `Retry-After` and `X-Request-Id` are exposed to browser JavaScript, so you can read the challenge on a 401 and back off correctly on a 429.

  This applies only to the authorization server. `https://api.sendops.dev` does **not** allow cross-origin calls — API requests are made from your server, not the browser.


```json
{
  "access_token": "eyJhbGciOiJFZERTQS013...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "ocr_9f8c...",
  "scope": "api.messages.view api.contacts.manage"
}
```

## Refreshing

Access tokens last 15 minutes. Trade a refresh token for a fresh pair:

```bash
curl -s https://auth.sendops.dev/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token=ocr_9f8c... \
  -d client_id=oc_live_... \
  -d client_secret=ocs_...
```

Two rules matter:

- **Refresh tokens rotate.** Each refresh returns a **new** refresh token and invalidates the one you sent. Always store the newest.
- **Replay revokes the family.** If an old refresh token is ever presented again, SendOps treats it as theft and revokes the entire chain descended from that authorization. A stolen token cannot be used quietly beside the legitimate one — the next legitimate refresh fails and the user must re-authorize, which is the signal.

The refresh token's own lifetime is set on the client (never / 30 / 90 / 365 days) and is **absolute** — measured from when the user authorized, not from last use. Rotation does not extend it. When it lapses, the user re-authorizes.

## Revocation

Revoke a token per RFC 7009:

```bash
curl -s https://auth.sendops.dev/oauth/revoke \
  -d token=ocr_9f8c... \
  -d client_id=oc_live_... \
  -d client_secret=ocs_...
```

Revoking a **refresh** token kills the whole family. Revoking an **access** token denylists it for its short remaining life. Users can also revoke your app themselves from **Connected Apps**, and an org admin can revoke any grant org-wide — so treat a token vanishing mid-life as normal and re-authorize.

## Errors

Token and authorize endpoints return the OAuth-standard shape (RFC 6749 §5.2), not the Public API's [RFC 7807 problem+json](/api-reference/errors):

```json
{
  "error": "invalid_grant",
  "error_description": "authorization code has expired or already been used"
}
```

| `error` | Meaning |
|---|---|
| `invalid_request` | A parameter is missing or malformed |
| `invalid_client` | Client ID unknown, or secret wrong |
| `invalid_grant` | Code/refresh token expired, already used, or revoked |
| `invalid_scope` | Requested a scope the client does not hold |
| `unsupported_grant_type` | `grant_type` is not one of the three above |

Once you hold an access token, calls to `https://api.sendops.dev` return the same status codes and problem+json bodies as any other request — a `403` for a missing scope, `401` for an expired or revoked token. See [Errors](/api-reference/errors).

## Scopes

OAuth uses the same `api.*` scope vocabulary as API keys — see [the catalogue](/api-reference/authentication#scopes), published in full as `scopes_supported` in the [metadata document](#discovery). A client's registered scopes cap what any of its tokens can request; for the connect flow, the authorizing user's live role caps it further.

Three scopes are **[MCP](/api-reference/mcp)-only** — no `/v1` route reads them, and the dashboard's API-key picker filters them out:

| Scope | What it unlocks |
|---|---|
| `api.templates.manage` | Author and save template content (routed to a pull request on a git-connected org) |
| `api.templates.test` | Send a real test message through the customer's SES |
| `api.workflows.manage` | Author, size and activate Drip Workflows |