@formbuilder-cloud/sdk
v0.1.1
Published
Typed TypeScript SDK for the formBuilder Cloud API: a headless form backend and form submissions API, generated from its OpenAPI 3.1 contract.
Maintainers
Readme
@formbuilder-cloud/sdk
Typed TypeScript SDK for the formBuilder Cloud API: a form builder API and headless forms backend. You create forms from formBuilder definitions, publish them, collect submissions through the form submissions API, and read them back. The types are generated from the API's OpenAPI 3.1 contract (GET https://api.formbuilder.online/v1/openapi.json), so every /v1 operation is typed. The client is a thin layer on top that adds auth, idempotency, retries and typed RFC 7807 errors.
Status:
0.1.0, not published yet. The package name is@formbuilder-cloud/sdk. It needs Node 20+ and works in modern browsers (see Browser vs Node). ThefbCLI (@formbuilder-cloud/cli,cli/in this repository) is built on this SDK; an MCP server is planned as another package.
Keywords: form builder api, form backend, form submissions api, headless forms, mcp.
30-second quickstart
npm i @formbuilder-cloud/sdk
export FORMBUILDER_API_KEY=fb_mgmt_… # a write management key (dashboard → API keys)
export PROJECT_ID=… # a project id (dashboard, or `fb.projects.list()`)import { FormBuilderCloud } from '@formbuilder-cloud/sdk'
const fb = new FormBuilderCloud() // reads FORMBUILDER_API_KEY
const form = await fb.forms.create(process.env.PROJECT_ID, { name: 'Contact', definition: [{ type: 'text', subtype: 'email', name: 'email', label: 'Email', required: true }] })
await fb.forms.publish(form.id)
for await (const s of fb.submissions.list(form.id)) console.log(s.id, s.accepted_at)The form now accepts submissions at POST /v1/forms/{form.id}/submit with its collection key. Embed it with the render adapter, or submit from your server:
const { submission, replayed } = await fb.forms.submit(form.id, { values: { email: '[email protected]' } }, { collectionKey: 'fb_col_…' })Authentication
new FormBuilderCloud({ apiKey?: string, baseUrl?: string })apiKeyis a management key (fb_mgmt_…, scopereadorwrite) for every management operation, or a form's collection key (fb_col_…), which can only submit to that form. In Node it defaults toprocess.env.FORMBUILDER_API_KEY. The browser entry never readsprocess.baseUrldefaults tohttps://api.formbuilder.online. In Node,FORMBUILDER_BASE_URLoverrides the default (for example for staging). An explicit option always wins.baseUrlmust behttps:. Plainhttp:is accepted only for loopback hosts (localhost,*.localhost,127.0.0.0/8,[::1]), such as a local dev stack, so the bearer key never crosses a network in clear text. A base URL with credentials (user:pass@), a query or a fragment is refused too. Both checks run at construction, before any request, and throw aTypeErrorthat doesn't repeat the value.- The key is sent as
Authorization: Bearer <key>. It is never sent to the public operations (the published document, the OpenAPI document, the error catalog), never logged, and never included in an error message.String(fb),console.log(fb),util.inspect(fb)andJSON.stringify(fb)show only its kind, e.g.FormBuilderCloud { baseUrl: 'https://api.formbuilder.online', apiKey: fb_mgmt_[redacted] }. - In Node every request carries
User-Agent: formbuilder-cloud-sdk/<version>. That is the only thing that identifies the SDK. - No telemetry. Importing the package or constructing a client sends nothing. A request is made only when you call a method, and only to
baseUrl.
Other options: fetch (your own fetch, which receives a Request), retry (below), timeoutMs (per attempt, until the response headers arrive; default 30 000, 0 = none) and autoIdempotencyKey (default true).
Idempotency
POST /v1/forms/{f}/submit takes an Idempotency-Key (1 to 128 visible ASCII characters, required for production workspaces, kept for 24 h). It is the only operation in the contract that takes one today.
fb.forms.submit()generates a key (a random UUID) unless you pass{ idempotencyKey }, and reuses it on every retry. The result tells you the key that was sent and whether the API replayed the original answer:const { submission, replayed, idempotencyKey } = await fb.forms.submit(formId, body, { collectionKey }) // Retry the same submission later (another process, after a crash): same key, same body ⇒ the original 201. await fb.forms.submit(formId, body, { collectionKey, idempotencyKey })The same key with a different body is
409 idempotency-conflict. If the workspace owner purged the submission within the key's 24 h, a retry with its key is410 submission-purgedand stores nothing: don't resend it under a new key automatically. Generate one key per logical submission (idempotencyKey()is exported).The low-level client also gets a generated key on submit, unless you set one or pass
autoIdempotencyKey: false. An invalid key is refused before anything is sent.
Retries
A request is retried only when sending it twice cannot do a second thing:
GET,HEADandOPTIONS;- an operation the API's OpenAPI document marks
x-fbc-idempotent: true, such as publish (an unchanged draft returns the active version), activate, renames and settings updates, revoking a key or cancelling a workspace deletion; - an operation marked
x-fbc-idempotent: "idempotency-key"(submit), only when the request carries anIdempotency-Key.submit()always sends one.
The list is generated from the document when the SDK is built (RETRY_SAFE_OPERATIONS in src/generated/operations.ts). Any other request is sent once, whatever the error: creating a form, saving a draft (a repeat with the old If-Match would be 412) and most deletes (a repeat would be 404). Read the state back before repeating one yourself.
A request is retried after a network failure, a timeout, or a problem the error catalog marks retryable: rate-limited (429), internal (500), unavailable (503), upstream-timeout (504 from the proxy: the first request may have been processed, which is why only the operations above are retried), or a 502/503/504 from a proxy. It is never retried after quota-exceeded or any other 4xx. Retry-After is honoured. The defaults are { maxRetries: 2, baseDelayMs: 500, maxDelayMs: 8000, maxRetryAfterMs: 60000 }: at most 3 attempts, exponential backoff with jitter when there is no Retry-After, and no retry when Retry-After is longer than 60 s (the error carries retryAfter). Use retry: false to turn retries off. An AbortSignal you pass ({ signal }) stops the request at once.
Errors
Every API error is an RFC 7807 problem. The convenience methods throw a FormBuilderCloudError that carries its members:
| Member | Meaning |
|---|---|
| status | HTTP status |
| type | the problem type URI, e.g. https://api.formbuilder.online/errors/not-found (it resolves to its docs page) |
| slug | not-found, …: one of the generated ProblemSlugs, or a newer type's slug, or null for a non-problem body |
| title, detail, instance | RFC 7807 |
| hint | a short remedy (optional; don't parse it) |
| errors | validation-failed: one { field, code, message } per field (code is a FieldErrorCode); publish 422s: definition errors |
| extensions | any other member, e.g. active_version on the version 410s |
| requestId | X-Request-Id, for support |
| retryAfter | Retry-After in seconds, or null |
| retryable | whether the identical request may succeed later (from the error catalog) |
import { isProblem, PROBLEM_CATALOG } from '@formbuilder-cloud/sdk'
try {
await fb.forms.submit(formId, body, { collectionKey })
} catch (err) {
if (isProblem(err, 'validation-failed')) for (const e of err.errors) console.log(e)
else if (isProblem(err, 'version-retired', 'version-unknown')) reload(err.extensions.active_version)
else throw err
}isProblem(err, ...slugs) narrows err.slug. isFormBuilderCloudError and isConnectionError work across the ESM and CJS builds. A request that never got an answer throws FormBuilderCloudConnectionError (kind: 'network' | 'timeout', attempts). PROBLEM_SLUGS, PROBLEM_CATALOG and FIELD_ERROR_CODES are generated from GET /v1/errors.json. Every type is documented at its type URI.
Convenience methods
| Method | Operation |
|---|---|
| fb.forms.create(projectId, { name, definition? }) | POST /v1/projects/{p}/forms |
| fb.forms.get(formId), fb.forms.list(projectId) | GET /v1/forms/{f}, GET /v1/projects/{p}/forms |
| fb.forms.getDraft(formId), fb.forms.updateDraft(formId, definition, { ifMatch? }) | GET/PUT /v1/forms/{f}/draft (publish_check says what publish would answer) |
| fb.forms.publish(formId) | POST /v1/forms/{f}:publish |
| fb.forms.getPublished(formId) | GET /v1/forms/{f}/published (public, sent without the key) |
| fb.forms.submit(formId, { values, meta? }, { collectionKey?, idempotencyKey? }) | POST /v1/forms/{f}/submit |
| fb.submissions.list(formId, filters?), fb.submissions.get(id) | GET /v1/forms/{f}/submissions, GET /v1/submissions/{id} |
| fb.projects.list(workspaceId?), fb.projects.create({ name }, { workspaceId? }) | a management key implies its own workspace (GET /v1/me) |
| fb.me() | GET /v1/me |
Lists return a Paginator. for await (const item of list) walks every page through next_cursor, list.pages() yields whole pages, list.first() makes one request, and list.toArray(limit?) collects the items. Nothing is requested until you iterate.
Everything else: the typed low-level client
fb.api is an openapi-fetch client over the generated paths. Paths, parameters, bodies and responses are all typed, and it shares the auth, Idempotency-Key and retry behaviour:
const result = await fb.api.GET('/v1/workspaces/{ws}/usage', { params: { path: { ws } } })
const usage = fb.unwrap(result) // the data, or throws FormBuilderCloudErrorThe generated paths, components and operations types are exported too. paginate(fetchPage) turns any list operation into a Paginator.
Browser vs Node
| | Node 20+ (import/require) | Browser (browser condition, or @formbuilder-cloud/sdk/browser) |
|---|---|---|
| FORMBUILDER_API_KEY / FORMBUILDER_BASE_URL | read | never (no process) |
| User-Agent | formbuilder-cloud-sdk/<version> | not set (browsers forbid it) |
| Management keys | yes | refused: anything in a page is public |
| What works | every operation | the published document and submit with the form's collection key (management routes don't answer other origins, CORS) |
The browser entry imports no Node built-ins. Both entries ship as ESM and CommonJS with their own type declarations. To render and submit a form in a page, the render adapter (@formbuilder-cloud/adapter, not published yet) is usually the better fit.
Versioning
The SDK follows semver. The API itself is versioned in its path (/v1): additive changes ship in /v1 and clients ignore members, enum values and problem types they don't know, while breaking changes ship only in /v2. Deprecations are announced with Deprecation/Sunset headers and in the API changelog. Regenerating the SDK from a newer contract is a minor release.
Licence
Proprietary (UNLICENSED): Copyright (c) 2026 Kevin Chappell. All rights reserved. You may use it only with the formBuilder Cloud service and under that service's terms; see LICENSE. No open-source licence is granted.
