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 — as a Bearer token against https://api.sendops.dev.
Reach for OAuth when an API key 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.
API keys are not going away
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 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_…oroc_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; 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 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.
{
"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_idmust equal the URL it was fetched from, compared canonically. Without this, one document could claim to be any other client.- Every
redirect_urisentry 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_typesmust includeauthorization_codeand must not includeclient_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 benone. 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:
- The document is re-read on every authorization.
redirect_urisare replaced, not merged, and a declaredscopelist 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. - 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.
No single organization can retire a CIMD client
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:
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 | 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.
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" {
"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:
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.
Where each step happens
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.
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:
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=….
Check state, match redirect_uri exactly
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.
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:
Calling the token endpoint from a browser
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.
{
"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:
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:
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:
{
"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.
Scopes
OAuth uses the same api.* scope vocabulary as API keys — see the catalogue, published in full as scopes_supported in the metadata document. 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-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 |