I built a Discord bot for TAMU ColorStack's 100+ member server. Members fill out one profile form, check in to
events with a short code, and look up their own points. Officers don't touch attendance data at all.
The chapter talks on Discord, but attendance lived in Airtable.
members
Filled out an Airtable form at every event, typing their name, email, major and class year again each time.
Waited for an officer to give them their server roles by hand.
Couldn't see their own points.
officers
Had to dig through Airtable to see who came to what, while everything else they work on is in Notion.
Counted points by hand from those form responses.
The form was long enough that people skipped it, so the attendance numbers were low before anyone even looked at them.
02 users & research
Before writing code I talked to the officer board and to members after meetings, and I kept asking them between releases. A few things came up over and over:
Members skipped the form when they were in a hurry. One told me, "If I have to type my major again I'm just not doing it."
Officers wanted the data in Notion, where they already plan events. "I'd have to export the Airtable to figure out who had enough points for anything."
Members don't want to leave Discord. Every link out to a form lost people, so the whole flow had to happen in the server.
The member records are messy. Nicknames, typos and personal vs. school emails meant exact matching would lock real members out.
At a full meeting, anything slower than typing a short code backs up the room.
03 goals & success metrics
goal
how i measured it
target
result
active members verified
% of active members linked to a directory row
80%
~85%
fast check-in
time for one member to check in
under 1 min
under 1 min (was ~5)
no manual point tracking
officer time spent on attendance and points
~0
~0
members can do it alone
members who needed help to verify
a handful
3
always on
outages this semester
0
0
04 key product decisions
where the data lives
keep Airtable
Notion only
SQLite + Notion sync
Officers already work in Notion, so the data had to end up there. Notion's API is rate-limited, though, and a bot that waits on it would stall when a room checks in at once. SQLite takes every write instantly, and a sync worker copies changes into Notion.
Tradeoff: two copies of the data means sync logic, which is where the race condition came from (see architecture).
one profile form, filled out once
a form per event
one Notion member form + a check-in code
The Airtable form asked for the same details at every event. Now members fill out the Notion member form once, and the bot links it to their Discord account. After that, checking in is a single command with the event code.
how strict verification should be
exact email match only
officer approves everyone
exact match → fallback match → officer review
Exact matching locked out real members with messy records, and approving everyone by hand doesn't scale to a 100+ person server. The bot tries an exact match, then a looser one, and only sends what's left to an officer.
how members check in
react to a message
scan a QR code
officer-generated check-in code
Anyone can click a reaction from home, and QR needs a screen at every event. A short, time-limited code read out in the room shows you were there and works at a volleyball practice as well as a general meeting. QR is next on the roadmap as an extra option.
who can do what
hardcoded officer IDs
one admin role
member / officer / admin tiers mapped to Discord roles
The officer board changes every year. Because permissions follow Discord roles, handing the bot over means reassigning roles, with no code change or redeploy.
05 technical architecture
membersdiscord
→
Stack botpython · discord.pydocker on fly.io
↔
SQLitesource of truth
↓ sync worker (locked) ↓
officersread + edit
←
Notiondirectory · event log · points
Members only use Discord. The bot writes to SQLite right away, and a locked sync worker copies changes into Notion for officers.
SQLite keeps check-ins fast and correct. Notion is where officers read the data. The bot never waits on Notion before answering a member.
the sync race condition for engineers
Early on, two things could touch the same member at once: a check-in awarding points and the sync worker pushing that member to Notion, or two check-ins landing together. Both read the row, both computed a new total, and whichever wrote last won. Points disappeared without any error.
I fixed it with a per-member asyncio.Lock around every read-modify-write and a version number on each row. The sync worker takes the same lock, snapshots the row, pushes it to Notion, and only clears the dirty flag if the version is unchanged.
async with locks[member_id]:
row = db.get(member_id)
row.points += award
row.version += 1
row.dirty = True
db.save(row)
identity verification for engineers
New members see a "Get Verified" message. It's registered as a persistent view, so the button still works after restarts and redeploys. Clicking it opens a modal for name and email, and then the matcher runs:
exact email match against the directory
normalized fallback: lowercased, trimmed, school vs. personal email, name similarity
no confident match: queue it for an officer with the closest candidates attached
A match gives the member the Member role and their class-year role and links their Discord ID to the directory row.
permissions design for engineers
Every command declares a minimum tier: member, officer or admin. One check maps the user's Discord roles to a tier before the command runs, so members never see officer commands like starting attendance or adjusting points. A new officer gets access as soon as their role changes.
deployment for engineers
The bot runs in a Docker container on Fly.io, with the SQLite file on a persistent volume and the bot token and Notion key in Fly's secret store. Shipping a change is one deploy, and no laptop has to stay on. It has run since August without an outage.
06 execution & iteration
I shipped small versions, watched how officers and members used them, and let their feedback decide what came next.
v1
verification + roles. Linked Discord accounts to the directory and assigned roles automatically. Learned: exact matching failed on messy records.
v2
fallback matching + officer review. Most members verified on the first try, and the rest went to a short officer queue.
v3
attendance codes + points. Replaced the per-event Airtable form. An officer starts attendance with one command, members type the code, and points show up right away.
v4
SQLite + Notion sync. Moved everything off Airtable into a shared Notion workspace, with SQLite underneath for instant writes. Fixed the sync race condition with locks.
v5+
points by event type, leaderboard, role tiers. General meetings are worth 3 points and smaller events 1. Members can check their standing any time, and officers got a screen-capture tutorial for running attendance.
07 results
Numbers from the Notion event log, Sep 13 to Oct 6, 2026:
13events run on check-in codes
110check-ins logged
186points awarded automatically
~85%of active members verified
5 min → <1time to check in
0outages on fly.io
Officers spend no time on attendance or points now. The event log and member directory in Notion fill themselves in.
Even small events use codes. Volleyball practice, dodgeball, and the weekly web dev and LeetRooms sessions are worth 1 point, and members still check in.
64 members have filled out the one-time profile form in the Notion directory.
Only 3 members have needed help getting verified, and I rarely get asked to fix anything.
08 what's next & what i learned
roadmap
QR code check-in as a second option next to codes
more analytics views in Notion for officers, like attendance trends and who's dropping off
lessons
How easy something is to use decides how often people use it. When checking in took five minutes of retyping, members skipped it. At under a minute, they check in at volleyball practice for a single point.
Design for the messy data you have. Clean records were never coming.
SQLite and one lock fixed the lost-points bug.
Ship small and let feedback pick the next version.