@draggable/formeo-sdk
v0.2.0
Published
Typed JavaScript/TypeScript client for the Formeo REST API: forms, submissions and webhooks with an organization API key, plus webhook signature verification
Maintainers
Readme
@draggable/formeo-sdk
A typed JavaScript/TypeScript client for the Formeo REST API: forms,
submissions, webhooks and document generation with an organization API key, webhook signature verification, typed errors and rate-limit-aware retries. It uses only
fetch and has no runtime dependencies, so it runs on Node.js 22+, browsers, Deno, Bun and edge
runtimes.
npm install @draggable/formeo-sdkPreview. The SDK is a 0.x release: a new minor version (0.2, 0.3, ...) may change its API. npm's default range (
^0.1.0) doesn't pick those up, so upgrading is your choice.
import { Formeo } from '@draggable/formeo-sdk'
// formeo.io (https://api.formeo.io is the default); self-hosted, add baseUrl: 'https://formeo.example.com'
const formeo = new Formeo({ apiKey: process.env.FORMEO_API_KEY })
const form = await formeo.forms.create({ name: 'Signup', schema: {} })
await formeo.forms.publish(form.id)
const { items } = await formeo.submissions.list({ formId: form.id })
// In a webhook receiver: verify, then parse (throws WebhookVerificationError)
import { constructWebhookEvent } from '@draggable/formeo-sdk/webhooks'
const event = await constructWebhookEvent({
payload: await request.text(),
headers: request.headers,
secret: process.env.FORMEO_WEBHOOK_SECRET,
})baseUrl defaults to https://api.formeo.io (HOSTED_API_ORIGIN). The API is at
<baseUrl>/api; a URL already ending in /api is used as is, and https://formeo.io (the
website, no API) means the hosted API (apiBaseUrl() in src/base-url.ts, shared with the MCP
server). apiUrl overrides the derived API base URL. The SDK reads no environment variables.
Generate a document from a template and download it once signed:
const templateId = '0199f6a4-1a2b-7c3d-8e4f-5a6b7c8d9e0f' // a template of the organization
const document = await formeo.documents.generate({
templateId,
values: { client: 'Ada Lovelace' },
recipients: [{ name: 'Ada Lovelace', email: '[email protected]', signerRoleKey: 'client' }],
})
// Each recipient's private link (also emailed when the server has email on), shown once
const { links } = await formeo.documents.send(document.id!)
console.log(links?.map((link) => link.url))
// Later, once every signer has signed (`pdf.status` is `ready`)
const pdf: Uint8Array = await formeo.documents.downloadPdf(document.id!)Customer docs (methods, scopes, errors, retries, configuration): docs/public/sdk.md, served at
/docs/sdk.
API
forms:list(projectIdandtagIdoptional,'none'for unassigned or untagged forms;tagIdtakes a list too, matched withtagMatch: 'any'(default) or'all', andtagNamenames the tags instead, ignoring case),get,create,update,publish,schedule,unpublish,delete,getPublic(no key)submissions:list(formIdoptional;formTagId/formTagName/formTagMatchfilter by the forms' tags, asforms.list'stagIdandtagName),listAll(aPaginator:for awaitover the items, or.pages()),get,getFile(an uploaded file's metadata and a 5-minute download URL),export(CSV),delete,deleteMany(up to 100 ids,POST /v1/submissions/bulk-delete),create(public submit, no key)uploads:create(a presigned upload URL for one file of a file upload field) andupload(create, thenPUTthe file; a failed storagePUTthrowscode: 'upload_failed'), no keyschemas.form(): the form data JSON Schema (no key)webhooks:eventTypes,list,get,create(returns the one-timesecret;formatformeoorslack, fixed at creation),update(PATCH),delete,rotateSecret,test,listDeliveries,listAllDeliveries(aPaginator),redelivertemplates(#665):listandlistAll(built-in templates and your organization's own;locale,category,tag,q,source,archived;{ items, pagination }),categories,get,createForm(a draft form from a template,forms:write), and on Pro and Enterprisecreate(save a form or form data as your organization's template),update,archive,unarchive(templates:write)- Document generation (#620, Pro and Enterprise; keys need
documents:read/documents:write, anddocuments:exportfordocuments.export):documentTemplates(list,listAll,get,create,updatewith theversionread,archive,unarchive,duplicate),documentRecordTypesanddocumentRecords(list,listAll,get,create,update,archive,unarchive),documents(list,listAll,generate,get,updateSettings,addRecipient/updateRecipient/removeRecipient,sendandcreateLink(link tokens returned once),revokeLinks,listComments,comment,replyToComment,resolveComment,events,versions,createVersion,compareVersions,downloadPdf(the signed PDF as aUint8Array;409until stored;documents:export),regeneratePdf,export). Their lists answer{ items, pagination }like the others (the API sendspaginationnext todata;PAGINATED_ENVELOPEinsrc/client.ts). The recipient link endpoints (/v1/document-links/:token) aren't methods: they are the recipient's own page, whose signing evidence is the recipient's browser and IP verifyWebhookSignature(options)/constructWebhookEvent(options): check a delivery'sFormeo-Webhook-Signature(Web Crypto, no key needed); also exported from@draggable/formeo-sdk/webhooks. Options:payload(raw body),headers,secretorsecrets,toleranceSeconds(300),now. ThrowsWebhookVerificationError(reason)request(operationId, { params, query, body, signal, requestId }): any operation insrc/endpoints.ts
Calls (#139, src/response.ts): every method takes CallOptions last (signal, requestId)
and returns a FormeoPromise, a Promise of the data whose withResponse() resolves to
{ data, status, requestId, rateLimit, warnings, headers } (then/catch return plain promises; the data
promise is made on first use, so awaiting only withResponse() leaves no unhandled rejection).
warnings (#401) is the envelope's warnings (ApiWarning[]: code, path, message), only
the well-formed ones and only those three fields, undefined when there are none; codes the
spec doesn't list are kept (ApiWarningCode is the spec's enum or any string). Additive: awaiting
a call still resolves to the data alone. Only updateForm sends warnings today.
requestId is sent as X-Request-Id on every attempt (REQUEST_ID_PATTERN, else a
TypeError before sending); the API otherwise makes one up. signal is combined with the
per-attempt timeout (AbortSignal.any, with a fallback) and also ends a retry wait; an abort
rejects with AbortError (name 'AbortError', cause the signal's reason), never retried.
Paginator requests page after page (ITERATOR_PAGE_SIZE 100 unless limit), stops after
totalPages or an empty page, and drops items the previous page had (offset pages shift when
new items arrive). When a page answers a nextCursor (submissions in the default order, #157)
it asks for the next one by that cursor instead of its number, and stops when there is none;
submissions.listAll({ cursor }) starts after one (not with page).
Errors: FormeoError (every one has requestId and, when the response had them, rateLimit),
AbortError, AuthenticationError (401), PermissionError (403, requiredScope),
NotFoundError, ValidationError (400/422), RateLimitError (429, retryAfter, rateLimit),
ServerError (5xx), ConfigurationError (baseUrl/apiUrl isn't the Formeo API: an HTML
page, a redirect, or a 2xx/404 without the JSON envelope; never retried), ConnectionError and
TimeoutError. Messages never contain the key. Requests use redirect: 'manual', so a redirect
is a ConfigurationError rather than a request that loses its key or goes elsewhere.
Input checks (#280): a path id that's empty, . or .. throws a TypeError before any request
(each id is one encoded path segment, so .. can't turn forms/{formId}/submissions into every
form's submissions); an apiKey with a plain http:// URL to another host throws unless
allowInsecureHttp: true; timeoutMs and retry must be whole numbers in range;
uploads.upload refuses a plain http storage URL from an https API, and any method but
PUT; verifyWebhookSignature refuses a NaN or negative toleranceSeconds or now, reads at
most 8 signatures and computes one HMAC per secret.
Retries (default { maxRetries: 2, maxDelayMs: 10_000 }, retry: false to disable): a 429 is
retried after Retry-After for any method unless the wait exceeds maxDelayMs; 5xx, network
errors and timeouts are retried with backoff for GET/PUT/DELETE only, never POST or
PATCH. RateLimitError.rateLimit.scope is key, organization, user (a dashboard session)
or ip. FormeoError.requestId is the response's X-Request-Id (the server sends one on every
response, middleware/request-id.ts), or without a response the requestId the call sent.
Exports (src/index.ts): the Formeo class, FormeoPromise, Paginator, VERSION,
API_KEY_PATTERN, REQUEST_ID_PATTERN, ENDPOINTS,
redact, HOSTED_API_ORIGIN and apiBaseUrl (src/base-url.ts), the error classes
(including ConfigurationError), the webhook helpers with WEBHOOK_ID_HEADER,
WEBHOOK_TIMESTAMP_HEADER, WEBHOOK_SIGNATURE_HEADER and DEFAULT_WEBHOOK_TOLERANCE_SECONDS,
and types: every type in src/types.ts (API objects, method params, webhook event types),
FormeoOptions, CallOptions, RequestOptions, RetryOptions, WithResponse, ResponseInfo,
Page, ApiKeyScope, Endpoint, OperationId,
FormeoErrorCode, RateLimitInfo, VerifyWebhookOptions, WebhookHeaders,
WebhookVerificationFailure and the generated paths, components, operations.
./webhooks (src/webhooks.ts) exports only the webhook helpers, WebhookVerificationError
and their option types: import the event types from the package root.
Development
npm run build -w packages/sdk # tsc → dist
npm test -w packages/sdk # unit tests with a mocked fetch
npm run generate -w packages/sdk # regenerate src/generated/openapi.ts from the specsrc/generated/openapi.ts is generated by openapi-typescript from the spec the server builds
(npm run openapi -w packages/server -- sync-sdk-types); don't edit it by hand. The server's
src/openapi/sdk.test.ts fails when it's stale, when an operation in src/endpoints.ts drifts
from the spec (method, path, x-required-scope), and runs the SDK against the real app.
Packaging
npm pack -w packages/sdk (or npm publish) runs prepack, which cleans and rebuilds dist/.
The tarball holds dist/ (ES modules and declarations; no tests or source maps, since src/
isn't shipped), README.md, LICENSE and package.json. Only ., ./webhooks and
./package.json are exported; deep imports (@draggable/formeo-sdk/dist/...) are refused.
tsconfig.json is self-contained, so the package builds outside the monorepo.
npm run pack:smoke from the repo root (scripts/pack-smoke.mjs) packs it with the MCP server,
installs the tarballs into a temp project outside the repo, calls a local fake API, verifies a
signed webhook, checks deep imports are refused and the MIT LICENSE ships, and type-checks a
consumer under NodeNext (with @types/node) and Bundler (browser lib, no Node types) with
the library check on. npm run ci runs it.
npm run docs:samples type-checks every SDK code sample in the docs (docs/public/*.md, this
README) and the app against the packed tarball, and the server's sdk-samples.test.ts runs the
runnable ones against the real app with it (#241; docs/README.md, "SDK code samples").
Publishing (#236): 0.1.0 is on npm (first published 2026-10-27). Only the Publish
packages workflow publishes it, from an sdk-v<version> tag; see
docs/RELEASING-PACKAGES.md. The docs, the website
(PLATFORM_FEATURE_NOTES in packages/common/src/plans.ts, the home, pricing, Products,
Platform and About pages) and the dashboard's webhook verification tabs (WebhookManager.tsx)
call it a preview; drop that once it reaches 1.0.
License
MIT, copyright Draggable llc.
