Skip to content

We built an NYC marathon tracker that texts you

Texts don't need an app, an account, or a notification setting turned on. So that's what we built a race-day tracker around.

Marathon runners crossing the Brooklyn Bridge, with a text reading: Alex just reached halfway! 13.1 miles down.

A few of us have friends running the NYC Marathon on Sunday. If you’ve spectated before, you know the drill. You pick a spot, get there early, and watch the NYRR app so you don’t miss the ten seconds your runner is actually in front of you to cheer them on.

Texts don’t need an app, an account, or a notification setting turned on. That’s what we built around. A runner shares a link, friends pick the marathon milestones they want updates on, and each friend gets a text when the runner reaches them.

We build text messaging infrastructure at Sent, so this was a chance to show what an afternoon build with our API looks like.

If you’re running

  1. Enter your name and bib number.
  2. We text you a verification code. Your bib is matched against the official runner list, so the page is tied to you.
  3. You get a link to your page. Share it with whoever you want. You’ll be able to see everyone who’s signed up.

If you’re cheering on a runner

If a runner sent you a link:

  1. Open the link the runner sent you.
  2. Pick the milestones you want a text for - every mile, the finish line, or just close to where you’ll be standing.
  3. We text you a verification code. You’ll only get texts for the milestones you selected.
  4. On Sunday, you get a text as your runner reaches each one. You can change your selections at any time.

How we built this

There are four components: a Next.js app on Vercel, a Supabase database, a Trigger.dev worker, and the Sent API.

Core services

Diagram of the tracker: the website saves sign-ups to the database, background jobs read race results and process the message queue, and the Sent API texts each friend's phone

The web app handles signup and sharing, and saves each friend’s milestone picks to Supabase. The worker processes normalized checkpoint events, queues eligible messages, and sends them through Sent. We tested this pipeline with simulated events. Our webhook handler verifies Sent’s callbacks and saves them for processing. Delivery events update message status - inbound STOP messages and opt-out events block future sends.

From checkpoint to text

Diagram of one checkpoint becoming texts: the checkpoint arrives, recipients who chose it are found, messages are saved, then sent through Sent

A checkpoint arrives with the runner’s ID, the milestone, and the original crossing time. The database selects active subscriptions for that runner and milestone, checks phone verification and opt-out status, and excludes subscriptions started after the crossing. The worker rechecks eligibility before sending. The database records the checkpoint and queues eligible messages in one transaction. Unique constraints allow only one delivery per subscription and checkpoint result, so replaying an event doesn’t create another queued message. Then it sends through Sent using an idempotency key saved on that row.

Note that accepted by the API isn’t the same as delivered to the phone. Sent returns a message ID when it accepts the request, and the worker records that. Delivery status arrives asynchronously through Sent’s callbacks. Separate inbound-message and opt-out events update the recipient’s permission to receive future texts.

If a request fails transiently, retries reuse the same saved key and body to prevent duplicate sends within Sent’s 24-hour idempotency window.

Sent API in practice

Here are two shortened pieces from the integration: the first is the request for a single race update, and the second is how the worker records that Sent accepted it.

Personalized race update

Shell
curl https://api.sent.dm/v3/messages \
  -H "x-api-key: $SENT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: demo-josh-halfway-001" \
  -d '{
    "to": ["+12125550190"],
    "channel": ["sms"],
    "template": {
      "id": "YOUR_PROGRESS_TEMPLATE_ID",
      "parameters": {
        "runner_name": "Josh",
        "checkpoint": "HALF",
        "elapsed": "1:45:00",
        "message": "Josh just reached halfway!"
      }
    },
    "sandbox": true
  }'

Swap in your own template ID and parameters. sandbox: true validates the request and returns a simulated response without sending a text. We used it in a timed cloud replay, driven by Vercel Cron, to test request handling and reconcile the expected messages. Sandbox mode doesn’t verify template existence, approval, or handset delivery. Live sending requires a configured sender, the appropriate template and consent, and enabled live-send settings. Use a new operation key for the live request.

Send once, retry the same operation

TypeScript
// Inside our delivery worker, after eligibility checks:
const receipt = await transport(
  delivery.idempotency_key,   // saved once per delivery
  prepared.request_body,      // frozen before the first call
  delivery.mode,
);

await rpc.call("complete_delivery", {
  p_id: delivery.id,
  p_lease: delivery.lease_token,
  p_status: receipt.sandbox ? "sandbox_accepted" : "accepted",
  p_message_id: receipt.messageId,
  p_request_id: receipt.requestId,
  p_replayed: receipt.replayed,
});

The idempotency key and request body are saved before the first call, and every retry reuses them. After validating Sent’s response, the worker records the message ID and marks the row accepted. Delivery confirmation is a separate status. Our receipt.replayed field reflects Sent’s Idempotent-Replayed response header, indicating that the API returned a cached response for the same operation.

Note that transport and rpc are our helpers - not Sent SDK methods.

If you want to build something like this

Sending a text message is deceptively simple. Numbers need to be registered, and carriers want to know who you are and what you’re sending before they’ll deliver anything.

Sent takes care of that part, so we could focus our attention on runner matching, subscriptions, and race updates - and make an afternoon prototype possible.

To everyone running on Sunday - best of luck, and see you out there!

Share

Published on

Read More

  • A Claude chat box with the Sent connector being switched on

    Sent is now an official Claude connector

    Send SMS, WhatsApp, and RCS from Claude, check delivery, look up a number, and see what you need before you text a new country, all through the same gates as the API.

  • The Sent and Zapier app icons side by side on a field of blue dots

    Sent is now a native app on Zapier

    Start a thread off a new database row, send reminders off a calendar event, and route replies into your CRM, with no webhook endpoints to host.

  • An RCS message bubble reading 'Sending a message' on a field of blue dots

    Is RCS finally ready for its moment?

    Branded profiles, richer content, and built-in actions are changing the inbox. What will it take for RCS to become an everyday business channel?