Skip to main content
Contact us
A tablet showing one game's mile credit run, with a ledger row per member and a retried job skipped

Project Story

A fan rewards program that credits miles to 145,000 members after every game

12Status gives fans one Delta SkyMiles mile for every Seahawks passing yard. We rebuilt it for Octagon and run it every season. Enrollment goes through Delta's loyalty APIs, and the crediting ledger is built so a game can only pay out once.

Client
12Status, for Octagon
Industry
Sports sponsorship
When
2024 to 2026
Status
Live, 2026-27 season
  • Next.js
  • TypeScript
  • tRPC
  • Prisma
  • PostgreSQL
  • Bull
  • Redis
  • Slack API
  • Amazon SES

What the program does

Fans in Washington sign up at 12status.com with their SkyMiles number, or open a new SkyMiles account right there, and then earn one mile for every passing yard the team throws that season. The miles live in Delta’s loyalty system. Our app holds the enrollments and, after each game, tells Delta’s API who to credit and how much.

Octagon runs the program, and we came in as their development partner in 2024. The first job was a rebuild of the platform they’d inherited, and honestly, we copied how the old one behaved before changing much of anything. This is our third season running it.

It’s a Next.js app with tRPC on top of PostgreSQL through Prisma. Long-running work (crediting a game, enrolling a batch, exporting members) goes through a Bull queue on Redis. Transactional email goes out through Amazon SES. Three tables carry the program: Delta12RegisteredUsers for enrollments, SeasonGame for the schedule and each game’s credit status, and MilesAccrualAuditLog, the ledger of who was paid for which game.

On October 26, 2026 the program had 145,911 members for the season: 98,424 returning from earlier seasons, 35,196 new to 12Status, and 12,291 who opened a SkyMiles account through the site to join.

Enrolling through Delta's APIs

There are two ways in. A fan who already has a SkyMiles number types it in, and we check it before anything reaches Delta. SkyMiles numbers carry a check digit (a mod 10 scheme with a couple of exception ranges that have none), so a typo gets caught on the form. A fan without an account gets one opened through Delta’s enrollment/lite endpoint, which takes a name, birth date, email and postal code and hands back a new SkyMiles number.

Either way, joining 12Status also shows up on Delta’s side, as a zero-mile accrual under the program’s affiliation product code. That call goes on the queue as a one-member job flagged enrollmentCredit, so a slow or failed affiliation for one new member can’t touch the status of a whole game.

Every Delta call authenticates with an OAuth client-credentials token from Delta’s identity server, cached until it expires. The enrollment call has a 30-second timeout. When Delta rejects a request, we log what the transport said, the content type, the body size, a short SHA-256 digest and any error code field. The body itself never gets logged. Delta’s validation errors quote the submitted value back, and that value is a member’s personal details.

Sign-up is limited to Washington. The site checks the visitor’s location with a MaxMind GeoLite2 lookup and checks the ZIP code against a table of eligible ones.

One game, one payout

After each game, a credit run starts with that game’s passing yards. For game 7 on October 26, 2026 that was 257 yards, and the run credited 145,481 members with 37,388,617 miles in total.

Who’s eligible is decided by one shared function that every path goes through, the first run and every retry. A member qualifies if they enrolled (or re-enrolled) at or before kickoff. Kickoff is stored as a Seattle date and time and resolved in America/Los_Angeles using the game’s own date, so games on either side of a daylight saving change get the right offset whatever time zone the server runs in. The yardage is capped at 1,000 per game, since the NFL single-game passing record is 554 and a slipped keystroke would otherwise go to Delta for every member.

A job like that can be started twice. Two people press the button, a job retries after a timeout, or a server restarts halfway through a batch. So the crediting is built so a repeat finds the work already done:

  • The game is claimed once. Starting a run moves the game to PENDING in one conditional UPDATE (where credited is not PENDING). Only one caller gets a row count of 1. Everyone else is told a run is already going. If the job never gets queued, the claim is handed back so the game doesn’t sit stuck.
  • Each SkyMiles number is paid once per game. One number can have several member rows, one per season, so the run dedupes by number before it starts.
  • The ledger entry comes first. Before we call Delta’s nonairAccrual endpoint for a member, we write a MilesAccrualAuditLog row for that member and game with confirmedAt empty. A retry that finds the row skips the member. If Delta fails, the row is deleted so the member can be retried. If Delta succeeds, confirmedAt is set.

The transaction id we send Delta is generated fresh for each call, so the dedupe has to happen on our side. That ledger row is the idempotency key. A row left with confirmedAt empty means the process died mid-call and we don’t know Delta’s answer. A sweep at the end of every run lists any such row older than 15 minutes for a person to check against Delta.

That design has a cost, and we picked it on purpose. If Delta’s call fails and deleting our row fails right after it, the row stays. The system then treats that member as paid when they weren’t. They come up short until someone looks, and Sentry gets an alert carrying the ledger row id, member row id and game id needed to fix it by hand (no SkyMiles number). A member who’s short can be topped up the same day. Clawing back miles that were paid twice is a far harder conversation for Octagon to have.

The final whistle starts a credit run. A retried job finds members already in the ledger and skips them, so the total stays exact.

Batches, retries and what the queue does

