Messaging

Broadcast batches

One template, many recipients, one API call. Runs on a dedicated queue so broadcasts cannot starve transactional sends.

Create a batch

POST/v1/batches
curl https://api.tyxter.com/v1/batches \
  -H "authorization: Bearer $TYXTER_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "name": "April promo",
    "from": "pn_...",
    "template": { "name": "promo_april", "language": "pt_BR" },
    "recipients": [
      { "to": "+5511999999001", "variables": { "1": "Diego" } },
      { "to": "+5511999999002", "variables": { "1": "Ana" } }
    ]
  }'

Returns 202 with a message_batch.

The approved template version, not a batch field, chooses its parameter format. Inline recipients may use exact POSITIONAL numeric keys or exact NAMED BODY parameter names through the existing variables map. Every recipient is checked before the batch is written; a missing, extra, or wrong-format key returns 400 template_param_mismatch at recipients.<index>.variables. Inline batches cannot supply text-HEADER or dynamic URL button values, so those templates synchronously return param: template.

{
  "name": "Named order updates",
  "from": "pn_...",
  "template": { "name": "order_tracking_update", "language": "pt_BR" },
  "recipients": [
    { "to": "+5511999999001", "variables": { "customer_name": "Diego", "order_id": "ORD-1" } },
    { "to": "+5511999999002", "variables": { "customer_name": "Ana", "order_id": "ORD-2" } }
  ]
}

Target a saved audience

Use audience_id instead of inline recipients when you want to reuse a saved contact list.

curl https://api.tyxter.com/v1/batches \
  -H "authorization: Bearer $TYXTER_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "name": "April promo",
    "from": "pn_...",
    "template": { "name": "promo_april", "language": "pt_BR" },
    "audience_id": "aud_..."
  }'

Audience membership is snapshotted when the batch is created. Existing batches are not affected by later audience edits.

An audience has no per-recipient template-variable source. Any approved template requiringPOSITIONAL or NAMED BODY variables, text-HEADER values, or dynamic URL button values is refused synchronously with template_param_mismatch and param: template; Tyxter does not interpolate contact fields.

Lifecycle

pending     →  just accepted, nothing enqueued yet
enqueuing   →  worker is walking the recipient list
sending     →  every recipient has been enqueued; sends are in flight
paused      →  enqueue cursor is preserved until resume
completed   →  every send terminal (sent / failed / opted_out)
failed      →  the batch itself failed before enqueuing could complete
cancelled   →  manual cancel; no more recipients are enqueued

What the worker does

  1. Pulls the MessageBatch by id.
  2. Pages through recipients 100 at a time, resuming from enqueued_count on retry.
  3. For each recipient, checks consent. Opted-out recipients get a failed Message row with error_code: contact_opted_out — no send is ever attempted.
  4. Mandatory Tyxter guards apply to marketing in sandbox and production. Identical template name, language, parameters and media content remain blocked until an inbound message after the previous attempt; there is no timed duplicate expiry within retained message history. Reuploading identical bytes under a new asset ID does not change content. A read receipt is not engagement. Different content on a healthy sender is allowed. A seven-day sender/recipient cooldown applies only when phone or template quality is YELLOW/RED or a policy warning occurred within seven days. An inbound message after the latest marketing attempt exempts duplication and that cooldown, even after the 24-hour service window closes. General opt-outs and Meta marketing stop preferences take priority; only a Meta marketing resume clears that provider preference. Meta 131049 requires at least 24 hours before retrying even after engagement; two consecutive 131026 refusals or a latest 130472 holdout require new inbound engagement. Utility and authentication are outside these marketing guards; general consent still applies. HTTP 400 recipient_duplicate_message or recipient_frequency_capped includes error.details.guard_reason, error.details.next_allowed_at and error.feedback. next_allowed_at is null for pending sends or recovery requiring engagement or consent; never schedule a retry from null. Missing or unreadable policy, unclassifiable pending templates, or new marketing media without verified hashes returns 503 recipient_cadence_policy_unavailable. Legacy media equality without a retained hash remains unknown and is logged; no migration invents historic media equality. Queued templates are checked again before dispatch. Batch failure exports expose guard_reason and next_allowed_at when the failure receipt is retained. Recorded provider rejections count as attempts. Guard skips create no provider attempt or message-processing charge and do not renew the cooldown. Customers cannot disable or shorten these protections. They do not guarantee delivery or improved sender quality. Protected recipients appear in recipient_guard_skipped_count within failed_count. Export failures to inspect each reason and recovery instruction.
  5. Allowed recipients get a fresh accepted Message row and a broadcast.send job on the outbound.broadcast queue.
The same SendOutboundMessageUseCase processes both transactional and broadcast jobs. The separation is purely queue-level, which is enough: a broadcast of 50k messages can't delay a time-sensitive OTP because they never share a worker.

Pause, resume, cancel

POST/v1/batches/{id}/pause
POST/v1/batches/{id}/resume
POST/v1/batches/{id}/cancel

Pause stops future enqueue pages while preserving enqueued_count. Resume requests the enqueue worker again and continues from that cursor. Cancel is terminal for the batch enqueue worker.

Scheduling

Pass scheduled_for (ISO-8601) to delay the batch. The outbox publisher respects available_at, so the job simply won't be picked up until that time.

Tracking progress

GET/v1/batches/{id}

Poll the batch row for enqueued_count, sent_count, and failed_count. Individual per-recipient messages carry the batch id in their metadata.batch_id (it replaces any batch_id you pass in the batch metadata) so you can query GET /v1/messages for the full picture.

GET/v1/batches/{id}/failures

The failure export returns failed per-recipient rows for CSV or support workflows.