The model: no auto-charge, one invoice per cycle
Card subscriptions work because the card network lets a merchant pull from a stored credential on a timer. Crypto has no equivalent and is not going to grow one: a payment is a transaction the customer signs and broadcasts themselves, and a wallet that let a third party silently drain it on a schedule would be a security hole, not a feature. So stop looking for a 'save card and auto-charge' primitive — it does not exist here, and any system that claims to offer it for self-custody wallets is misdescribing what it does.
Model the subscription as a series of one-off payments instead of a standing charge. Each billing period is its own invoice, anchored to the plan price in fiat. At renewal your scheduler creates the next invoice, sends the customer its checkout link, and waits for payment. Entitlement — the customer's access to the plan — is granted when the invoice for the current period reaches paid, and it lapses when no paid invoice covers the period. That single rule is the whole engine: access follows paid invoices, one per cycle.
The trade-off is honest and worth stating to yourself up front. You lose the silent automatic renewal of card billing, so reminders and a grace policy do the work that automatic retries do on a card. In exchange you hold no vault of payment credentials, carry no card-on-file compliance scope, and never argue about whether a charge was authorized — every payment is a transaction the customer chose to send. The rest of this guide is the mechanics of running that loop reliably.
- No standing authority to charge a wallet — model recurring as one invoice per period.
- Anchor each invoice to the plan price in fiat so the booked amount never drifts.
- Grant access off the verified paid invoice; lapse access when no paid invoice covers the period.
- Reminders and a grace window replace the automatic retry of card dunning.
What to store per subscription
Before any API call, fix the data model, because the webhook in Step 4 needs to map back to a subscription and period without a fragile lookup. Keep your own subscription record as the source of truth for entitlement; halfin owns the payment, your database owns who-has-access-until-when.
The minimum you need is a subscription identifier, the plan and its fiat price, the current period (a year-month or an explicit start/end), the period the subscription is paid through, and — once you create each cycle's invoice — the halfin invoice id for that period. Derive a stable cycle key from the subscription id and the period, for example sub_4821:2026-06. That same key becomes both the idempotency key on the create call and the tag you attach to the invoice, so the paid webhook tells you exactly which renewal to extend.
| Field | Holds | Used by |
|---|---|---|
| subscription_id | Your stable subscription identifier | Cycle key, webhook lookup |
| plan / fiat price | e.g. 49.00 USD per month | Step 3 create-invoice amount |
| current_period | e.g. 2026-06 or an explicit start/end | Cycle key, renewal scheduling |
| paid_through | Latest period an invoice.paid covers | Entitlement check, dunning state |
| cycle key | subscription_id + period, e.g. sub_4821:2026-06 | idempotency_key and invoice tag |
| invoice_id (per period) | halfin invoice for the current cycle | Reconcile webhook to the period |
Step 1 — Schedule the renewal
Renewal is a job you run, not a charge that fires. Run a scheduler — a daily cron, a worker tick, whatever you already have — that looks a few days ahead of each subscription's period end and, for any subscription about to roll over, kicks off the create-invoice flow in Step 3. Lead time matters because the customer has to act: a reminder three to five days before the period ends gives them room to pay before access would lapse.
Make the scheduler idempotent at the job level, not just at the API level. A daily job will see the same about-to-renew subscription on consecutive days until it is paid; that is fine, because the cycle key keeps every create call for the same period pointing at the same invoice (Step 3). What the scheduler must not do is advance the subscription's period or paid_through itself — only a verified paid webhook does that. The scheduler's single responsibility is: this subscription needs an invoice for this period; ensure one exists and the customer has its link.
Step 2 — Confirm halfin can run the whole loop
Recurring billing is not a separate halfin product; it is the invoicing primitive plus webhooks, arranged on a schedule. Before you build, confirm each piece you depend on exists and behaves the way the loop assumes.
The fiat anchor and rate lock keep the renewal amount stable in dollars while the customer pays in a volatile or stable asset. The per-chain confirmation and reorg-aware crediting mean a paid event reflects a credit halfin will stand behind. The signed webhook is the independent signal that survives a closed browser tab. And underpaid / overpaid handling gives the awkward middle cases a defined outcome instead of a stranded payment. If your flow relies on any behavior not in this table, check it against docs.thehalfin.com rather than assuming it.
| Primitive you rely on | What the loop assumes | halfin behavior |
|---|---|---|
| Fiat-anchored invoice | Renewal priced in dollars, paid in crypto | amount_fiat + fiat_currency; payable asset amount computed from the live rate |
| Rate lock + expiry | Amount fixed while the customer pays; stale quotes do not settle | Quote locks at activation; the invoice expires rather than re-pricing |
| Confirmation + reorg handling | paid means actually settled, not first-seen | Per-chain confirmation thresholds; reorg-aware crediting |
| Signed webhook | A reliable paid signal independent of the browser | HMAC-signed invoice.paid event POSTed to your endpoint |
| Under / overpayment | Short and surplus payments have a defined outcome | invoice.underpaid / invoice.overpaid record the gap against the quote |
Step 3 — Create the cycle invoice (fiat-anchored, idempotent)
For the period you are renewing, create an invoice anchored to the plan's fiat price. Send the amount as a string — monetary values are strings end to end, never floats — and the fiat_currency as your billing currency. halfin computes the payable asset amount and locks that rate when the invoice activates, so the customer pays the dollar value of the plan even if the asset's price moves while the checkout page is open.
Two details make this safe to call from a scheduler that may retry. First, set the idempotency_key to the cycle key (subscription id plus period): a retried create for the same period returns the existing invoice instead of issuing the customer a second bill for the same month. Second, tag the invoice with your own subscription and period metadata so the paid webhook in Step 4 maps straight back to the renewal without a side table you have to keep in sync. The exact field names and the full response schema live in the API reference at docs.thehalfin.com; the response carries the invoice id and a hosted checkout URL on checkout.thehalfin.com.
Store the returned invoice id against the period, drop the checkout URL into the renewal email and the in-app billing page, and stop there. Do not grant or extend access yet — Step 4 is what tells you the customer actually paid.
curl -X POST https://api.thehalfin.com/api/v1/invoices \
-H "X-API-Key: $HALFIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount_fiat": "49.00",
"fiat_currency": "USD",
"description": "Team plan — June 2026",
"idempotency_key": "sub_4821:2026-06"
}'
# halfin locks the rate at activation and pins the payable asset amount.
# The response carries the invoice id and a hosted checkout URL on
# checkout.thehalfin.com. Store the id against the period, email the URL,
# and wait for the signed invoice.paid webhook before extending access.
# See docs.thehalfin.com for the full request and response schema.Step 4 — Grant access from the verified webhook, not the redirect
This is the step that decides whether the integration is correct, so treat it strictly. When the invoice resolves, the customer is returned to your success URL — but that redirect can be missed. A customer pays on their phone, the wallet app comes to the foreground, the browser tab is gone, and your success page never loads. If the redirect is your only signal that they paid, you will silently fail to renew a subscription that was actually paid for.
The reliable signal is the webhook. halfin POSTs your server an HMAC-signed invoice.paid event when the invoice settles past the chain's confirmation threshold, and that event arrives independently of whatever the customer's browser did. Verify it before you trust it: recompute the HMAC over the exact raw request bytes using your endpoint's signing secret, compare it to the signature header with a constant-time comparison, and only then parse the body and act. An unsigned or wrongly signed request is not a halfin event and must never extend a subscription — the endpoint URL is public, so the signature is the only thing separating a real event from a forged 'paid' callback.
Once verified, read your subscription and period from the metadata you attached in Step 3, advance paid_through to that period, and record that you did. Keep the handler idempotent — delivery is at least once, so a redelivered paid event carries the same stable event id and must not push the renewal date out twice or send a second receipt. Verify, map to the period, apply the extension exactly once, return 2xx fast, and defer the email and ledger write to a queue. The Node handler below is the spine of the flow.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
const SIGNING_SECRET = process.env.HALFIN_WEBHOOK_SECRET!;
// HMAC must be computed over the EXACT raw bytes received — not over a
// re-serialized JSON object — so capture the raw body for this route.
app.post(
"/webhooks/halfin",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.header("x-halfin-signature") ?? "";
const expected = createHmac("sha256", SIGNING_SECRET)
.update(req.body) // req.body is a Buffer here
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).send("invalid signature");
}
// Only now is it safe to parse and act on the event.
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "invoice.paid") {
// The cycle key you tagged in Step 3 maps the invoice to a period.
// extendSubscription is idempotent on the stable event id, so a
// redelivered paid event renews the period exactly once.
await extendSubscription(event.id, event.data);
}
// Acknowledge fast; defer email and ledger writes to a queue.
return res.status(200).send("ok");
},
);Step 5 — Handle the awkward states: underpaid, overpaid, expired
A subscription that only models paid and unpaid will eventually strand money. Crypto payers cover the network fee out of the same amount, fat-finger a digit, or let a rate lock lapse, and each of those has a defined event you should react to rather than ignore. Decide each policy once and let the recorded state drive it.
An invoice.underpaid means a real payment arrived but fell short of the quote; do not extend the subscription on it — surface the shortfall so your back office can ask for the remainder or void the cycle. An invoice.overpaid means the customer settled with a surplus; extend the period and credit the excess toward the next cycle rather than scrambling a refund. An invoice.expired means the rate lock lapsed before payment; if the customer still wants to renew, issue a fresh invoice with a current quote — which is exactly what the next scheduler tick does. Each of these arrives as its own signed webhook, so the same verify-then-act discipline from Step 4 applies.
| Webhook event | What it means | What your billing logic does |
|---|---|---|
| invoice.confirming | Invoice is live with a locked quote and an expiry | Show the amount and address; do not extend access yet |
| invoice.paid | Settled past the chain's confirmation threshold | Advance paid_through to this period; extend access |
| invoice.underpaid | A real payment below the quote arrived | Hold the period; request the remainder or void — do not extend |
| invoice.overpaid | Settled with a surplus over the quote | Extend, and credit the excess toward the next cycle |
| invoice.expired | The rate lock lapsed before payment | Re-issue at a fresh quote on the next scheduler tick |
Step 6 — Dunning, grace, and choosing the rail
Because the customer pays each cycle by hand, lapses are normal and dunning is part of the design, not a failure case. When a period rolls over unpaid, keep the cycle invoice live until its rate lock expires, send reminders by email and in-app, and re-issue a fresh invoice when the lock lapses so the customer always has a current quote to pay. Decide a grace policy up front, the same way card-billing decides how many failed charges precede a downgrade: a common shape is to keep access live for a short grace window after the period ends, suspend until the cycle invoice is paid, then expire entitlement after a longer window. The paid webhook from Step 4 is what lifts a suspension — when the customer finally pays the outstanding cycle, the signed event restores access.
Steer customers toward stablecoins for a recurring bill. A renewal priced in dollars is far less stressful to pay in a dollar-stable asset than in a volatile one, so USDC on Base or Solana for low fees, or USDT on Tron where that is what the customer already holds, are natural defaults. Enable the rails you are comfortable settling on and let the checkout present the choice; you do not have to predict which one each customer reaches for. And where you can, nudge subscribers toward annual billing — one invoice a year, one payment, one webhook turns twelve manual renewals into one and sidesteps most of the dunning surface above.
- Keep the unpaid cycle invoice live until its lock expires, then re-issue a fresh quote.
- Define a grace window, then a suspension, then expiry — the paid webhook lifts the suspension.
- Default to stablecoins (USDC on Base/Solana, USDT on Tron) for predictable renewal amounts.
- Nudge toward annual billing where you can — one invoice, one payment, one webhook per year.