Skip to main content

Machine-readable specification

The complete OpenAPI 3.0.3 description of the Unsubscribe API — request and response schemas, parameters, error envelopes, and security definitions — is published alongside this guide. Generate client SDKs from it, or import it into Postman / Insomnia for interactive exploration.

OpenAPI specification

Download truagents.json — covers POST /oauth/token and all six /api/v1/unsubscribe/{email,sms,phone} operations.
This guide is the canonical prose narrative; the OpenAPI document is the machine-readable contract. If you spot drift between the two, treat this page as authoritative and let us know.

Summary

The Unsubscribe API lets a partner system maintain opt-out and opt-in rules keyed by raw identifier — an email address or a phone number. Each rule decides whether TruAgents may send on a given channel. Three properties distinguish these rules from per-contact suppression flags:
  • Decoupled from Contact records. Rules are stored separately from TruAgents contacts. You can push a rule for an email or phone that does not exist as a contact in any of your TruAgents organizations, and you can query state for any identifier whether or not a matching contact exists.
  • Can apply globally across organizations. A single rule can govern sending behavior in any subset (or all) of the organizations you manage on TruAgents — including ones where the contact only exists in one of those organizations, or in none of them. This is configured per channel at onboarding time (see Channel scoping model below).
  • Opt-ins are first-class. Rules carry an unsubscribed boolean; setting it to false re-enables sending on that channel. TruAgents preserves the row and updates source and updated_at to reflect the latest transition. A complete event log of every change is not part of this contract — source and updated_at describe only the most recent transition.

Who this is for

External compliance, suppression-list, or CRM systems that need to keep TruAgents in sync with the partner’s source-of-truth opt-out / opt-in state.

Rules, not contacts

Records pushed through this API are independent of any TruAgents Contact row:
  • A rule is a (channel, identifier, unsubscribed) triple stored against an admin-controlled channel scope, not tied to a TruAgents contact record.
  • TruAgents joins rules to contacts at send time, by matching the contact’s email or phone against rules in the contact’s organization’s channel scope. The rule existed before the contact was created, or after, or even if the contact is never created — the join is by raw identifier.
  • A single rule can suppress (or re-enable) a channel for contacts living in multiple TruAgents organizations, when an administrator has configured those organizations to share a channel scope. The contact does not need to exist in every organization that the rule applies to — or in any of them.
  • Deleting a contact in TruAgents does not delete the rule; pushing a rule does not create a contact.
This means you can populate the unsubscribe API in any order relative to contact uploads:
  • before any contacts exist in TruAgents (e.g., at onboarding, when pushing a known suppression list)
  • alongside contact uploads (order of operations does not matter)
  • for identifiers that may never become contacts (e.g., addresses you have suppressed on your side but never sent us as leads)

Channel scoping model

TruAgents tracks opt-out state for three channels: email, sms, and phone (voice calls). For each channel, a scope decides which TruAgents organizations a rule applies to. Each organization has an independent scope per channel, set by a TruAgents administrator at onboarding time and invisible to the partner over the API. Each channel’s scope works the same way:
  • Default: a fresh per-organization scope at organization creation. Rules pushed against an organization’s scope apply only to that organization.
  • Shared scope: an administrator can point any group of organizations at the same scope value. Rules then apply across every organization in that group — including ones where no matching Contact exists.
The phone (voice) channel has one additional configuration option: an administrator can set its scope equal to the same organization’s sms scope, producing a single suppression list that covers both SMS and voice on the same phone number. Equivalently, they can be kept distinct so SMS and voice can be opted out independently. The partner does not pass a channel scope identifier. The partner may pass an org_slug to target a specific organization their API key is authorized to address (see Managing multiple organizations); when omitted, the request applies to the key’s default organization. The configured channel scoping then decides whether each write fans out to other organizations.

Examples

  • An organization with the default per-organization SMS scope: POST an SMS opt-out for +15551234567 — it suppresses SMS sends in that one organization only.
  • An organization whose SMS scope is shared with two sibling organizations (A, B, C): POST an SMS opt-out for +15551234567 once — it suppresses SMS sends in A, B, and C, regardless of which of A, B, or C the phone exists as a contact in, including the case where the phone is not a contact in any of them.
  • An organization with the default per-organization email scope: POST an email opt-out for john@example.com — it suppresses email sends in that one organization only.
  • An organization whose email scope is shared with siblings: POST an email opt-out for john@example.com — it suppresses email sends across the entire shared scope, by the same rule as SMS.
  • An organization with sms and phone scopes set to the same value: a single POST to /api/v1/unsubscribe/sms and a separate POST to /api/v1/unsubscribe/phone for the same phone number write to the same underlying list; setting one channel’s unsubscribed independently of the other is not possible until an administrator splits the scopes.

Authentication

