Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

Build a Group MMS Delivery Tracker with TypeScript

A group message is sent once, but its outcome arrives one recipient at a time. That distinction matters when a dispatcher sends job instructions to a field crew. The initial API response only confirms that the message w

A group message is sent once, but its outcome arrives one recipient at a time.

That distinction matters when a dispatcher sends job instructions to a field crew. The initial API response only confirms that the message was queued. Later, separate delivery webhooks report whether each recipient received it, failed, or still has an unknown status. Replies arrive through another webhook path.

Crew Cast turns those events into one durable delivery board. It sends a Group MMS, tracks every recipient, records replies as acknowledgements, and keeps the board available across worker restarts and deployments.

This tutorial walks through the architecture and the reliability patterns behind it.

What the sample builds

The application exposes four routes:

Route Purpose
POST /cast Create or reopen a job actor and send the Group MMS
POST /webhooks/messaging Verify and process Telnyx messaging webhooks
GET /board/:cast_id Return the current delivery board
GET /board?cast_id=... Query the same board by parameter

The result is a per-recipient view of:

  • delivery status
  • carrier and line type, when returned by the API
  • the time of the latest finalized event
  • whether the recipient replied
  • the reply history for the group

Telnyx Group Messaging supports up to eight recipients per conversation, uses MMS, and is limited to US and Canadian destinations. Inbound group messages require v2 webhooks on the Messaging Profile. The Group Messaging guide documents those constraints.

One Stateful Actor per job

Every cast is identified by a job_id. The router turns that stable identifier into the name of a CrewCast Stateful Actor:

const actorId = env.CREW_CAST.idFromName(jobId)
const actor = env.CREW_CAST.get(actorId)

This is the central design choice. Every webhook for that job is routed back to the same durable entity, so the roster, delivery states, and replies do not depend on one particular function process staying alive.

The actor owns state such as:

type CrewState = {
  cast_id: string
  group_message_id: string | null
  recipients: RecipientState[]
  replies: Reply[]
  repolls: number
  notified_signature: string | null
}

The process may restart. The actor identity and persisted state remain tied to the job.

Persist the roster before sending

The sample writes the recipient roster before it calls the Messaging API. That ordering protects the system from a failure between "message accepted" and "roster recorded."

The live send uses the pre-authenticated Telnyx binding:

const response = await this.env.TELNYX.messages.send({
  from: crewFrom,
  to: recipients,
  text,
  media_urls: mediaUrl ? [mediaUrl] : undefined,
})

The API response contains a message id and a to[] array. Crew Cast stores the id as group_message_id and uses the recipient objects to seed carrier, line type, and initial delivery status.

That ID is the correlation key for the asynchronous delivery events described in the Group MMS API reference.

Route webhooks with KV

The application needs to find the correct actor without scanning every active job. It writes two small routing records to KV:

group:<group_message_id> -> cast_id
thread:<crew_number>     -> cast_id

When a message.finalized webhook arrives, the router looks up the cast by group_message_id. For an inbound message.received event, it can route the thread through the Telnyx number that received the reply.

KV is only the routing index. The full conversation state remains inside the actor, while SQL stores the reportable recipient rows.

Make delivery updates replay-safe

Messaging webhooks can be retried. Crew Cast treats each finalized event as an update to one logical record identified by:

(group_message_id, recipient_phone_number)

The actor checks the stored state before applying an update:

  • If the incoming status matches the stored status, it is a duplicate.
  • If the stored status is already hard-terminal, a stale update cannot move it backward.
  • If the recipient is not on the roster, the event is acknowledged without changing the board.
  • Valid later events can move a provisional unknown status to a final result.

No-op events still return HTTP 200. This prevents a harmless replay from becoming an endless retry loop.

unknown is also kept separate from delivery_failed. Unknown means the system does not yet have a definitive delivery result; it should not be presented as a confirmed failure.

Let replies acknowledge the job

Inbound group replies contain the sender plus a cc[] list of the other participants. Crew Cast folds the reply into the actor, deduplicates it by webhook ID, and marks the sender with an acked_at timestamp.

On the first reply, the actor can also use cc[] to backfill participants missing from its roster while excluding the thread owner number. This makes the group conversation itself an additional source of membership context.

Use SQL for the delivery summary

Actor state is ideal for owning one job, but the delivery board also needs aggregate answers. Crew Cast mirrors each recipient into a SQL table with a composite primary key:

PRIMARY KEY (cast_id, phone_number)

The report then uses a real aggregate query:

SELECT status, COUNT(*) AS count
FROM crew_delivery
WHERE cast_id = ?
GROUP BY status

This division of responsibility stays simple:

  • Stateful Actor: authoritative lifecycle for one job
  • KV: fast webhook-to-actor routing
  • SQL: recipient rows and grouped reporting

Schedule a bounded follow-up

If recipients remain queued or unknown, the actor schedules a follow-up after 30 minutes:

await this.schedule(1800, "repollUnknown")

The scheduled method reviews the durable state and can schedule one more bounded check. It does not invent a delivery result or replace the carrier webhook. A later message.finalized event remains the source of truth.

Once no recipient remains queued, the actor sends a summary SMS to the configured dispatch number. A signature derived from the totals prevents the same summary from being sent repeatedly.

Run the sample in demo mode

Clone the repository and enter the example:

git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/crew-cast
npm install

Create the local environment file and generate binding types:

cp .env.example .env
npx telnyx-edge types

Keep DEMO_MODE=true for the first run. Demo mode simulates the Group MMS send and accepts the included unsigned webhook fixtures, so you can exercise the state machine without sending a real message.

Run the smoke test:

npm run smoke

Deploy when the bindings and placeholder resources in telnyx.toml are configured for your account:

npm run deploy

Create a cast with your own authorized test destinations:

curl -X POST "$PUBLIC_BASE_URL/cast" \
  -H "Content-Type: application/json" \
  -d '{
    "job_id": "job-demo",
    "recipients": [
      "<CREW_MEMBER_1_E164>",
      "<CREW_MEMBER_2_E164>"
    ],
    "text": "Job details are ready. Reply ACK when received.",
    "media_url": "https://example.com/job-photo.jpg"
  }'

Then inspect the board:

curl "$PUBLIC_BASE_URL/board/job-demo"

The repository includes demo webhook fixtures for testing finalized delivery events and replies without real phone numbers.

Switch to live messaging carefully

For live operation, configure:

  • an MMS-capable Telnyx number for CREW_FROM
  • a Messaging Profile with v2 webhooks for CREW_PROFILE_ID
  • PUBLIC_BASE_URL/webhooks/messaging as the public webhook endpoint
  • TELNYX_PUBLIC_KEY as a secret
  • a valid dispatch destination
  • DEMO_MODE=false

In live mode, the router verifies the Ed25519 signature against the raw request body and rejects timestamps outside a five-minute window. Unsigned webhooks are accepted only in demo mode.

Before using the sample in production, add authentication and authorization to /cast and /board, define data-retention rules, restrict who can view recipient information, and monitor webhook failures. A job_id is an actor identifier, not an access-control mechanism.

The reusable pattern

Crew Cast is a messaging example, but the pattern applies to any workflow where one command produces many asynchronous participant results:

  1. Persist intent before triggering the external action.
  2. Use the provider's correlation ID as the event join key.
  3. Route every event to one durable entity.
  4. Fold retries idempotently.
  5. Keep provisional states distinct from confirmed failures.
  6. Store aggregate-friendly rows separately from lifecycle state.

The complete TypeScript sample, fixtures, and deployment configuration are available in the Telnyx code examples repository.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.