OAuth 2.1

Search Documentation

Search across all developer documentation

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_credentialsauthorization_code
Token acts asYour organizationThe authorizing user, in the org they chose
A person approvesNoYes — a consent screen
PKCEn/aRequired (S256), even for confidential clients
Client typeConfidential onlyConfidential or public
Use it whenAutomating your own orgYour 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 IDoc_live_… or oc_test_…, not a secret;
  • a client secretocs_…, 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_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.

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.

Resourceresource valueDefault?
The Public APIhttps://api.sendops.devyes — used when a request names none
The MCP serverhttps://mcp.sendops.devno

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:

RequestOmitting resource means
A fresh one — /oauth/authorize, client_credentialsthe default, https://api.sendops.dev
Exchanging a code, or any refreshthe 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"
}
errorMeaning
invalid_requestA parameter is missing or malformed
invalid_clientClient ID unknown, or secret wrong
invalid_grantCode/refresh token expired, already used, or revoked
invalid_scopeRequested a scope the client does not hold
unsupported_grant_typegrant_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:

ScopeWhat it unlocks
api.templates.manageAuthor and save template content (routed to a pull request on a git-connected org)
api.templates.testSend a real test message through the customer’s SES
api.workflows.manageAuthor, size and activate Drip Workflows