The Unsubscribe API uses TruAgents’ standard OAuth 2.0 client_credentials flow. Your TruAgents administrator issues a client_id + client_secret pair; your service exchanges them for a short-lived bearer token at POST /oauth/token and sends the bearer token on every request below. See Authentication for the full flow, token lifetimes, refresh-token rotation, and error envelopes. Each client_id is bound to a set of organizations with one designated as the default — requests that omit org_slug apply to the default; requests that include org_slug apply to that organization, provided it is in the authorized set. See Managing multiple organizations for the full mechanic and how to discover the slugs your key is authorized for.

Managing multiple organizations

One client_id can address several TruAgents organizations on your behalf. Your TruAgents administrator picks the authorized set and the default when the credential is issued and reviews them on every change. Targeting a specific organization. Supply an org_slug on GET (as a query parameter) or POST (as a top-level body field). One call addresses exactly one organization — responses are never mixed. Omitting org_slug. The request applies to the key’s default organization. The response echoes the resolved org_slug so you can confirm which default was used. Authorized vs. unknown slug. The server returns 403 unauthorized_organization whenever the supplied slug is not currently in your key’s authorized set — including the case where the slug does not exist at all. The response body echoes the slug back so you can spot a typo without the server confirming whether the slug exists:
Discovering your slugs. Until a discovery endpoint ships, your TruAgents administrator tells you which org_slug values your key is authorized for and which one is the default. Keep these in your configuration alongside the client_id. Scope-sharing interaction. The authorized set bounds the org_slug values you may target. The downstream effect of each write is still decided by admin-configured channel scopes — addressing org A may suppress sends in sibling organizations whose channel scope is shared with A, even if those siblings are not in your authorized set. The channel scoping model section describes that mechanic; the authorized set just decides which slug you may name, not which orgs are affected downstream. Rate limits. Per client_id, not per client_id × org_slug — see Rate limits for ceilings and how to request a bump.

Endpoints

Three channel-scoped resources, each with GET (list current state) and POST (push changes):

Query opt-out state

Each GET returns a page of state-change records for one channel, in one organization, newest first. Treat the endpoint as a change stream: each call returns a page within the pagination window; cursor advances toward older records; since and until bound the window. The same parameters apply to all three endpoints — the examples below illustrate different combinations of org_slug, since, and limit.
GET /api/v1/unsubscribe/email (default organization, latest 50)
GET /api/v1/unsubscribe/sms (since a fixed timestamp)
GET /api/v1/unsubscribe/phone (explicit org_slug)
Query parameters since and until are pagination bounds, not arbitrary filters: each call returns a page in updated_at descending order, and subsequent calls via cursor walk toward older records until has_more is false. To find the current state of a specific identifier, walk far enough — the most recent record for that identifier (the first one encountered) is its current state. Response — email endpoint
Response — SMS / Voice endpoints
  • org_slug echoes the organization this page belongs to — either the value you supplied, the one carried by cursor, or your key’s default organization when neither was provided.
  • Each record represents the current state of one identifier on this channel; the page only contains identifiers whose most recent transition falls inside the pagination window.
  • unsubscribed: true ⇒ opted out (TruAgents will not send on this channel).
  • unsubscribed: false ⇒ opted in (record kept for history; sends proceed normally if no other suppression applies).
  • source records who or what last changed the state. The same five values apply to every endpoint (email, SMS, voice):
    • api — pushed by the partner via this API.
    • admin — a TruAgents operator manually changed the state via TruAgents’ internal admin surface (e.g. compliance response, support-driven override). The updated_at reflects the operator’s action; the operator identity is not exposed in the partner response.
    • import — bulk import: CSV upload, CRM sync (HubSpot / Salesforce / Mailchimp), or the one-time legacy backfill from pre-existing opt-out state.
    • user_action — recipient took a concrete provider-observed action: SendGrid unsubscribe link, SendGrid list-unsubscribe header, Twilio STOP / UNSUBSCRIBE keyword. Opt-ins via Twilio START / YES / UNSTOP emit the same source with unsubscribed: false. On the voice endpoint, user_action is visible when your administrator has unified your organization’s SMS and voice scopes and a Twilio event suppressed the shared row.
    • user_intent — TruAgents inferred opt-out intent from an inbound message. The provider did not observe a subscription action; TruAgents acted on the recipient’s behalf. Only visible for organizations enrolled in TruAgents’ inbound-message classifier.
  • updated_at is the timestamp of the most recent state change.
  • Records are ordered by updated_at descending.
  • next_cursor is null when there are no more results.

Push opt-out / opt-in changes

Each endpoint accepts a batch of { identifier, unsubscribed } items targeting one organization. Both opt-outs (true) and opt-ins (false) are accepted. Up to 10,000 items per request. Items are processed in array order. If the same identifier appears more than once in the same batch, only the first occurrence is applied; every subsequent occurrence is rejected with reason: "duplicate identifier" and surfaces in skipped_items. Dedupe on your side before posting. The examples below show explicit org_slug on each channel; omit it to use the key’s default organization.
POST /api/v1/unsubscribe/email
POST /api/v1/unsubscribe/sms
POST /api/v1/unsubscribe/phone
To suppress both SMS and voice for the same phone, call both endpoints — or call them in parallel. Each endpoint is independent. To write to multiple organizations, send one request per organization with the appropriate org_slug. Request body (same shape for all three endpoints, identifier field differs) Each item: Response
  • org_slug echoes the organization the batch was applied to — either the value you supplied, or your key’s default organization when you omitted it.
  • The operation is not atomic — each item is processed independently. Partial failures do not roll back successful updates.
  • processed is the count of items accepted into updated (i.e., items.length − skipped_items.length). The call is idempotent: items already at the requested state are still counted in processed and listed in updated, even though no state change occurred in the database.
  • updated lists every accepted item — one input item produces exactly one entry. The returned updated_at is the row’s current state-change timestamp; to detect whether this write moved state, compare it against the updated_at you previously observed for the same identifier (from an earlier GET or POST response).
  • skipped_items lists items rejected due to validation conflicts (see below).

