billingthing Get in touch

One account · one integration · one question

Stop rebuilding the payment dance.

Taking money in a project means products in a dashboard, a webhook endpoint, a signing secret, a handler for every event and a table of who paid. billingthing does all of that once, and a project asks one question: does this user have access?

Act I

The payment dance.

A shop wants a monthly Pro plan. Here's the integration, before the next project that takes money writes it all again.

01 · Products

Products and prices, in a dashboard.

Name, price, period, save. The price gets an id you'll paste into the project's code, and it's different in test mode.

payments-provider.example/products/new
New product Payment provider
Name Shop Pro
Price 99.00 DKK
Billing Monthly
Save product
Created price_1Q8ZxK2eZvKYlo… (test mode)

02 · Webhooks

Then a webhook endpoint.

Pick the events you think you need, point them at the project, and copy a signing secret into its environment.

payments-provider.example/webhooks/new
Add endpoint Webhooks
URL https://shop.example/webhooks/payments
Events checkout.completed, invoice.paid, +4
Add endpoint
Signing secret: whsec_7b1c… (shown once)

03 · The handler

Then the handler.

Verify the signature, ignore duplicates, handle events arriving out of order, and decide what a failed payment means. In every project.

webhook.ts lines 1–23
export async function webhook(req: Request) {
const body = await req.text();
const sig = req.headers.get("payments-signature")!;
const event = payments.webhooks.verify(body, sig, process.env.WEBHOOK_SECRET!);
// events can arrive twice, and out of order
if (await db.events.exists(event.id)) return new Response("ok");
switch (event.type) {
case "checkout.completed":
case "invoice.paid":
await db.subscriptions.upsert(event.customer, { active: true, until: event.period_end });
break;
case "invoice.payment_failed":
// grace period? email them? lock them out? decide again per project.
break;
case "subscription.deleted":
await db.subscriptions.update(event.customer, { active: false });
break;
}
await db.events.insert(event.id);
return new Response("ok");
}

04 · The access check

Then "has this user paid?"

A subscriptions table, a migration, and a check sprinkled through the app. Each project has its own idea of what "active" means.

~/shop
$ mix ecto.gen.migration create_subscriptions
$ grep -rn "subscription.active" lib | wc -l
14

05 · When it misses

And one day it misses a webhook.

The database was busy for a second. The provider retried, then gave up. The customer paid and has no access, and you hear it from them.

~/shop
$ grep webhooks/payments log/prod.log | tail -3
POST /webhooks/payments 500 · db timeout
POST /webhooks/payments 500 · db timeout
invoice.paid for cus_R4n… never recorded · customer locked out

That was one project that takes money.

things set up by hand
2
pieces of code to keep
3
ways to lose money
1
projects that copy it
all of them

Here's the same plan, with billingthing.

Act II

The billingthing way.

One merchant account, one webhook handler, one idea of what "paid" means. A project implements as little as possible: a link to pay, and a question.

01 · Plans, declared

Plans live in one file.

Price, features and grace period per plan. A project names a plan and a feature, never a provider's price id.

plans.toml lines 1–10
# every product and price, declared once. Projects name a plan, never a price id.
[plans.shop-pro]
price = "99 DKK / month"
features = ["pro", "priority-support"]
grace = "7 days" # a failed payment doesn't lock anyone out at once
[plans.booking-seat]
price = "49 DKK / month per seat"
features = ["booking"]

02 · A link to pay

Checkout is one call.

The project says who and which plan, and gets back a link to send the user to. The provider's checkout does the rest.

~/shop
curl -X POST https://billingthing.com/v1/checkout \
-H "Authorization: Bearer $BILLINGTHING_KEY" \
-d '{ "user": "u_1842", "plan": "shop-pro" }'
→ https://pay.billingthing.com/c/01J8Z9…

03 · The question

Then one question, wherever it matters.

Does this user have this feature? billingthing answers from its own record of every payment, so the project keeps no table and handles no webhook.

access.ts lines 1–4
const res = await fetch(`https://billingthing.com/v1/access?user=${user.id}&feature=pro`, {
headers: { Authorization: `Bearer ${process.env.BILLINGTHING_KEY}` },
});
const { access } = await res.json(); // true or false. that's the whole integration.

04 · The webhooks, once

The webhooks land here, once.

Signature checks, duplicates, ordering and retries are billingthing's problem, handled in one place and fixed for every project at once.

~/billingthing
$ billingthing events --tail
invoice.paid cus_R4n… → u_1842 shop-pro recorded
invoice.paid cus_R4n… duplicate, ignored
payment_failed cus_T9x… → u_0317 booking grace until 4 Oct

Act III

The hard questions.

Taking on everything means answering the questions each project used to fudge. These are the ones billingthing has to get right, and they're open.

  1. 1

    Whose user is it?

    Projects pass their own user ids, and billingthing maps them to the provider's customers. One person using two projects shouldn't mean two unrelated accounts, or should it?

  2. 2

    Ask, or be told?

    Asking is simplest for a project, but a cancelled plan should end access promptly. Likely both: ask with a short cache, plus an optional signed push when access changes.

  3. 3

    What if it's down?

    A paying customer must never be locked out because billingthing blinked. Projects keep the last answer with an expiry, and failures lean toward letting people in.

Taking money, both ways
Measure Per project billingthing
Products and prices ids pasted into code plans in one file
Webhooks a handler per project none in any project
Who has access a table per project one question
A missed event a customer locked out handled once, retried

Got a project that should take money?

Tell me what you'd charge for. billingthing is how every project here will ask whether someone has paid.

Get in touch