A complete Go client for the YNAB API v1: all 44 operations behind a domain-first surface, exact money arithmetic, first-class delta sync, and zero runtime dependencies. Not an OAuth flow, not a persistence layer, not a budgeting engine — a wire-faithful client.
YNAB's API says budget; this library says plan, following YNAB's own product vocabulary.
client.Plan(id)maps to/budgets/{budget_id}on the wire — the mapping is one-to-one.
go get pkg.venceslau.dev/ynabRequires Go 1.25+ (the minimum in go.mod). The public API follows SemVer: the v1 surface is frozen, and every change is recorded in the CHANGELOG.
Create a Personal Access Token
at app.ynab.com → Settings → Developer Settings and export it as YNAB_TOKEN.
A complete program — no IDs to look up, PlanIDLastUsed resolves server-side:
package main
import (
"context"
"fmt"
"log/slog"
"os"
"pkg.venceslau.dev/ynab"
)
func main() {
client := ynab.New(os.Getenv("YNAB_TOKEN"))
plan := client.Plan(ynab.PlanIDLastUsed)
accounts, _, err := plan.Accounts.List(context.Background())
if err != nil {
slog.Error("listing accounts", "err", err)
os.Exit(1)
}
for _, a := range accounts {
fmt.Printf("%s %s\n", a.Name, a.BalanceFormatted)
}
// Checking $1,282.23 (your accounts will differ)
}Tip
Two flavors of examples: the examples/ directory holds runnable
programs that hit the real API (quickstart, delta-sync, splits, mock testing),
and the package examples
on pkg.go.dev cover every service offline — 26 runnable, httptest-backed.
Everything plan-scoped hangs off the handle client.Plan(id) returns — built
without I/O, bound to its id for good:
plan := client.Plan(ynab.PlanIDLastUsed)
groups, knowledge, err := plan.Categories.List(ctx)
category, knowledge, err := plan.Categories.Assign(ctx, ynab.CurrentMonth(), categoryID, ynab.UnitsToMilliunits(150))
detail, knowledge, err := plan.Export(ctx) // the whole plan, one requestMoney is Milliunits (exact int64 thousandths — never floats), months are a
dedicated Month type that accepts the server-resolved ynab.CurrentMonth(),
and write payloads use the Optional tri-state: omitted, SetNull (clear), or
Set — zero values included, so Approved: ynab.Set(false) reaches the wire.
YNAB's quota is about 200 requests per hour — delta sync is how a polling
integration lives inside it. Delta-capable reads return a ServerKnowledge
cursor; hand it back to receive only what changed, tombstones included:
st := &ynab.SyncState{} // JSON-persistable; save it between runs
detail, err := plan.Delta(ctx, st) // full read first, increments after
store = ynab.MergeByID(store, detail.Transactions)See the package examples for the full delta loop and the flatten-before-merge categories caveat.
API failures match a sentinel taxonomy through errors.Is, class- and
sub-code-wise at once:
_, _, err := plan.Transactions.Create(ctx, spec)
switch {
case errors.Is(err, ynab.ErrConflict): // duplicate import_id
case errors.Is(err, ynab.ErrRateLimited): // wait — a *ynab.Error carries RetryAfter
case errors.Is(err, ynab.ErrForbidden): // any 403.*, including 403.1 subscription lapsed
}Client-side pre-flight failures (a zero Month, a 501-character payee name,
split legs that do not sum) are *ArgumentError — the request is never sent.
Retries for 429/500/503 are built in, write-safe by default, and
IsRetryable exposes the same classification for custom loops.
Two dependency-free seams, both with runnable examples: point WithBaseURL
at an httptest.Server, or install a fake http.RoundTripper via
WithHTTPClient. You need nothing from this library's internals.
Correctness is enforced by machinery, not vigilance: the operation table is diffed both ways against the vendored OpenAPI spec on every CI run, write bodies are asserted byte-exact including their emitted key sets, every decode runs twice with all optional response headers stripped, every nullable field has a null-variant fixture, and a weekly job diffs the live spec for drift. Observed-vs-documented API divergences live in API_NOTES.md.
This module replaces the archived v1.x releases. It is a clean break —
budget → plan, one package, no source compatibility. The predecessor's
versions stay installable from the module proxy under their original
paths (go.bmvs.io/ynab up to v1.3.0, then
github.com/brunomvsouza/ynab.go); the archive/* tags here browse
that history. Under pkg.venceslau.dev/ynab those old versions — and
this module's own pre-rewrite v0.1.0 — are retracted, so go get
resolves to the current release; see the CHANGELOG.
Issues are acknowledged within 14 days and resolved — an accept/decline decision, and the fix when accepted — within 30 days of acknowledgement. See CONTRIBUTING.md for the gate workflow before opening a PR.
BSD 2-Clause. Not affiliated with YNAB — use at your own risk.