humanblnk is a Go library for keeping a wallet's or bank's books in Blnk Core. You work in bookkeeping terms (customers, accounts, transactions, holds, reversals) and exact money, and humanblnk makes Blnk follow the rules a money ledger needs, which Blnk alone leaves to you.
It's for developers building fintech products (wallets, neobanks, marketplaces, payouts, remittance) who want Blnk's double-entry ledger without re-learning its sharp edges in production.
go get github.com/otyang/humanblnk # Go 1.24+Blnk is a solid double-entry engine, but it's deliberately low-level. These are the gaps humanblnk closes:
| With Blnk alone | With humanblnk |
|---|---|
| Amounts are integers you scale yourself; one write at the wrong precision mixes units silently | Amounts are money.Money, exact decimals, sent in the currency's smallest unit at its own precision; entries read back are checked against it |
| A USD 10.00 transaction applied to an NGN (Nigerian naira) account lands as NGN 10.00 | A currency mismatch is refused with money.ErrCurrencyMismatch |
| A retried request hits a "reference already used" error that doesn't say whether your write applied | You build each reference from your own ids with humanblnk.Reference. A retried Record gets ErrDuplicate (it applied) or ErrReferenceUsed (something else holds that reference); a retried hold, or a reversal retried with its Ref, returns the one already made. Any reference works, / included |
Writes are queued by default: insufficient funds turns up later as a REJECTED status |
Writes are synchronous: you get ErrInsufficientFunds from the call itself |
| A split (one transaction paying several accounts, such as an amount plus a fee) that fails partway has already moved its earlier parts; Blnk refunds them later, from its worker | A split is held and captured at once, so one that fails partway moves nothing, not even for a moment. Its entry reads as StatusRejected and its reference can't be reused |
| Overdraft is a flag on each write: any write that sets it can take its source below zero, so a split from a customer and a system account can't let only one of them | Only system accounts (such as @World, money outside your books, or a provider's clearing account) go below zero, each on its own terms, even in one split |
| Customer money and your company's own can share a ledger | Customer accounts need an owner and live in the customer ledgers you name; your own accounts can't be opened there |
| Blnk's refund undoes a transaction once, in full; partial refunds are yours to track | Reversals, full or partial, are numbered per entry and never undo more than it moved, even when they run concurrently |
-
Not a payment provider integration. You talk to Stripe, Paystack or your bank yourself; humanblnk moves the money in your books to match.
-
Not a server. It's a library inside your Go service, with no HTTP API of its own.
-
Not a store for personal data. Customers are Blnk identities holding only your ids (such as
customer_id). Names, documents and KYC stay in your own systems. -
Not a replacement for Blnk. You run Blnk Core 0.15.6 with its worker, Postgres and Redis. To start all four locally:
docker compose -f deploy/docker-compose.yml up -d # Blnk on http://localhost:5001
import (
"github.com/otyang/humanblnk"
"github.com/otyang/humanblnk/blnk"
"github.com/otyang/humanblnk/money"
"github.com/otyang/humanblnk/money/currency"
)
usd := currency.MustGet(currency.USD)
hb := humanblnk.New(blnk.New("http://localhost:5001"), // add blnk.WithAPIKey(key) if Blnk runs in secure mode
humanblnk.WithAccountLedger(walletsLedgerID), // made once by a setup script (tutorial chapter 1)
)
// A customer and their wallet. Identities.Create is not safe to repeat:
// in a sign-up handler, try hb.Identities.FindByCustomer first.
owner, err := hb.Identities.Create(ctx, humanblnk.Meta{"customer_id": "cus_123"})
wallet, err := hb.Accounts.OpenForCustomer(ctx, humanblnk.CustomerAccount{Owner: owner, Currency: usd}) // safe to repeat
// A card top-up. Stripe owes you the money until it settles, so it comes
// from Stripe's clearing account.
amount := money.MustParse("50.00", usd)
_, err = hb.Transactions.Record(ctx, humanblnk.Transaction{
Ref: humanblnk.Reference("deposit", "stripe", paymentIntentID), // the same on every retry
From: humanblnk.Single(humanblnk.ClearingAccount("stripe"), amount),
To: humanblnk.Single(humanblnk.AccountRef(wallet.ID), amount),
Description: "Card top-up",
})
if errors.Is(err, humanblnk.ErrDuplicate) {
err = nil // a retried webhook: already recorded
}
// A payout: reserve the money now, then settle or release it when the bank answers.
hold, err := hb.Holds.Place(ctx, humanblnk.Transaction{
Ref: humanblnk.Reference("payout", payoutID),
From: humanblnk.Single(humanblnk.AccountRef(wallet.ID), money.MustParse("20.00", usd)),
To: humanblnk.Single(humanblnk.World, money.MustParse("20.00", usd)),
Description: "Payout to bank",
}, time.Now().Add(72*time.Hour))
acct, err := hb.Accounts.Get(ctx, wallet.ID)
fmt.Println(acct.Available) // "30.00": what the customer can spend now
if bankAccepted {
_, err = hb.Holds.Capture(ctx, hold, money.Money{}) // all of it
} else {
err = hb.Holds.Cancel(ctx, hold)
}humanblnktest runs your code against an in-memory Blnk, so your tests need no
Blnk, Docker or network:
func TestPayout(t *testing.T) {
hb := humanblnktest.New(t) // a fresh in-memory Blnk, closed when the test ends
ada := hb.Customer(t, "USD")
hb.Fund(t, ada, "100.00")
svc := payouts.New(hb.Holds) // your code, given the real humanblnk
// ... exercise svc ...
if got := hb.Available(t, ada); got != "70.00" {
t.Fatalf("Ada can spend %s, want 70.00", got)
}
}It copies the Blnk Core 0.15.6 behaviour humanblnk relies on: reused references refused, held splits released at once when a later posting fails, holds settled by transactions of their own, signed webhooks. humanblnk's contract tests run against it and a real Blnk alike, so a difference they cover fails CI. It isn't Blnk: nothing is queued, and a few features humanblnk doesn't use are left out (listed in tutorial chapter 13), so keep a few tests against a real Blnk too.
- Tutorial: fourteen chapters. Eleven walk through a real task (sign-up, deposits, transfers with fees, payouts, refunds, currency exchange, webhooks, reconciliation) with a program you can run against your own Blnk; the others cover errors and retries, testing your service without Blnk, and a cheat sheet.
- Reference: every service and method, the rules humanblnk enforces, what's safe to retry, its errors and its limits. Also in doc.go.
- money: exact amounts and currencies, usable on their own.
| Package | What it is |
|---|---|
humanblnk |
Your books: Client, its services, and the vocabulary |
money |
An exact amount in one currency: parse, add, round, convert to the smallest unit |
humanblnktest |
humanblnk on an in-memory Blnk, for your tests |
blnk |
A Blnk Core HTTP client. You use it to build the client (blnk.New, blnk.WithAPIKey); only humanblnk calls Blnk through it |
Your own features (airtime top-ups, payouts, transfers) go in their own
packages and import humanblnk; humanblnk never imports them.
See CONTRIBUTING.md for the layout, the tests and how the tutorial is built.