Conflicts and validation

Error responses

What TruAgents tracks per record

The channel is implicit in the endpoint you call. Each record persisted by the API or by an internal source carries: Opt-ins (transitions from true → false) are first-class — TruAgents preserves the record with unsubscribed: false, updates source and updated_at to reflect the new state, and respects the most recent change regardless of which source wrote it. Only the latest state is queryable; intermediate transitions are not retained.

Internal sources that can change state

In addition to the partner API, the same records are written by:
  • Provider-managed subscription actions — recipient-driven opt-outs (SendGrid unsubscribe link, SendGrid list-unsubscribe header, Twilio STOP keyword) and opt-ins (Twilio START / YES / UNSTOP, and equivalents). Source: user_action on both channels. If your TruAgents administrator has configured your SMS and voice scopes to share a single suppression list, Twilio-sourced records also surface on the voice (/phone) endpoint — they suppress both channels in that configuration.
  • Bulk imports — CSV uploads and CRM sync (HubSpot / Salesforce / Mailchimp). Source: import on every endpoint. Imported opt-outs are pre-existing state whose original mechanism is not persisted on the row itself; the ingestion pipeline is identified separately on internal records for identifiable imports.
  • TruAgents admin UI — manual changes by a TruAgents operator. Source: admin. Admin changes to the SMS or voice list under a unified scope behave the same as provider-sourced ones: the single underlying record is visible on both the /sms and /phone endpoints.
  • TruAgents classifier — when TruAgents infers opt-out intent from an inbound message, TruAgents’ classifier writes source='user_intent'. No partner or admin action is involved; TruAgents acts on the recipient’s behalf. This is distinct from user_action, where a provider (SendGrid / Twilio) directly observes the recipient’s subscription action.
When two sources change state for the same identifier, the most recent write wins. The partner API and internal sources do not have a conflict resolution beyond timestamps; each push is authoritative for the moment it is applied.

Common mistakes

  • Calling the wrong endpoint for the channel — SMS opt-outs go to /api/v1/unsubscribe/sms, voice opt-outs go to /api/v1/unsubscribe/phone. They are independent lists.
  • Including the wrong identifier field in an item — the email endpoint accepts only email; the SMS and voice endpoints accept only phone.
  • Mixing organizations in one batch — org_slug is top-level; one request targets one organization. Build one batch per organization and send them in sequence or in parallel.
  • Sending a mismatched org_slug alongside cursor on a GETorg_slug is optional when cursor is supplied (the cursor already encodes the target). Sending a matching value as a sanity check is fine; sending a different value returns 400. To switch organizations, drop the cursor and start a fresh page.
  • Sharing cursors between deployments or credentials — cursors encode the target scope and pagination position, not an identity. Two keys authorized for the same organization can in principle interoperate on a cursor, but treat cursors as ephemeral and non-transferable across credentials by convention. Each credential should walk its own pagination state; if a credential’s authorized set changes mid-walk, its own cursor may start returning 400 invalid_cursor and you re-anchor by dropping it.
  • Assuming a 403 unauthorized_organization means the slug is wrong — it can also mean the slug exists but your administrator has not (yet) added it to your key’s authorized set. The response does not distinguish the two; ask your administrator if you expected access.
  • Assuming an email opt-out applies across all your TruAgents organizations — each organization’s email channel scope is independent unless your TruAgents administrator has explicitly configured a shared channel scope at onboarding. Targeting one organization with org_slug only changes which organization you address; it does not change downstream channel-scope sharing.
  • Using inconsistent phone formats — phones must be E.164 (+15551234567), not (555) 123-4567 or 555-123-4567.
  • Treating every item in the POST updated list as a state change. Compare the returned updated_at against a value you previously observed for the same identifier to distinguish actual transitions from idempotent no-ops. For an identifier you have not previously read, the row may already exist at the requested state because an internal source (user_action, import, admin, or user_intent) wrote it first — issue a GET before the POST if definitive no-op detection matters.
  • Branching on old channel-specific source values. Prior to this contract revision, email opt-outs surfaced as user_sendgrid and SMS / voice opt-outs as user_twilio. Both collapse to user_action under the current enum, and external_api collapses to api. Partner integrations that branch on the old values must accept the new tokens; during the transition window a partner may observe both old and new values in the same response until the underlying rows are updated.