Node.js / TypeScript SDK
Fully-typed TypeScript SDK for the BeeL. API with automatic retries, idempotency and built-in webhook verification.
The official BeeL. SDK for Node.js 18+ and any modern TypeScript runtime. It wraps the public REST API with strongly-typed resources, fluent builders, automatic retries with exponential backoff, automatic Idempotency-Key generation on every POST, and a constant-time HMAC webhook verifier.
This page describes version 2.2.0 of @beel_es/sdk. Everything you do to a company (NIF) goes through beel.company(companyId), which addresses the company in the URL of every request.
Installation
The SDK has a single runtime dependency (openapi-fetch) and ships its own TypeScript definitions.
npm install @beel_es/sdkWith another tool: pnpm add @beel_es/sdk, yarn add @beel_es/sdk, bun add @beel_es/sdk.
Use 2.2.0 or later.
Requires Node.js 18 or higher. The SDK uses the global fetch API.
Authentication
Every request must include an API key generated from your BeeL. dashboard. The SDK sends it as a Bearer token in the Authorization header.
import { BeeL } from '@beel_es/sdk';
const beel = new BeeL({
apiKey: process.env.BEEL_API_KEY!,
// baseUrl: process.env.MY_API_URL, // only for another environment or a proxy
});If your project configures the API URL, pass it as baseUrl instead of calling the API by hand; without it the client uses production. Read the key and the URL from your project's own environment variables, whatever their names.
| Key prefix | Environment | VeriFactu submission |
|---|---|---|
beel_sk_test_* | Sandbox | To AEAT's test environment — nothing is registered for real (details) |
beel_sk_live_* | Production | Real submission counts against your quota |
The SDK does not read environment variables automatically; always pass apiKey explicitly so credentials remain visible at the call site.
Find your company ID
Invoices, customers, products and series belong to a company (one NIF). You pass its ID to beel.company(). If you don't have it yet, read your account from your identity and list its companies:
const { account_id: accountId } = await beel.catalogs.identity();
const { companies } = await beel.account(accountId).companies.list();
console.log(companies.map((c) => [c.id, c.nif]));The ID doesn't change, so store it in your configuration instead of looking it up on every run. See Companies for how companies are created and switched on.
Quickstart
Create a customer and an invoice, then issue it, in three calls.
Scope the client to your company
import { BeeL } from '@beel_es/sdk';
const beel = new BeeL({ apiKey: process.env.BEEL_API_KEY! });
const company = beel.company(process.env.BEEL_COMPANY_ID!);Create a customer
const customer = await company.customers.create({
legal_name: 'Acme SL',
nif: 'B86561412',
address: {
street: 'Calle Mayor',
number: '1',
postal_code: '28001',
city: 'Madrid',
province: 'Madrid',
country: 'Spain',
country_code: 'ES',
},
});Create a draft invoice
const draft = await company.invoices.create({
type: 'STANDARD',
recipient: { customer_id: customer.id },
lines: [
{
description: 'Consulting',
quantity: 1,
unit_price: 500,
main_tax: { type: 'IVA', percentage: 21, regime_key: '01' },
irpf_rate: 0,
},
],
});Issue the invoice
Assigns the next invoice number, freezes the totals and, when VeriFactu is on for the NIF, submits the record to the AEAT.
const issued = await company.invoices.issue(draft.id);
console.log(issued.invoice_number); // "A-2026/0001"Company resources
beel.company(companyId) returns a scope with one property per resource. Every method returns a typed Promise and throws a typed error on non-2xx responses.
invoices
list, get, create, createOnce, update, delete, issue, void, createCorrective, createSimplifiedExchange, verifactuRecords, convertToInvoice, setStatus, getPdf, send, derive, plus schedule.get / schedule.set / schedule.clear
customers
list, get, create, createBulk, update, delete
products
list, get, create, update, delete
series
list, create, update, delete, getDefaults, ensureDefaults, setDefault
recurringInvoices
list, get, create, update, delete, setStatus, skip, generateNow, nextOccurrence, history
paymentConnections
list, authorize, update, disconnect, and events(connectionId): list, get, retry, draft, resolve, discard, restore
taxConfiguration
get, update (replaces the whole configuration)
verifactuConfiguration
get, update (enabled is the only writable field)
The scope itself reads and manages the company: get(), update(), delete(), fiscalSummary({ year }) and issuingReadiness(), which tells you whether the NIF can issue right now and what is missing if not.
Listing and pagination
List endpoints return the collection with a pagination object. Destructure both:
const { invoices, pagination } = await company.invoices.list({
page: 1,
limit: 50,
status: ['ISSUED'],
});
console.log(`Showing ${invoices.length} of ${pagination.total_items}`);List endpoints take page (starts at 1) and limit (max 100) query parameters, and return the collection alongside a pagination object:
| Field | Description |
|---|---|
pagination.current_page | Page you are on (1-based) |
pagination.total_pages | Total number of pages — iterate up to this, never assume one page |
pagination.total_items | Total matching items across all pages |
pagination.items_per_page | Effective page size |
pagination.has_next / has_previous | Convenience booleans for cursorless iteration |
Finding and reading an invoice
list takes the same filters as the API, including external_ref, the reference you sent when you created the invoice. get reads one by id:
// Your own reference, sent as `external_ref` when you created the invoice
const { invoices: found } = await company.invoices.list({ external_ref: 'ORD-2026-0042' });
const invoice = found[0] ?? (await company.invoices.get(issuedId, { timeoutMs: 10_000 }));
console.log(invoice.status, invoice.verifactu?.submission_status);Every company.invoices method takes per-call options as its last argument: timeoutMs, signal and idempotencyKey — see Retries and idempotency.
Creating an invoice once per order
createOnce creates a standard or simplified invoice at most once per external_ref: it looks the reference up first, creates only if nothing holds it, and after a 409 INVOICE_DUPLICATE_EXTERNAL_REFERENCE or an unknown outcome returns the invoice that exists. Calling it again after any error is safe. The invoice returned may be one created earlier, in any status. It does not create correctives. The complete pattern is in From order to invoice.
// One invoice per order, safe to call again after any error
const orderInvoice = await company.invoices.createOnce(
{
type: 'STANDARD',
external_ref: 'ORD-2026-0042',
recipient: { customer_id: '4f244735-980b-8d9c-80e8-6331fa0b1958' },
lines: [
{ description: 'Consulting', quantity: 1, unit_price: 500, main_tax: { type: 'IVA', percentage: 21, regime_key: '01' }, irpf_rate: 0 },
],
options: { issue_directly: true },
},
undefined,
{ timeoutMs: 20_000 },
);Correcting an invoice
createCorrective issues a corrective invoice over an issued one. The body is CreateCorrectiveInvoiceRequest: which reason code applies and what TOTAL and PARTIAL do are in Corrective invoices.
const corrective = await company.invoices.createCorrective(issuedId, {
rectification_type: 'PARTIAL',
rectification_code: 'R1',
reason: 'Discount granted after the sale',
circumstance_date: '2026-09-01',
lines: [
{
description: 'Discount of 10 % granted after the sale',
quantity: -1,
unit_price: 50,
main_tax: { type: 'IVA', percentage: 21, regime_key: '01' },
},
],
options: { issue_directly: true },
});verifactuRecords lists the records AEAT holds for an invoice — its registration and, if voided, its cancellation:
const records = await company.invoices.verifactuRecords(issuedId); // registration and cancellation recordsA customer who wants a full invoice for tickets already issued gets an exchange, not a corrective (Simplified vs standard):
// Exchange issued simplified invoices for one standard invoice with the customer identified
const exchange = await company.invoices.createSimplifiedExchange({
simplified_invoice_ids: [issuedId],
recipient: { customer_id: '4f244735-980b-8d9c-80e8-6331fa0b1958' },
});Scheduling an invoice
One call sets the schedule, and calling it again moves it. generation_mode is required: DRAFT leaves a draft for you to review on that date, ISSUE_AND_SEND issues and sends it.
await company.invoices.schedule.set(draftId, {
scheduled_for: '2026-12-01',
generation_mode: 'ISSUE_AND_SEND',
});
const schedule = await company.invoices.schedule.get(draftId);
await company.invoices.schedule.clear(draftId); // back to a plain draftDeriving an invoice from another
derive creates a new draft from an existing invoice:
const copy = await company.invoices.derive({
from_invoice_id: issuedId,
mode: 'DUPLICATE',
});Downloading PDFs
getPdf returns a pre-signed download_url that stops working after expires_in_seconds. Download it right away:
import { writeFile } from 'node:fs/promises';
const pdf = await company.invoices.getPdf(issuedId);
const response = await fetch(pdf.download_url);
await writeFile(pdf.file_name, Buffer.from(await response.arrayBuffer()));Payment connections
A connection links Stripe to one NIF so its charges become invoices under that NIF. Each connection has its own ID: a NIF can hold several connections to the same provider. You name the provider only when you start an authorization.
const { authorization_url } = await company.paymentConnections.authorize({
provider: 'stripe',
});
const { connections } = await company.paymentConnections.list();
const events = company.paymentConnections.events(connections[0].id);
// Charges that did not become an invoice
const { events: pending } = await events.list({ needs_action: true });
await events.retry(pending[0].id); // run it again with the NIF's current configuration
const draft = await events.draft(pending[0].id); // or build a draft to review before issuing
await events.resolve(pending[0].id); // or mark it as handled outside BeeL.discard hides an event that needs no action, and restore brings it back. See Payment connections for when each lever applies.
Account resources
These live on the client itself, not on a company:
| Property | Methods |
|---|---|
beel.account(accountId) | get, usage, changeAccessLevel, endManagement, createClaimToken, setOwner, and the sub-resources companies (list, create, stats), members (list, get, update, remove, listGrants, putGrant, removeGrant), invitations (list, create, get, revoke) and webhooks (list, create, get, update, delete, test, rotateSecret, listDeliveries, retryDelivery) |
beel.accounts | list, provision, get, scope — the accounts you manage |
beel.catalogs | taxTypes, invoiceCustomizationOptions, updateMe (your personal preferences, such as the language) |
beel.nif | validate(nif) — server-side AEAT census lookup |
const account = beel.account(accountId);
const webhook = await account.webhooks.create({
url: 'https://example.com/webhooks/beel',
events: ['invoice.issued', 'verifactu.status.updated'],
account_relationship: 'own',
});The response carries the signing secret, which is only returned once. Store it before doing anything else.
Any endpoint: beel.raw
Not every operation has a named method. beel.raw is the typed client underneath, so you can call any operation of the contract the installed version was built from:
const { data } = await beel.raw.GET('/v1/companies/{company_id}/recurring-invoices/stats', {
params: { path: { company_id: process.env.BEEL_COMPANY_ID! } },
});Authentication, retries, Idempotency-Key and typed errors still apply — raw throws the same typed errors as the named methods. The path, its parameters and the body are checked against that contract, so a wrong one does not compile. What you give up is the unwrapping: data is the whole response body, with the payload under data.data.
An operation added to the API after your installed version is not in its types. Update the package to get it, or call it with fetch meanwhile.
Fluent builders
For larger payloads, the SDK ships two chainable builders that validate required fields before they hit the network. Their output goes to the company-scoped create methods.
const payload = InvoiceBuilder.create()
.type('STANDARD')
.forCustomer('4f244735-980b-8d9c-80e8-6331fa0b1958')
.mainTax({ type: 'IVA', percentage: 21, regime_key: '01' }) // for the lines added after it
.addLine('Consulting', 10, 85.5)
.addLine('Support', 5, 50)
.dueDate('2026-05-31')
.metadata({ stripe_payment_intent: 'pi_abc' })
.build();
const invoice = await company.invoices.create(payload);build() throws if neither forCustomer() nor at least one line have been set.
const customerPayload = CustomerBuilder.create()
.name('Acme Corporation SL')
.nif('B12345678')
.email('contact@acme.com')
.address('Calle Mayor', '1', '28001', 'Madrid', 'Madrid', 'Spain')
.build();
const customer = await company.customers.create(customerPayload);build() throws if name, nif or address are missing. address(...) defaults countryCode to ES.
Deprecated: the session-focus resources
beel.invoices, beel.customers, beel.products, beel.series, beel.configuration and beel.downloadPdf() call the flat routes (/v1/invoices, /v1/customers, …), which don't name the company. They are marked @deprecated in the SDK and in the contract, and the routes they call retire on 9 December 2026. After that date these methods fail.
Move each call to the company scope:
| Deprecated | Use instead |
|---|---|
beel.invoices.* | beel.company(id).invoices.* |
beel.invoices.schedule() / reschedule() | company.invoices.schedule.set(): one call sets or moves the date |
beel.invoices.unschedule() | company.invoices.schedule.clear() |
beel.invoices.duplicate() | company.invoices.derive({ from_invoice_id, mode: 'DUPLICATE' }) |
beel.invoices.markPaid() / markSent() / revertToIssued() | company.invoices.setStatus() with status: 'PAID', 'SENT' or 'ISSUED' |
beel.invoices.sendEmail() | company.invoices.send() |
beel.customers.* (deactivate) | beel.company(id).customers.* (delete) |
beel.products.* (search) | beel.company(id).products.* (list({ q })) |
beel.series.* | beel.company(id).series.* |
beel.configuration.getTaxConfig() / updateTaxConfig() | company.taxConfiguration.get() / update() |
beel.configuration.getVerifactu() / updateVerifactu() | company.verifactuConfiguration.get() / update() |
beel.configuration.getTaxTypes() / getInvoiceCustomizationOptions() | beel.catalogs.taxTypes() / invoiceCustomizationOptions() |
beel.configuration.updateLanguage() | beel.catalogs.updateMe({ language }) |
beel.downloadPdf() | company.invoices.getPdf() and fetch download_url (above) |
See Resources under the NIF for the full list of retiring routes.
Error handling
Every non-2xx response is thrown as a BeeLApiError, or one of its subclasses, with statusCode, apiCode (the API's own error.code), message, requestId and, when the API sends them, details. Narrow with instanceof, and branch on apiCode; BeeLErrorCodes names the codes the SDK itself relies on. A request that gets no response at all — a network error, or a timeoutMs or signal that fired — throws the runtime's own error, not a BeeLApiError.
import {
BeeLApiError,
BeeLConflictError,
BeeLErrorCodes,
BeeLNotFoundError,
BeeLRateLimitError,
BeeLValidationError,
} from '@beel_es/sdk';
try {
await company.invoices.get('4f244735-980b-8d9c-80e8-6331fa0b1958');
} catch (err) {
if (err instanceof BeeLNotFoundError) {
// 404: surface a friendly message
} else if (err instanceof BeeLValidationError) {
console.log(err.apiCode, err.message, err.details); // 422: which rule, and on which field
} else if (err instanceof BeeLConflictError && err.apiCode === BeeLErrorCodes.INVOICE_DUPLICATE_EXTERNAL_REFERENCE) {
// 409: an invoice already holds this external_ref — look it up
} else if (err instanceof BeeLRateLimitError) {
console.log(`Retry after ${err.retryAfterSeconds}s`); // after the automatic retries ran out
} else if (err instanceof BeeLApiError) {
console.error(err.statusCode, err.apiCode, err.requestId, err.message);
} else {
throw err; // no response at all: network error, timeout or abort
}
}| Class | HTTP | code (kept for compatibility) |
|---|---|---|
BeeLAuthError | 401 / 403 | UNAUTHORIZED / FORBIDDEN |
BeeLNotFoundError | 404 | NOT_FOUND |
BeeLConflictError | 409 | CONFLICT, with details |
BeeLValidationError | 422 | UNPROCESSABLE_ENTITY, with details |
BeeLRateLimitError | 429 | RATE_LIMIT_EXCEEDED, with retryAfterSeconds |
BeeLApiError | any other status (400, 5xx…) | the API's error.code |
code is the class's generic value for the statuses with their own class; read apiCode for the API's code, such as INVOICE_DUPLICATE_EXTERNAL_REFERENCE on a 409 or the fiscal rule a 422 enforces.
Retries and idempotency
The client retries on its own only when repeating the request cannot apply it twice, and every POST carries an Idempotency-Key (a UUID) that all the automatic retries of that call reuse:
| Situation | Automatic retry? |
|---|---|
429 | Yes, after Retry-After, any method |
409 IDEMPOTENCY_KEY_PROCESSING | Yes, with the same key: the first request is still running |
5xx or network error on a GET | Yes, with exponential backoff |
5xx or network error on a POST with a key | Yes, with the same key: if the first attempt took effect, the API replays its answer. A replayed 5xx (Idempotency-Replay: true) is final |
5xx or network error on PUT, PATCH, DELETE, or a POST without a key | No |
Any other 4xx | No |
Tune the policy at construction time:
const beel = new BeeL({
apiKey: process.env.BEEL_API_KEY!,
maxRetries: 5, // default 3
retryDelayMs: 1000, // default 500
maxRetryDelayMs: 60000, // default 30000: the cap of every wait
});A new call is a new key. If create() finally throws a 5xx or a network error, the invoice may exist, and calling create() again can duplicate it. For invoices, use createOnce; for anything else, persist one key per operation and pass it on every attempt, with a timeout:
// Per call, on every company.invoices method: your own key, a timeout, an abort signal
await company.invoices.create(body, undefined, {
idempotencyKey: `order-${order.id}`, // persisted with the order: the same key on every attempt
timeoutMs: 15_000, // covers the automatic retries too
});Within 24 hours, a repeated key replays the first 2xx or 5xx answer instead of running the operation again. See Idempotency.
Webhook verification
WebhookVerifier validates the BeeL-Signature header using HMAC-SHA256 with constant-time comparison and rejects replays older than the tolerance window (5 minutes by default). verify returns a typed event: after the test check, comparing event.type narrows event.data to that event's payload. Plug the handler into your framework with the raw request body — for Express, express.raw({ type: 'application/json' }).
import { WebhookVerifier, WebhookVerificationError } from '@beel_es/sdk';
const verifier = new WebhookVerifier(process.env.BEEL_WEBHOOK_SECRET!);
/** Call it with the raw request body (bytes as received) and the BeeL-Signature header. */
export function handleWebhook(rawBody: string | Buffer, signature: string | undefined): number {
let event;
try {
event = verifier.verify(rawBody, signature);
} catch (err) {
if (err instanceof WebhookVerificationError) return 400; // bad signature or too old
throw err;
}
// "Send test" deliveries carry synthetic data: acknowledge and stop.
if (event.test) return 200;
if (event.type === 'verifactu.status.updated') {
// `event.data` is narrowed by `type`
const { invoice_number, operation, new_status, qr_url, error_code, error_message } = event.data;
// operation: REGISTRATION (the invoice) or VOID (its cancellation)
// ACCEPTED → store qr_url; REJECTED → log error_code / error_message and notify the user
console.log(invoice_number, operation, new_status, qr_url, error_code, error_message);
}
return 200;
}The body must be the raw bytes as received from BeeL — any reserialization will invalidate the signature. See Webhooks for the full event catalogue and payload shapes.
Configuration reference
Prop
Type
Source and issues
The SDK is open source. Contributions, bug reports and feature requests are welcome.
View on GitHub
Source code, changelog, examples and issue tracker.
npm package
Latest release, install command and version history.