Messaging

Contacts & opt-out

A minimal consent ledger — one row per phone, per environment. Not a CRM. Opt-out blocks sends to that number until an explicit override is recorded.

Opt in / opt out

POST/v1/contacts/opt-in
POST/v1/contacts/opt-out
curl https://api.tyxter.com/v1/contacts/opt-out \
  -H "authorization: Bearer $TYXTER_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "phone": "+5511999999999",
    "source": "api",
    "reason": "Requested via Intercom ticket #4821"
  }'

Both endpoints are idempotent. Re-applying the same state no-ops and does not emit a second webhook.

Import an opt-out list

Call POST /v1/contacts/opt-out for each E.164 phone with contacts:write scope. Do not use /v1/contacts/bulk-import: that endpoint opts contacts in.

Opt-out uses the shared source-IP admission bucket: 10 requests/second sustained, with a 60-request API-origin burst capacity. Other protected requests from the same IP share this allowance; edge admission can also return 429. Start at two requests/second with one request in flight. This pacing does not reserve capacity or guarantee no 429s. On 429, wait at least Retry-After (seconds or HTTP date) and retry the identical body with the same Idempotency-Key. Use capped exponential backoff if the header is absent. Stop after bounded retries or on other errors.

Save this as import-opt-outs.py. Set TYXTER_API_KEY, IMPORT_ID (a stable ID for this list), and optionally TYXTER_API_BASE_URL for local rehearsal. Run python3 import-opt-outs.py < phones.txt with one E.164 phone per line. Start in sandbox. Preserve the import ID when resuming an unchanged list after an error; use a new ID for a different consent operation. Failed rows are never skipped.

import hashlib
import json
import os
import sys
import time
import urllib.error
import urllib.request
from email.utils import parsedate_to_datetime

# One E.164 phone per line; keep IMPORT_ID stable when resuming this list.
base = os.environ.get("TYXTER_API_BASE_URL", "https://api.tyxter.com").rstrip("/")
key = os.environ["TYXTER_API_KEY"]
import_id = os.environ["IMPORT_ID"]
phones = list(dict.fromkeys(line.strip() for line in sys.stdin if line.strip()))
completed = 0
for phone in phones:
    body = json.dumps({"phone": phone, "source": "api"}).encode()
    digest = hashlib.sha256((import_id + "\n" + phone).encode()).hexdigest()
    request = urllib.request.Request(base + "/v1/contacts/opt-out", body, {
        "Authorization": "Bearer " + key,
        "Content-Type": "application/json",
        "Idempotency-Key": "opt-out-" + digest,
    })
    for attempt in range(8):
        time.sleep(0.5)  # Two requests/second, one in flight.
        try:
            with urllib.request.urlopen(request, timeout=30) as response:
                response.read()
            completed += 1
            break
        except urllib.error.HTTPError as error:
            if error.code != 429 or attempt == 7:
                raise SystemExit(f"Stopped: HTTP {error.code}; completed {completed}. Resume with the same IMPORT_ID.")
            retry_after = error.headers.get("Retry-After", "")
            error.read()
            try:
                delay = float(retry_after)
            except ValueError:
                try:
                    delay = parsedate_to_datetime(retry_after).timestamp() - time.time()
                except (ValueError, TypeError, OverflowError):
                    delay = min(60, 2 ** attempt)
            time.sleep(max(1, delay))
print(f"Completed {completed} opt-outs")

Auto opt-out on inbound

The inbound worker scans the text body of every incoming message against the Brazilian opt-out keyword list (case-insensitive, trimmed):

parar, sair, cancelar, stop

A match transitions the contact to opted_out with source inbound_keyword and emits contact.opted_out. Every subsequent outbound send to that phone is blocked with 400 contact_opted_out until you explicitly opt them back in.

Honor opt-out requests promptly — both LGPD and Meta's Business Platform policies require it. The consent service is the single place where outbound sends are gated, so once you've opted a contact out, every code path is covered.

LGPD: data export and erasure

POST/v1/contacts/{id}/export

Returns the contact row plus a summary of every Message associated with the contact's phone (last 500, ordered descending). The response includes a generated_at timestamp — record it in your audit trail.

DELETE/v1/contacts/{id}

Tombstones the phone number to +erased-<suffix>, clears metadata and opt-in source, and redacts the payload and metadata of every associated Message. Returns the count of messages redacted and emits contact.erased.

Erasure is a soft scrub, not a hard delete. Contact ids are referenced by audit logs, outbox events, and usage records — rewriting those would rewrite immutable history. The PII is gone; the integrity chain stays intact.