The core idea behind Featherboard is to keep the core functionality accessible without a paid plan. You can run the app on your own hardware for free, while the hosted version offers the convenience of having it managed for you for a small fee.
So I've started implementing billing with Stripe. It's still in the early stages of development, but I've already discovered that getting a simple Checkout flow working is easy while making sure the local subscription state stays in sync with Stripe is the part that's actually challenging.
Starting a Checkout Session
What's currently built is a simple endpoint that starts a Stripe Checkout Session and is only visible to Admin accounts. It uses a Price ID from an environment variable and authenticates with a secret key from another one. Those values should never be in the source code. Stripe takes care of the rest: collecting and handling the payment details.
That said, when you're dealing with a multi-tenant application, it's important to handle Stripe callbacks correctly so subscription changes are associated with the right organisations. To achieve that, each Checkout request includes Featherboard's organization ID in the new subscription's metadata, which the webhook reads to associate the subscription with the right organisation.
Syncing with Stripe through webhooks
Getting a redirect from Stripe Checkout is not evidence of a successful payment. There are a lot of things that could go wrong, the user could cancel the payment, the card could be rejected, and so on. That's why I'm using a server-to-server webhook to reliably sync subscription changes.
After receiving a webhook event, the app gets a signature in request header. It uses that signature to check that the raw request body wasn't tampered with. The signature is an HMAC-SHA256 over the timestamp and JSON body. The timestamp is scoped to a five-minute window, so old requests are rejected.
After this basic verification passes, we process subscription events such as created, updated and deleted. We map the webhook body to a corresponding event structure and save its status in our SQLite database. We're using upsert SQL syntax to elegantly handle new and existing subscription records.
This is a critical flow, so I added tests for valid and invalid signatures, malformed headers, expired timestamps, and database upserts.
Pending work
The current Checkout flow is not ready to be merged yet. The webhook endpoint and subscription handling are in place, but the UI flow is still missing: there is no admin action to start Checkout. It's still possible to create multiple Stripe subscriptions for the same organisation, while our database only tracks one subscription per organisation. Each subscription could lead to charges, so those edge cases need to be handled before going into production.
The return URL also lands in the same place whether the payment succeeded or failed. On top of that, the app needs a pending state and must not rely on the redirect to tell us what happened.
There are some other gaps as well: handling paid and failed invoices, duplicate or out-of-order webhook events, and a self-service subscription management flow. Those are all on the list before we can consider billing done.
I have to admit that I underestimated the effort required to implement payments. It's not just an API call. Actually, it's a full-blown state machine with a lot of edge cases and security checks at every step.
As always, here's the codebase if you'd like to follow along: https://github.com/RafalManka/Featherboard
Top comments (0)