Table of Content
No headings found on page

How to Set Up Automated Gym Billing (Step-by-Step)

Setting up gym billing seems easy until failed webhooks lock paying members out of the building. A practical look at building recurring payment systems that don't break in production.

Kartikey Mishra

Business

Dec 29, 2025

Billing in Computer

Everyone thinks gym billing is just running a cron job on the 1st of the month to charge credit cards. It isn't.

In a standard SaaS app, if a payment fails, you show the user a red banner on their next login and block access to the dashboard. It's entirely digital. Gyms are physical. If you get the billing state machine wrong, a legitimate member gets locked out in the cold at 5 AM. The front desk staff gets yelled at. Developers end up manually patching database records while customer support panics.

In reality, gym billing is a distributed state machine. Half the critical state changes happen asynchronously via webhooks from an external payment gateway. The other half interact with physical turnstiles and RFID readers out in the real world.

This part looks simple on a whiteboard. It usually isn't. Here is how these systems are pieced together when they actually survive production.

Step 1: Offload the Billing Engine

First thing is getting out of the way of PCI compliance. Don't touch raw credit cards.

Use Stripe, Braintree, or Adyen. You create a customer record in your local database and map it to the provider's customer_id. The gateway handles the vaulting, the security, and the recurring schedule. This part is actually pretty simple. Don't overcomplicate it.

Just store the gateway's ID and the current plan reference. Your system shouldn't know when the next billing date is natively. It should only know what the payment gateway told it last.

Step 2: The Webhook Queue (Where things break)

Setting up the initial recurring charge is a single API call. Handling the lifecycle is where teams severely underestimate the work.

You need an endpoint to listen to webhooks from the gateway. Stuff like invoice.payment_succeeded, invoice.payment_failed, and customer.subscription.deleted.

A lot of devs just parse the payload and update the database directly in the HTTP handler. This breaks when events start arriving out of order. And they will arrive out of order. A retry webhook might hit your server before the original failure timeout resolves.

Put the incoming webhooks straight into a queue (SQS, RabbitMQ, whatever). Acknowledge the HTTP request immediately with a 200 OK so the gateway doesn't retry. Then, have background workers process the queue idempotently.

If an invoice succeeds, the worker updates the valid_until timestamp on the user's membership in your DB. If you process the same webhook twice, the timestamp just updates to the same value. Safe.

Step 3: Grace Periods and "Past Due" Purgatory

What happens when a card declines? The payment provider usually retries a few times over a few days. This is dunning.

During this window, the subscription isn't fully dead, but it's not healthy either. It's "past due". You have to decide if the member can still get in the building. Usually, gyms give a 3 to 5-day grace period.

If you just flip a boolean is_active to false immediately on the first decline, your staff is going to have a miserable time dealing with angry people who just got new bank cards in the mail. Store a discrete status string (active, past_due, canceled) and rely on an access_ends_at timestamp rather than a simple true/false flag.

Always store this timestamp in UTC. Local timezones will mess up your 11:59 PM expiration logic eventually.

Step 4: Physical Door Sync and Partitions

Gyms have physical turnstiles. Your cloud database being accurate doesn't matter if the local RFID system thinks the user is suspended.

Most door control systems have their own APIs. When your background worker updates the database to "canceled", it also needs to make a network call to the door provider to revoke the physical credential.

This network call will fail sometimes. The door provider's API will rate limit you, or a local network partition at the gym will drop the connection.

You need a retry mechanism here, or the systems get out of sync fast. A common pattern is an outbox table. When the subscription state changes, write an event to an outbox table in the same database transaction. A separate process reads that table and hammers the door API until it succeeds, then marks the outbox record as done. If the door API goes down, your queue backs up, but nobody gets permanently out of sync.

Step 5: Handling Database Race Conditions

Let's say a user hits the "upgrade plan" button twice because the frontend is slow. Or Stripe fires two identical webhooks at the exact same millisecond.

If your worker reads the user's state, performs some logic, and saves it, two concurrent workers will overwrite each other. You need a database lock.

Use pessimistic locking (like SELECT ... FOR UPDATE in Postgres) when processing a webhook for a specific user. This forces the second webhook worker to wait until the first one finishes the database transaction. It slows down processing slightly, but it prevents duplicate credits or overlapping subscription records.

