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, the unsubscribe group discovery endpoint, and all write / list operations under /api/v1/unsubscribe/.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. Rules live in unsubscribe groups: named, shareable block lists with a client-visible id (ug_-prefixed). Every organization has a default group per channel; an administrator can point several organizations at one group so they share a single list. The discovery endpoint shows you every group your credential can reach and which organizations use it — the sharing topology is self-service, not out-of-band.
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 rule in a group shared by several organizations governs sending behavior in all of them — including ones where the contact exists in one of those organizations, or in none of them. See Unsubscribe groups below.
- Opt-ins are first-class. The
/addendpoints record opt-outs; the/removeendpoints record opt-ins. TruAgents preserves the row in either direction and updatessourceandupdated_atto reflect the latest transition. A complete event log of every change is not part of this contract —sourceandupdated_atdescribe 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 TruAgentsContact row:
- A rule is an
(identifier, unsubscribed)pair stored in an unsubscribe group, not tied to a TruAgents contact record. - TruAgents joins rules to contacts at send time, by matching the contact’s email or phone against the rules in the group each organization’s channel points at. 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 suppresses (or re-enables) a channel for contacts living in every organization that uses the rule’s group on that channel. The contact does not need to exist in every one of those organizations — or in any of them.
- Deleting a contact in TruAgents does not delete the rule; pushing a rule does not create a contact.
- 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)
Unsubscribe groups
TruAgents tracks opt-out state for three channels: email, sms, and phone (voice calls). Rules live in unsubscribe groups — shareable block lists with a client-visible id (ug_-prefixed), a kind, and an owning organization:
- A group’s
kindis eitheremailorphone_number. Email-channel requests must target anemail-kind group; SMS and voice requests must target aphone_number-kind group. A mismatch returns400. - Every organization has a default group per channel (
email,sms,phone), created automatically with the organization. By default each organization’s groups are its own — rules apply to that organization only. - Sharing: an administrator can point any set of organizations’ channels at one group. Rules in that group then apply to every organization using it — including ones where no matching Contact exists.
- Unified SMS + voice: when an administrator points an organization’s
smsandphonechannels at the samephone_number-kind group, a single rule covers both channels for that phone number. A recipient’s STOP text — or your/sms/addpush — suppresses voice calls too. Keep the channels on distinct groups when they must be controllable independently. Per-channel granularity inside one shared group is intentionally impossible.
Examples
- Default setup:
POST /api/v1/unsubscribe/sms/addfor+15551234567writes to your organization’s own SMS group — it suppresses SMS sends in that one organization only. - Shared group across organizations A, B, C: one
/sms/addpush 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. - Unified SMS + voice: with both channels on one group,
/sms/addand/phone/addfor the same phone number write to the same underlying list; opting out one channel independently of the other is not possible until an administrator splits the channels onto distinct groups.
Authentication
The Unsubscribe API uses TruAgents’ standard OAuth 2.0client_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. A group is reachable with your credential when its owning organization is in your authorized set, or when any organization in your authorized set uses it on some channel.
Targeting a group
Every write and list request resolves to exactly one unsubscribe group. Three ways to pick it — use at most one:
Both fields travel as top-level body fields on
POST and as query parameters on GET. Supplying both group_id and org_slug in one request returns 400 bad_request. A pagination cursor already encodes the target group — supplying group_id or org_slug alongside a cursor also returns 400.
Every response echoes the resolved group_id, so the untargeted forms still tell you exactly which list you touched.
Authorized vs. unknown. The server returns 403 unauthorized_organization when the supplied org_slug — or group_id — is not reachable with your credential, including the case where it does not exist at all. The body echoes the offending value back so you can spot a typo without the server confirming whether it exists:
400 default_group_not_configured. Ask your TruAgents administrator to configure one, or target another list explicitly via group_id.
Rate limits. Per client_id, not per client_id × organization — see Rate limits for ceilings and how to request a bump.
Endpoints
Discover your unsubscribe groups
GET /api/v1/unsubscribe-groups returns every group reachable with your credential, with per-group usage:
GET /api/v1/unsubscribe-groups
idis thegroup_idyou pass when targeting the group.kindtells you which endpoints accept the group —emailfor/email/*,phone_numberfor/sms/*and/phone/*.unsubscribescounts identifiers currently opted out in the group (opted-back-in records are excluded).owneris the owning organization’s slug — ornullwhen the owner is outside your authorized set (you reach the group only because one of your organizations uses it).used_bylists which of your authorized organizations use the group, per channel. Organizations outside your authorized set never appear, even when they share the group.- The response is not paginated; a credential’s reachable set is expected to fit one response.
Query opt-out state
EachGET /api/v1/unsubscribe/{channel} returns a page of state-change records from one group, 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.
GET /api/v1/unsubscribe/email (default organization's email group, latest 50)
GET /api/v1/unsubscribe/sms (explicit group, since a fixed timestamp)
GET /api/v1/unsubscribe/phone (explicit org_slug)
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
group_ididentifies the group this page belongs to — the one you targeted, or the one yourorg_slug/ default organization resolved to.- Each record represents the current state of one identifier in this group; the page only contains identifiers whose most recent transition falls inside the pagination window.
unsubscribed: true⇒ opted out (TruAgents will not send on the channels routed through this group).unsubscribed: false⇒ opted in (record kept for history; sends proceed normally if no other suppression applies).sourcerecords 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). Theupdated_atreflects 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 withunsubscribed: false. On the voice endpoint,user_actionis visible when your administrator has unified your organization’s SMS and voice channels on one group and a Twilio event suppressed the shared record.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_atis the timestamp of the most recent state change.- Records are ordered by
updated_atdescending. next_cursorisnullwhen there are no more results.
Push opt-out / opt-in changes
The URL verb decides the direction:/add opts identifiers out, /remove opts them back in. Each request carries a batch of items targeting one group. Up to 10,000 items per request.
Items carry only the identifier — direction comes solely from the URL verb. Bodies that include a legacy unsubscribed field are rejected as an unknown field with 400 bad_request.
POST /api/v1/unsubscribe/email/add
POST /api/v1/unsubscribe/sms/add (explicit group)
POST /api/v1/unsubscribe/email/remove (opt back in)
/add endpoints. Under a unified group one call suffices — see Unsubscribe groups. To write to multiple lists, send one request per target.
Request body (same shape on every write endpoint; identifier field differs by channel)
Each item:
Response
group_idechoes the group the batch was applied to.- The batch is atomic — all or nothing. The whole request is validated before anything is written. Any invalid item rejects the entire request with
400 invalid_itemand zero rows persisted; there is no partial success and no skipped-items list. Fix the reported item and resend the whole batch. processedis the count of items written (alwaysitems.lengthon a200). The call is idempotent: items already at the requested state are still counted and listed inupdated, even though no state change occurred in the database.updatedlists every item — one input item produces exactly one entry. The returnedupdated_atis the row’s current state-change timestamp; to detect whether this write moved state, compare it against theupdated_atyou previously observed for the same identifier.
Conflicts and validation
An invalid item rejects the whole batch:400 with error: "invalid_item", the zero-based item_index of the first invalid item, and an item_error reason:
Request-level rejections (no
item_index):
Error responses
What TruAgents tracks per record
The channel is implicit in the endpoint you call; the group is echoed asgroup_id. Each record persisted by the API or by an internal source carries:
Opt-ins 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. Under a unified SMS + voice group, Twilio-sourced records surface on both the/smsand/phonelist endpoints — one record suppresses both channels. - Bulk imports — CSV uploads and CRM sync (HubSpot / Salesforce / Mailchimp). Source:
importon 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 under a unified group behave the same as provider-sourced ones: the single underlying record is visible on both the/smsand/phoneendpoints. - 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 fromuser_action, where a provider (SendGrid / Twilio) directly observes the recipient’s subscription action.
Common mistakes
- Posting to the bare
POST /api/v1/unsubscribe/{channel}path — it is not a write endpoint (onlyGETlists there). The direction lives in the URL:/addto opt out,/removeto opt in. - Sending an
unsubscribedfield in a write item — direction lives in the URL verb only, so the field is not part of the request contract and is rejected as an unknown field with400 bad_request. - Treating a
400 invalid_itemas partial success — the batch is all-or-nothing. Nothing was written; fix the item atitem_index(or dedupe / validate the batch client-side) and resend the whole request. - Targeting an
email-kind group from/sms/*or/phone/*(or aphone_number-kind group from/email/*) — group kinds are per-channel; checkkindin the discovery response. - Supplying both
group_idandorg_slug— pick one targeting form per request. Both present returns400. - Sending
group_idororg_slugalongside acursor— the cursor already encodes the target group. To switch lists, drop the cursor and start a fresh page. - Reusing cursors minted before this contract revision — they no longer decode and return
400 invalid_cursor. Drop the cursor and re-anchor with a fresh request. - Calling the wrong endpoint for the channel — SMS opt-outs go to
/api/v1/unsubscribe/sms/add, voice opt-outs go to/api/v1/unsubscribe/phone/add. Whether they share one list is decided by your administrator’s group configuration; check discovery. - Mixing targets in one batch —
group_id/org_slugare top-level; one request targets one group. Build one batch per target and send them in sequence or in parallel. - Assuming a
403 unauthorized_organizationmeans the value is wrong — it can also mean the organization or group exists but is not (yet) reachable with your key. The response does not distinguish the two; check discovery, then ask your administrator if you expected access. - Assuming an email opt-out applies across all your TruAgents organizations — organizations share a list only when an administrator points them at the same group. The discovery endpoint’s
used_byshows the actual sharing topology. - Using inconsistent phone formats — phones must be E.164 (
+15551234567), not(555) 123-4567or555-123-4567. - Treating every item in the
updatedlist as a state change. Compare the returnedupdated_atagainst 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 record may already exist at the requested state because an internal source (user_action,import,admin, oruser_intent) wrote it first — issue a GET before the POST if definitive no-op detection matters. - Branching on old channel-specific source values. Prior contract revisions surfaced
user_sendgrid/user_twilio(nowuser_action) andexternal_api(nowapi). Partner integrations must branch on the current five values only.

