Inside a Worker, use bindings. Outside it, use the one REST client. Never both, and never a hand-rolled request to the Cloudflare API.
The rule is short because the boundary is sharp: a Worker has bindings and nothing else does.
Inside a Worker: bindings
Every binding a capability declares is in the bindings reference; the client for the outside path ships in one package; and what it authenticates with is a Cloudflare API token.
env.DB, env.SESSIONS, the email binding. They are faster, cheaper, need no token, and keep the data in your account by construction.
A binding is a capability handed to the script by the runtime. There is no credential to leak because there is no credential — the platform decided this script may reach this database, and that decision lives in your wrangler.jsonc rather than in a token somebody could copy.
You will rarely think about this. c.var.db and c.var.kv are typed registries over bindings, and the binding is the thing underneath.
Outside a Worker: one client
The CLI. CI. Provisioning. Anything running on your machine rather than in a script.
There are no bindings there, so remote migration, feature-environment provisioning, secret-store management and widget management all reach Cloudflare over the REST API — and @pithy-sh/cloudflare is the only place in the kit that does.
It wraps the official SDK, falls back to raw requests for the few endpoints the SDK does not type yet, and is configured with a scoped token and the account it targets.
It is REST-only, and it does not accept bindings — that is what bindings are for.
Never hand-roll a request to the Cloudflare API
This is the part of the rule with teeth, and the reason is not tidiness.
One client means one place where the token is read, one place where an error is classified into a cloudflare/* code, one place where retries and rate-limit handling live, and one place to look when a call starts failing.
Hand-rolled requests scattered across a codebase means each of those exists as many times as somebody needed it, and they will not agree.
The nice consequence: one query builder, two transports
The D1 manager implements the same interface a binding does.
So it backs the same Kysely dialect, which means the query builder a Worker runs against a binding is the query builder the CLI runs against D1 over REST. That is exactly how pithy migrate promotes a schema: same registry, same ordering, same per-database runs, only the driver differs.
Local runs go through Miniflare against the same state wrangler dev reads; a deployed environment executes over REST against the database that stanza names. You pass no ids either way.
If migrations had been written twice — once for local, once for remote — they would have diverged, and the divergence would have been discovered in production.
Two things called “control plane”
Worth naming here, because the word is overloaded and it decides which of the two paths you are on.
Cloudflare’s control plane is outbound. The REST API this page is about. It uses your token, runs from the CLI or CI, never appears in a request handler, and its failures carry cloudflare/* codes.
The Pithy control-plane seam is inbound. How a management client calls your Worker. It uses a key you registered, runs inside a request handler, never talks to Cloudflare at all, and its failures carry controlplane/* codes.
They share a name and nothing else. The control-plane seam is the inbound one.
Where the credentials come from
The REST path needs a token. It is account-scoped rather than per project, and it lives in the config directory outside every checkout — never in your repository.
CI has no config directory at all and supplies the pair as environment variables. pithy token mint ci-system produces a least-privilege credential scoped to exactly what your composed capabilities need CI to do, and what the resources your Workers bind need to be created.