@backendfree/core
v0.1.2
Published
The client every other @backendfree package is built on: auth, errors, caching and the project manifest.
Downloads
523
Maintainers
Readme
@backendfree/core
The client every other @backendfree package is built on. On its own it
gives you the project manifest and a typed way to call anything else /v1
offers.
fetch, AbortSignal and crypto.randomUUID and nothing else, so the same
import works in a build script, a Next.js route, a Worker at the edge and a
browser bundle. No dependencies.
npm install @backendfree/coreimport { Client } from '@backendfree/core';
const client = new Client({
origin: 'https://backendfree.com',
key: 'pk_live_...',
});
const project = await client.project();
// { id, name, timezone, currency, modules, cache_version, livemode, logo, contact }
if (project.modules.includes('bookings')) {
// ...
}Keys
A key identifies the project, which is why no path here names one and why there is no project option to get wrong.
| | pk_live_... | sk_live_... |
|---|---|---|
| read services, staff, availability, published content | yes | yes |
| create a booking | yes | yes |
| read, cancel, move or pay for one booking | with its token | yes |
| list bookings, read payments, start a checkout | no | yes |
| safe in a browser bundle | yes | never |
Neither key refunds a payment or changes the catalogue, the hours or the content: those happen in the dashboard, where the account owner signs in. A leaked secret key can read the books, and it cannot empty them.
Constructing a client with an sk_ key throws when window exists. It is the
one mistake with this product a deploy cannot take back: a secret key in a
bundle is every customer and every payment handed to whoever opens the network
tab.
Errors
Every /v1 failure arrives in one envelope, and this package keeps its
vocabulary rather than inventing a second one.
import { BackendFreeError, isNotFound, isRateLimited, isRetryable } from '@backendfree/core';
try {
await client.get('/services/' + id);
} catch (error) {
if (isNotFound(error)) { /* not there, not yours, or not switched on */ }
if (isRateLimited(error)) { /* error.retryAfter seconds */ }
if (isRetryable(error)) { /* send it again with the same idempotency key */ }
if (error instanceof BackendFreeError) {
error.type; // 'invalid_request' | 'rate_limit' | ...
error.code; // 'slot_unavailable', and what to branch on
error.param; // 'starts_at', or null
}
}message is written for a person and may be reworded at any time. Branching on
it will break; branching on code will not.
Caching
Reads of the catalogue, free times, content and the project carry an ETag, so a repeat read costs a 304 with no body until something changes. It is on by default, with the last 100 responses kept. The diary and one booking are read fresh every time, since a stale one sends somebody to an appointment that moved.
new Client({ origin, key, cache: false }); // off
new Client({ origin, key, cache: myStore }); // your own
await client.send('/project', { cache: false }); // just this oneWrites
Every method that is not GET carries an Idempotency-Key, generated for you
when you pass none, since many writes are refused without one. A generated key
is fresh on every call, so calling again is a second write: a booking sent twice
that way can be two appointments.
To retry a write after a timeout or a 5xx (isRetryable()), pass your own key,
the same both times, and the second answer is the first one's. Make it one
nobody could guess, such as a UUID your order already carries rather than its
number:
await client.post('/bookings', booking, { idempotencyKey: order.uuid });Webhooks
The other direction: the platform posts to a URL you gave it, and this says
whether to believe it. Your endpoint is public, so anybody can post to it, and
payment.succeeded is a message that marks an order paid.
import { verifyWebhook } from '@backendfree/core';
const event = await verifyWebhook({
body: await request.text(), // the raw body, before anything parses it
headers: request.headers,
secret: process.env.BACKENDFREE_WEBHOOK_SECRET!,
});
event.name; // 'payment.succeeded', from the signed body
event.delivery; // this delivery's id, from the signed body
event.data; // the payloadIt verifies whatever a project emits, not just payments, so one handler covers
every module. It throws a WebhookError when the signature is missing, wrong,
or older than five minutes; answer 400 and log it.
The name and the id are read from the body, never from the
X-BackendFree-Event and X-BackendFree-Delivery headers. The signature covers
the timestamp and the body and nothing else, so on a captured delivery those two
headers could be rewritten and the signature would still pass.
The body must be the bytes that arrived. The signature covers them exactly, so anything that parses first breaks every delivery:
app.post('/hooks', express.raw({ type: '*/*' }), handler); // yes
app.use(express.json()); // not on this routeTwo more things follow from how delivery works. Answer quickly and do the work
afterwards, because a timeout, a refused connection and a 408, 429 or 5xx are
all retried. And make the handler idempotent on event.delivery, because
delivery is at least once and never exactly once.
Anything else
request(), get(), post() and send() reach any endpoint with the same
auth, timeout, errors, caching and idempotency, so the SDK never blocks you on
an endpoint it has not learned yet.
const slots = await client.get('/availability', {
query: { service: id, from: '2026-06-10', to: '2026-06-17' },
});send() returns the status, the ETag and whether the API replayed a stored
answer, when you need more than the body.
