Authentication
Every request to https://api.sendops.dev must carry a SendOps API key as a Bearer token. Keys are issued from the dashboard, scoped to one organization, and granted a fixed set of permissions chosen at creation time.
The Bearer header
Authorization: Bearer sk_live_KQX...ZJ7 No other authentication method is supported in v1. There are no session cookies, no signed-URL flow, no HMAC. If the header is missing, malformed, or the key has been revoked, the response is 401 Unauthorized (or 403 when the key is otherwise 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 lives at:
curl https://api.sendops.dev/v1/_scopes 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 |
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}) — a write scope, but definition-only: no recipient PII. Git-backed topics are read-only here (409 conflict) |
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}) — a write scope, but definition-only: no member PII. Git-backed Lists are read-only here (409 conflict) |
api.segments.view | GET /v1/segments, /v1/segments/{id}, /v1/segments/{id}/members, and the membership check. Returns the predicate plus member contact ids/emails — PII-sensitive |
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 and dry-run an edit’s impact (POST/PUT/DELETE /v1/attributes, POST /v1/attributes/{id}/preview). Git-backed definitions are read-only — the repo wins |
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) — the typed registry that makes named activity properties referenceable in Segment predicates. Git-backed definitions are read-only — the repo wins |
api.assets.view | GET /v1/assets and /v1/assets/{id} — the org’s git-sourced image asset library. Read-only, no recipient PII |
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-only access to Drip Workflow definitions, runs, analytics (exits/funnel), the per-run timeline, and POST /v1/workflows/{id}/dry-run. Dry-run is a POST but mutates nothing persistent — it’s a simulation against an existing contact, not a write, which is why it sits under .view rather than .manage |
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 Settings → API Keys.
Create
Click New 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.
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 — that lives in the observability stack (see operational dashboards in your org settings).
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).