All docs

@backendfree/core

The client every other package is built on: keys, errors, caching, idempotent writes and verifying a webhook.

Updated 22 October 2026 About 4 minutes View as Markdown

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.

sh
npm install @backendfree/core
ts
import { 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.

ts
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.

ts
new Client({ origin, key, cache: false });     // off
new Client({ origin, key, cache: myStore });   // your own
await client.send('/project', { cache: false }); // just this one

Writes

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:

ts
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.

ts
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 payload

It 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:

ts
app.post('/hooks', express.raw({ type: '*/*' }), handler);  // yes
app.use(express.json());                                    // not on this route

Two 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.

ts
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.