Step 6: Membership Freezes (The Notorious Edge Case)

People get injured. People go on vacation. Every gym needs a "freeze" feature, and this destroys standard billing cycles.

Do not try to build custom logic that pauses your internal cron jobs. Instead, use the billing gateway's native pause features. In Stripe, you can pause collection or apply a 100% discount coupon for a specific number of billing cycles.

Your local system just needs to record the frozen_until date and push a temporary suspension to the door API. Let the payment provider handle the complex math of shifting the billing anchor date when they unfreeze.

Step 7: Roaming and Multi-Location Access

If you operate multiple gym locations, access gets vastly more complicated. A user pays at Location A, but scans their phone at Location B.

Does Location B's local turnstile database know about this user?

Usually, door systems are scoped per facility. When a webhook worker marks an invoice as paid, it shouldn't just update the "home" gym. It needs to broadcast an access update to the door controllers of every facility included in that user's tier. This amplifies the need for the outbox pattern mentioned in Step 4. One payment success might equal five distinct door API calls.

Step 8: Chargebacks and Proration

Eventually, someone will issue a chargeback through their bank instead of canceling through your app.

You will get a charge.dispute.created webhook. Treat this as an immediate cancellation. Update the status to disputed and kill their physical door access immediately. Don't build automated logic to fight the chargeback. Handle the state change so they can't keep working out while the bank investigates.

For proration—when someone switches from basic to VIP mid-month—try to avoid building custom math. Let the billing gateway handle it. They will calculate the unused time, generate an invoice for the difference, and charge it. Listen for the successful payment webhook and update the plan_tier in your DB. Custom proration logic is a great way to introduce rounding errors that mess up accounting later.

Surviving Production

Launching an automated billing system is mostly an exercise in paranoid logging.

On day one, a webhook will fail to parse because of an unexpected null field. A door controller in a basement will lose Wi-Fi. A user will somehow manage to trigger a cancellation and an upgrade in the exact same second.

Don't aim for a system that never fails. Aim for a system that fails safely. If a process dies, it should be able to wake back up, read the queue or the outbox table, and pick up exactly where it left off without double-charging anyone. Keep the state simple, defer the heavy lifting to the payment gateway, and always assume the physical door API is about to go down.

FAQs

1

1

Should we build our own recurring cron job to trigger charges?

No. Rely on the payment provider's internal clock to trigger subscription invoices. Building your own billing cron job means you have to handle timezone shifts, leap years, and retry logic yourself. Let Stripe do it.

2

2

How do we handle webhook failures if our server goes down?

Most major gateways will automatically retry webhooks for up to 3 days using exponential backoff. But if your worker logic is failing (e.g., a bad database migration), those webhooks will eventually be dropped. You need a Dead Letter Queue (DLQ) for events that fail processing multiple times so you can manually replay them later.

3

3

Why not just query the payment API directly when the user scans their card at the door?

Latency. Door scans need to resolve in under 500 milliseconds, or people walk into the turnstile. Making an external HTTP call to a payment gateway during a physical scan is too slow and fragile. Your local database needs to be the source of truth for physical access.

4

4

What happens if someone pays in cash at the front desk?

You need an API endpoint for staff to manually log an external payment. This endpoint should essentially fake the "payment succeeded" flow, updating the valid_until timestamp and pushing the active state to the door system, bypassing the payment gateway entirely for that cycle.

5

5

How do you test all these asynchronous states locally?

Use gateway CLI tools (like the Stripe CLI) to forward webhooks to your localhost. You can trigger specific events like invoice.payment_failed manually from the terminal to see how your state machine and queue workers handle the transition without waiting for real billing cycles.

Join PulseFit, the best gym management software in 2026

Address

Ireo The Corridors, Sec 67, Gurugram

10:00 AM - 19:00 PM

Copyright © 2026 PulseFit

Join PulseFit, the best gym management software in 2026

Address

Ireo The Corridors, Sec 67, Gurugram

10:00 AM - 19:00 PM

Copyright © 2026 PulseFit

Join PulseFit, the best gym management software in 2026

Address

Ireo The Corridors, Sec 67, Gurugram

10:00 AM - 19:00 PM

Copyright © 2026 PulseFit