Authentication
Every request to https://api.sendops.dev carries a Bearer token. There are two kinds, and they are interchangeable at the API — the difference is how you obtain the credential, not how you send it:
- an API key (
sk_live_…/sk_test_…), issued from the dashboard — this page; - an OAuth 2.1 access token, obtained from
https://auth.sendops.dev— see OAuth 2.1.
Use a key to automate your own organization; use OAuth to act on behalf of your customers’ organizations, or when you want short-lived credentials. Keys are the simpler choice and are not deprecated.
The Send API on https://ingest.sendops.dev (early access) uses the same header and the same keys, but takes API keys only: an OAuth access token is refused there with 401 invalid_key.
The rest of this page is about API keys. Keys are scoped to one organization and granted a fixed set of permissions chosen at creation time.
The Bearer header
Authorization: Bearer sk_live_KQX...ZJ7 Both credential types travel in this one header — there are no session cookies, no signed-URL flow, no HMAC. If the header is missing, malformed, or the credential has been revoked, the response is 401 Unauthorized (or 403 when the credential is valid but is missing the required scope).
Key format
Keys are 32 random bytes (Base32-encoded, no padding) prefixed with their environment:
| Environment | Prefix | Example |
|---|---|---|
| Live | sk_live_ | sk_live_KQX5...ZJ7 |
| Test | sk_test_ | sk_test_8GH2...P4Q |
The first 8 characters after the prefix are stored in plaintext on our side so the dashboard and audit log can identify the key without exposing the secret. The secret itself is hashed (SHA-256) before storage — once you create a key, we can never show it to you again. If you lose it, you must rotate it.
Treat keys like passwords
Anyone with the raw key can call the API on your behalf. Store keys in your secrets manager, never in version control. If a key leaks, revoke it immediately from the dashboard.
Live vs. test keys
Both environments share the same data plane and scope vocabulary — there is no separate test sandbox. The difference is rate:
- Live keys run at the org’s configured per-minute rate limit — which is unset (unthrottled) by default.
- Test keys run at 10% of that ceiling —
max(1, limit ÷ 10)— once a limit is configured.
See Rate Limiting for the full model. Most v1 endpoints are reads; a few write surfaces exist — contact roster writes, attribute management, and broadcast create/send (gated by their own *.manage scopes), plus append-only activity ingestion (api.activities.write). Use test keys for CI smoke checks and unit tests so noisy automation doesn’t burn your live budget.
Scopes
Scopes are SendOps ACL permission keys. The full catalogue is published unauthenticated as scopes_supported in the authorization server’s metadata, generated from the same catalogue the API enforces:
curl -s https://auth.sendops.dev/.well-known/oauth-authorization-server \
| jq -r '.scopes_supported[]' To see what a particular credential carries, call /v1/_scopes with it — that endpoint is authenticated and reports the caller’s own scopes, which is how you confirm a scope edit has propagated past the 5-minute cache:
curl https://api.sendops.dev/v1/_scopes \
-H "Authorization: Bearer sk_live_..." Scope keys are namespaced under api.* so they cannot be confused with the dashboard’s internal ACL permissions. Every scope you can attach to a key starts with api..
| Scope | What it unlocks |
|---|---|
api.account.view | Organization snapshot via /v1/account — plan, limits, AWS connection state |
api.messages.view | GET /v1/messages, /v1/messages/{id}, /v1/messages/{id}/events. Recipient addresses are masked unless the key also holds api.messages.unmask_recipients. |
api.messages.unmask_recipients | Unlocks the recipient= filter on /v1/messages and the full GET /v1/recipients/{email}/messages route (PII-gated) |
api.reports.view | Deliverability, engagement, template-performance reports |
api.suppressions.view | The account suppression list and per-recipient lookup |
api.undeliverable.view | The SendOps-derived undeliverable list (permanent bounces, complaints, rejects) plus operator-cleared tombstones for ?since= polling |
api.identities.view | SES identities (email and domain) |
api.templates.view | Email templates, their rendered output (POST /v1/templates/render) and their version history |
api.templates.manage | Create or replace a template (PUT /v1/templates/{slug}), delete one, and restore a version — a write scope. A successful write also deploys an email template to your own SES account (a layout is never deployed) |
api.templates.test | POST /v1/templates/{slug}/test — a write scope that sends real mail: it puts a real message through your SES and into a real inbox |
api.channels.view | Channels (SES configuration sets) |
api.tracking.view | Tracking subdomains and their DNS state |
api.contacts.view | GET /v1/contacts and /v1/contacts/{email}. Returns emails, attributes, and per-topic subscription preferences — PII-sensitive, grant deliberately |
api.contacts.manage | Create, replace, update, and archive contacts (POST/PUT/PATCH/DELETE /v1/contacts, POST /v1/contacts/bulk), with inline static-List membership — a write scope, PII-sensitive |
api.topics.view | GET /v1/topics and GET /v1/contact-list — list-level subscription metadata only, no recipient PII |
api.topics.manage | Create, edit, and delete subscription-topic definitions (POST /v1/topics, PUT/DELETE /v1/topics/{name}) and restore a version — a write scope, but definition-only: no recipient PII. Writes are pushed through to the SES contact list |
api.lists.view | GET /v1/lists, /v1/lists/{id}, /v1/lists/{id}/members, and the membership check. Returns member contact ids/emails — PII-sensitive |
api.lists.manage | Create, edit, and delete static List definitions (POST /v1/lists, PUT/DELETE /v1/lists/{id}), fill a List from a Segment, and restore a version — a write scope, but definition-only: no member PII |
api.segments.view | GET /v1/segments, /v1/segments/{id}, /v1/segments/{id}/members, the membership check, the version reads, and the two non-mutating dry-runs POST /v1/segments/preview and POST /v1/segments/validate. Returns the predicate plus member contact ids/emails — PII-sensitive |
api.segments.manage | Create, edit, and delete Segment definitions (POST /v1/segments, PUT/DELETE /v1/segments/{id}), pause or resume evaluation (PUT /v1/segments/{id}/status), force a re-evaluation (POST /v1/segments/{id}/evaluate), and restore a version — a write scope, definition-only |
api.broadcasts.view | Read broadcasts, their delivery state, preview, and per-recipient results |
api.broadcasts.manage | Create (POST /v1/broadcasts) and send or schedule (POST /v1/broadcasts/{id}/send) broadcasts — a write scope that can send mail to your audience |
api.attributes.view | GET /v1/attributes and /v1/attributes/{id} — the org’s custom-attribute registry (name, type, enum values) that Segment predicates reference |
api.attributes.manage | Create, edit, and delete attribute definitions, dry-run an edit’s impact (POST/PUT/DELETE /v1/attributes, POST /v1/attributes/{id}/preview), and restore a version. An edit always applies — what changes is the reported impact on dependent Segments |
api.activities.view | GET /v1/activities, /v1/contacts/{id}/activities, and /v1/contacts/{id}/activities/summary. Returns contact emails plus your free-form activity properties — PII-sensitive, grant deliberately |
api.activities.write | POST /v1/activities — ingest behavioral signals, resolving or creating stub contacts by email/external_id. Append-only: a write (not manage) scope, since nothing can be edited or deleted through it |
api.activities.manage | Promote, edit, demote, and dry-run activity-property definitions (POST/PUT/DELETE /v1/activity-properties, POST /v1/activity-properties/{id}/preview) and restore a version — the typed registry that makes named activity properties referenceable in Segment predicates |
api.assets.view | GET /v1/assets and /v1/assets/{id} — the org’s image asset library. No recipient PII |
api.assets.manage | Upload and delete image assets (POST /v1/assets, DELETE /v1/assets/{id}) — a write scope over image bytes and the logical paths templates reference |
api.tags.view | GET /v1/tags — the org’s tag/folder vocabulary, plus the ?tag= / untagged= collection filters on /v1/broadcasts and /v1/assets. No recipient PII |
api.workflows.view | Read access to Drip Workflow definitions, runs, analytics (exits/funnel), the per-run timeline, enrollment decisions, version history, plus POST /v1/workflows/{id}/dry-run and POST /v1/workflows/validate. Both are POSTs that mutate nothing persistent — simulations, not writes, which is why they sit under .view |
api.workflows.manage | Create, edit, activate, pause, archive and go-live a Drip Workflow, turn the send-approval gate ON, and restore a version. Also unlocks the .flow source and content_hash on a read. A write scope that can arm a live journey and therefore send mail |
api.inboxes.view | GET /v1/inboxes, /v1/inboxes/{id}, /v1/inboxes/{id}/messages and GET /v1/verified-senders. The messages route returns whole received messages in the clear — sender, recipients, subject, bodies and attachment links. Nothing is masked and no further scope unmasks anything; the most PII-heavy scope here, grant it deliberately |
api.send | Send API only, early access. POST /v1/send and GET /v1/messages/{request_id} on ingest.sendops.dev: sends real transactional email from your own SES account, which cannot be recalled. Grantable to API keys only (not an OAuth scope, so it is not in scopes_supported), and offered only once the Send API is on for your organization. A sk_test_ key with it sends only to SES mailbox-simulator addresses and your own verified identities. See Send API |
api.inboxes.manage | Mint, edit and delete temporary inboxes (POST/PATCH/DELETE /v1/inboxes), plus the verified-sender round trip (POST/DELETE /v1/verified-senders, POST /v1/verified-senders/{id}/confirm). A write scope that sends real email: POST /v1/verified-senders mails a six-digit code from SendOps to whatever address it names |
api.api_keys.request | Ask for a new, narrower API key in the same organization (POST /v1/api-key-requests), read the request’s status, and claim the approved key (POST /v1/api-key-requests/{id}/claim); the MCP api_key_requests tool. Mints nothing by itself: an org owner or admin approves each request in the dashboard. Requested scopes must be a subset of the caller’s own, and this scope can never be requested, so a minted key cannot request in turn. See API key requests |
A request that hits an endpoint your key does not cover is rejected with 403 Forbidden and an RFC 7807 problem document. See Errors.
Least privilege
Pick the smallest scope set that solves your use case. Pulling deliverability dashboards into a status page only needs api.reports.view; reading a customer’s message history needs api.messages.unmask_recipients and you’ll want to think hard about who that key is given to.
Creating, rotating, and revoking keys
All lifecycle actions happen in the dashboard, in Workspace → API Keys.
Create
Click Create API Key, give it a name, choose Live or Test, and select scopes. The raw secret is displayed once — copy it now. If you lose it before storing it, immediately revoke this key and create a new one. There is no recovery path.
If your org has enforce 2FA turned on, creating a key requires a fresh MFA challenge.
Edit scopes
You can update the scope set on an existing key without rotating it. The change is applied immediately and recorded in the audit log (api_key.scopes_updated).
Rotate
Rotation issues a new secret while keeping the same name and scope set. Both the old key and the new key continue to work for 24 hours — a hard, non-negotiable grace window. After 24 hours the old key is automatically revoked by a background job.
Use rotation when:
- You suspect a key may have leaked but want zero-downtime cutover.
- You’re rolling keys on a routine schedule.
Rotation does not require MFA — it’s the safe path because the old key still works while you deploy the new one.
Revoke
Revocation is immediate. Once revoked, the key is dead — every subsequent request returns 401. Use this when:
- You know a key has leaked.
- You want to retire a key that is no longer in use.
If your org has enforce 2FA turned on, revocation requires a fresh MFA challenge.
Revoking a key also revokes every key it obtained through an API key request, and lapses its open requests.
Keys minted from a request
A key issued through an API key request differs from one created in the dashboard in four ways: it always expires (the lifetime an admin approved, at most 90 days), it cannot be rotated or cloned, it can never hold api.api_keys.request, and it is revoked automatically when the API key or OAuth connection that asked for it is revoked. Rotating the requesting key moves its minted keys to the new key. In the key list a minted key shows Requested by and the credential that asked.
Lifecycle in the audit log
Every key lifecycle event appears in the org audit log:
api_key.created— actor: the user who created itapi_key.scopes_updated— actor: the user who edited; includes added/removed scope diffapi_key.rotated— actor: the user who rotated; emitted on both the old and new keyapi_key.revoked— actor: the user who revoked it (manual)api_key.grace_expired— actor:system; emitted when the 24h rotation grace ends
Request-level activity (which key called which endpoint when) is not in the audit log, and the dashboard does not show it per request. What it does show is each key’s Last used time, on Workspace → API Keys.
Storing keys safely
A few practical rules:
- Put the key in your secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler — anything but a
.envfile in version control). - Inject the secret into your runtime via environment variable, never hard-code it.
- Add the key prefix (
sk_live_orsk_test_) to your repository’s secret-scanning denylist. - Rotate on a cadence appropriate to your blast radius (we recommend at least annually for long-lived service keys, and immediately whenever a teammate with access leaves).