Delta is called once per member, so a run for every member takes hours. The creditMiles job works through members 25 at a time in parallel, with a pause of just over a second between batches to stay under Delta’s rate limit, and reports progress on the job as it goes. Members that fail get one automatic retry at the end of the run. The game then lands on COMPLETED, PARTIAL or FAILED. A Slack message posts the members credited and the miles awarded. If every member failed, the job throws so Bull’s own retry picks it up.

Failed jobs keep their payload in Redis, and those payloads carry SkyMiles numbers, so they’re dropped after 14 days by a cleanup that runs every six hours. Queue logs carry job ids, names and sizes and nothing from the payload.

To be fair about where this falls short, the queue was designed to run in its own worker process and it doesn’t. The Next.js server registers the processors when it imports the queue. So credit runs execute inside the web container. It works, and we’ve documented exactly why nothing in that file can assume a separate worker, but it’s on our list to split out.

Octagon runs it from Slack

Octagon’s staff kept needing things that used to mean a message to us: a member count at a specific moment, a few people enrolled by hand, a game credited. So we built a Slack bot and a matching admin panel that do those jobs directly.

  • /stats posts the current member counts, split by type.
  • /enrollments gives counts as of any date and time. Octagon asked where sign-ups stood at midnight and at 10am on game day so they could size a tailgate activation, and now they pull that themselves.
  • /enroll-users takes a CSV of members, either people with a SkyMiles number or people who need a new account opened.
  • /credit-game picks a game in a modal and starts a credit run after a confirmation step.

Every request is checked against Slack’s HMAC signature with a five-minute replay window, and then against a list of admin Slack user ids. The signature only proves Slack sent it, so the user check runs again on every confirm, in case a modal was left open across a deploy that changed the list.

Most of the work in /enroll-users is the CSV. Columns are matched by header name after stripping case and punctuation. SkyMiles #, sky_miles_number and skymilesnumber all land on the same field. A file with no header row is read by position. Dates come in as MM/DD/YYYY or the formats spreadsheets tend to export, and a date that doesn’t round-trip (February 30) is rejected. Each row is validated with the same code the admin panel runs in the browser, including the SkyMiles check digit. A duplicate email or SkyMiles number is reported on the later row. Rows with errors don’t sink the file. The bot shows which rows it will skip by their spreadsheet row number (header and blank rows counted) and enrolls the rest. A file with no valid rows, or more than 200 users, is refused.

Slack gives a modal submission three seconds, so the upload is capped at 1 MB with a two-second download timeout. The pending batch waits in Redis for 15 minutes under a one-time id, since Slack’s private_metadata field holds 3,000 characters. Confirming deletes that key, and only the request whose delete removed it may enqueue, so two quick confirms can’t both submit. The job id is pinned to the same review id, and Bull dedupes it if an enqueue is retried. When the batch finishes, the bot posts one message with every row’s result attached as a CSV.

Manual enrollment exists because sign-up on the site is limited to Washington by location, and residents who happen to be traveling get blocked. Staff can now vouch for them and enroll them from Slack.

A staff member types a slash command in Slack and the bot replies with a confirmation card before anything runs.

The season work around it

The 2026-27 season is the program’s tenth. We rebuilt the landing page from Octagon’s Figma designs in July, then rolled the dates and copy for the new season in August, the same as every year.

Questions about this project

HQ, a software design and development agency in Ogden, Utah, rebuilt and runs 12Status for Octagon, a fan rewards program where members earn Delta SkyMiles for every Seahawks passing yard. HQ's app handles enrollment, including opening new SkyMiles accounts through Delta's enrollment API, and credits miles to every member through Delta's loyalty API after each game.

HQ built the crediting in three layers. A game is claimed for crediting in a single conditional database update, so only one run can start. Each SkyMiles number is credited once per game, even if a member has duplicate records. And a ledger row is written before Delta is called for each member, so a retried job sees the member is already done and skips them.

In 12Status, HQ writes a ledger row for the member and game before calling Delta's accrual API. If Delta's call fails, the row is deleted and the member is retried once automatically at the end of the run. If the row can't be deleted, it stays and the member is treated as credited. An alert then goes to Sentry with the ids needed for a manual fix. HQ chose that trade so a failure leaves a member short, which is easy to top up, rather than paying a member twice. Rows whose outcome is unknown, because the process stopped mid-call, are listed by a sweep for someone to check against Delta.

On October 26, 2026, 12Status had 145,911 members for the season. That day's credit run for game 7, a 257-yard game, credited 145,481 members with 37,388,617 miles in total.

The 12Status Slack bot, built by HQ, matches CSV columns by header name in any order (or by position when there is no header row) and validates every row with the same code the admin panel uses. It checks the SkyMiles check digit, real calendar dates and duplicate emails or SkyMiles numbers within the file. Rows with errors are skipped and reported by their spreadsheet row number. The valid rows are enrolled after a confirmation step, and a file with no valid rows or more than 200 users is refused.

Yes. HQ built a Slack bot and an admin panel so Octagon's staff can pull member counts, see enrollments as of any date and time, enroll members in bulk from a CSV file, and start a game's credit run themselves. Every Slack command is limited to a list of admin users, and enrollment and crediting both ask for confirmation before anything runs.

Let's build together

Whether you're looking to design, build, update, or refresh an existing app or website, we can help. Even if we're not a good fit, we will help point you to somebody who might be.

Contact us