BeeL Node.js SDK

Official Node.js/TypeScript SDK for the BeeL API — Spanish invoicing for self-employed professionals with VeriFactu compliance.
Node.js 18+. Single dependency (openapi-fetch).
Reading this from node_modules? Everything you need is in Markdown next to this file:
this README for common tasks, docs/reference/README.md for every
resource and method (HTTP operation, parameters, return shape, errors), and
llms.txt as a short index. You do not need to read dist/index.d.ts.
Features
- Full TypeScript types generated from the OpenAPI contract, and a typed Markdown reference
- Automatic retries on 429, 5xx and network errors — only when repeating the request cannot apply it twice
- Idempotency keys on every POST, reused by that call's retries;
createOnce for invoices you must never duplicate
- Typed errors — catch
BeeLNotFoundError instead of checking status codes, read the API's apiCode
- Webhook signature verification with HMAC-SHA256
- Instance-based client — multiple API keys, no global state
- ESM + CommonJS
Installation
npm install @beel_es/sdk
Common tasks
Every example is typed: the request bodies are components['schemas'][...] of the contract,
which you can import with import type { components } from '@beel_es/sdk'.
Set up the client
import { BeeL } from '@beel_es/sdk';
const beel = new BeeL({ apiKey: process.env.BEEL_API_KEY! });
Find the company id
Every invoice belongs to a company (a NIF), addressed by its UUID — not by the NIF itself.
Your API key tells you its account; the account lists its companies:
const { account_id } = await beel.catalogs.identity();
const { companies } = await beel.account(account_id).companies.list({ search: 'B12345678' });
const company = beel.company(companies[0].id!);
Create and issue a standard invoice
A create makes a DRAFT; issue numbers it and submits it to VeriFactu. Or do both in one
call with options.issue_directly.
import type { components } from '@beel_es/sdk';
type CreateInvoiceRequest = components['schemas']['CreateInvoiceRequest'];
const body: CreateInvoiceRequest = {
type: 'STANDARD',
external_ref: 'ORD-2026-0042',
recipient: { customer_id: 'customer-uuid' },
lines: [
{
description: 'Consulting',
quantity: 10,
unit_price: 85.5,
main_tax: { type: 'IVA', percentage: 21, regime_key: '01' },
},
],
};
const draft = await company.invoices.create(body);
const issued = await company.invoices.issue(draft.id);
console.log(issued.invoice_number);
const invoice = await company.invoices.create({ ...body, options: { issue_directly: true } });
Create a simplified invoice (ticket)
For sales up to the legal limit without identifying the customer. The recipient may be empty
and must not carry a NIF.
const ticket = await company.invoices.create({
type: 'SIMPLIFIED',
external_ref: 'TICKET-2026-0815',
recipient: {},
lines: [
{ description: 'Menú del día', quantity: 2, unit_price: 14.5, main_tax: { type: 'IVA', percentage: 10, regime_key: '01' } },
],
options: { issue_directly: true },
});
If the customer later asks for a full invoice, exchange it:
company.invoices.createSimplifiedExchange({ simplified_invoice_ids: [ticket.id], recipient: { customer_id } }).
Correct an issued invoice
An issued invoice is never edited. If the operation happened but the invoice is wrong, issue a
corrective; if it was issued by mistake, void it.
const corrective = await company.invoices.createCorrective(issued.id, {
rectification_type: 'PARTIAL',
rectification_code: 'R1',
reason: 'Discount agreed with the customer after the sale',
lines: [
{ description: 'Discount', quantity: -1, unit_price: 50, main_tax: { type: 'IVA', percentage: 21, regime_key: '01' } },
],
});
await company.invoices.createCorrective(issued.id, {
rectification_type: 'TOTAL',
rectification_code: 'R1',
reason: 'Order cancelled by agreement with the customer before delivery',
});
await company.invoices.void(issued.id, { reason: 'Duplicate invoice issued by mistake', issued_in_error: true });
A corrective is not covered by the one-invoice-per-external_ref rule (it carries the order
reference of the invoice it corrects), so createOnce does not create correctives. To retry one
safely, list by rectified_invoice_id (company.invoices.list({ rectified_invoice_id })) before
sending it again, or pass the same idempotencyKey (see below).
Find invoices by your own reference
const { invoices } = await company.invoices.list({ external_ref: 'ORD-2026-0042' });
const invoice = invoices.find((i) => i.type === 'STANDARD' || i.type === 'SIMPLIFIED');
At most one live (not deleted) standard or simplified invoice can hold a given external_ref
in each environment: a second create answers 409 with apiCode
INVOICE_DUPLICATE_EXTERNAL_REFERENCE.
Read the VeriFactu status
AEAT answers asynchronously: a successful issue means accepted for submission.
const current = await company.invoices.get(invoiceId);
current.verifactu?.submission_status;
current.verifactu?.qr_url;
current.verifactu?.error_code;
const records = await company.invoices.verifactuRecords(invoiceId);
const { invoices: rejected } = await company.invoices.list({ verifactu_status: 'REJECTED' });
To be told instead of polling, subscribe a webhook to verifactu.status.updated (see
Webhooks).
Retries, idempotency and never duplicating an invoice
What the client does on its own (defaults: maxRetries: 3, autoIdempotencyKey: true):
429 rate limit | Yes, after Retry-After, any method. The API refused the request unprocessed. |
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 | Yes, with the same Idempotency-Key: if the first attempt took effect, the API replays its answer instead of running it again. A 5xx the API replays (Idempotency-Replay: true) is final and is not retried. |
5xx or network error on a PUT/PATCH/DELETE, or a POST sent without a key | No. |
Any other 4xx | No. |
- One call, one key. Every
POST gets an Idempotency-Key (a UUID) once, and every
automatic retry of that call reuses it. An automatic retry never creates a second invoice.
- A new call is a new key. If
create() finally throws a 5xx or a network error, the
outcome is unknown: the invoice may exist. Calling create() again sends a new key and can
duplicate it. Do not do that.
- The API stores the answer of a key for 24 hours (a
2xx or a 5xx; a 4xx is not
stored, so the corrected request can reuse the key). Retrying with the same key after a 5xx
returns the same 5xx.
The recommended pattern: give the invoice your order id as external_ref, and create it with
createOnce. It looks the reference up first, creates only if nothing holds it, and when the
create fails with an unknown outcome or a 409 INVOICE_DUPLICATE_EXTERNAL_REFERENCE, it returns
the invoice that does exist. Calling it again after any error is safe.
const invoice = await company.invoices.createOnce({
type: 'STANDARD',
external_ref: order.id,
recipient: { customer_id: order.customerId },
lines: [{ description: 'Order', quantity: 1, unit_price: 100, main_tax: { type: 'IVA', percentage: 21, regime_key: '01' } }],
options: { issue_directly: true },
});
If you manage keys yourself, persist one per operation and pass it on every attempt:
await company.invoices.create(body, undefined, { idempotencyKey: `order-${order.id}`, timeoutMs: 15_000 });
Errors keep the API's own code in apiCode (code stays the generic class code for backward
compatibility):
import { BeeLConflictError, BeeLErrorCodes } from '@beel_es/sdk';
try {
await company.invoices.create(body);
} catch (error) {
if (error instanceof BeeLConflictError && error.apiCode === BeeLErrorCodes.INVOICE_DUPLICATE_EXTERNAL_REFERENCE) {
}
throw error;
}
Companies (multi-NIF)
Every operation on beel.company(companyId) addresses the company in the URL path, so it
works with any number of NIFs and never depends on a session focus:
const company = beel.company('company-uuid');
const { invoices, pagination } = await company.invoices.list({ status: 'ISSUED' });
await company.invoices.issue('invoice-uuid');
await company.invoices.void('invoice-uuid', { reason: 'Duplicate', issued_in_error: true });
await company.invoices.createCorrective('invoice-uuid', {
rectification_type: 'PARTIAL',
rectification_code: 'R1',
reason: 'Discount granted after the sale',
circumstance_date: '2026-09-01',
lines: [ ... ],
});
await company.invoices.createSimplifiedExchange({
simplified_invoice_ids: ['simplified-uuid'],
recipient: { customer_id: 'customer-uuid' },
});
const records = await company.invoices.verifactuRecords('invoice-uuid');
const pdf = await company.invoices.getPdf('invoice-uuid');
const customer = await company.customers.create({ ... });
const product = await company.products.create({ ... });
await company.series.ensureDefaults();
await company.recurringInvoices.create({ ... });
await company.recurringInvoices.setStatus('rec-uuid', { status: 'PAUSED' });
await company.invoices.schedule.set('invoice-uuid', { scheduled_for: '2026-12-01' });
await company.invoices.schedule.clear('invoice-uuid');
const copy = await company.invoices.derive({ from_invoice_id: 'invoice-uuid' });
const verifactu = await company.verifactuConfiguration.get();
await company.verifactuConfiguration.update({ enabled: true });
const taxes = await company.taxConfiguration.get();
const summary = await company.fiscalSummary({ year: 2026 });
const readiness = await company.issuingReadiness();
Whether an invoice reaches AEAT is a fact of the NIF, resolved at issue time — not a
per-invoice choice. enabled is the only writable field of the VeriFactu configuration.
Payment connections (Stripe per NIF)
A connection links Stripe to one NIF so its charges auto-generate invoices under it. It is
addressed by its own UUID, not by the provider slug — a NIF can hold several
connections of the same provider. The slug only appears when you start an authorization.
const { connections } = await company.paymentConnections.list();
const { authorization_url } = await company.paymentConnections.authorize({
provider: 'stripe',
});
const events = company.paymentConnections.events(connections[0].id);
const { events: pending } = await events.list({ needs_action: true });
await events.retry('event-uuid');
const { invoice_id } = await events.draft('event-uuid');
await events.resolve('event-uuid');
await company.paymentConnections.disconnect(connections[0].id);
Catalogs and preferences
const taxTypes = await beel.catalogs.taxTypes();
const options = await beel.catalogs.invoiceCustomizationOptions();
await beel.catalogs.updateMe({ language: 'es' });
Any endpoint: beel.raw
A route reaches the generated types as soon as the contract is synced, but its ergonomic
wrapper is written by hand. beel.raw lets you call anything in the contract today,
without waiting for a release:
const { data } = await beel.raw.GET('/v1/companies/{company_id}/invoice-customization', {
params: { path: { company_id: 'company-uuid' } },
});
It is the same client the resources use — authentication, retries and Idempotency-Key
still apply — and it stays typed: a path, parameter or body the contract does not have
will not compile. What you give up is the sugar and the naming stability of the wrappers.
Error handling is the SDK's too: a non-2xx answer is thrown as a typed error (below), so a
raw call never resolves with openapi-fetch's { error } — data is always the success body.
Accounts (managed accounts, members & webhooks)
For integrators managing several accounts (provisioning, gestorías, platforms):
const { accounts } = await beel.accounts.list();
const created = await beel.accounts.provision({ external_ref: 'client-42' });
const account = beel.account(created.account_id);
const { companies } = await account.companies.list();
await account.companies.create({ nif: 'B12345678', ... });
const members = await account.members.list();
await account.members.putGrant('member-id', 'company-id', { permissions: [...] });
await account.invitations.create({ invited_email: 'user@example.com', account_role: 'MEMBER' });
const webhook = await account.webhooks.create({ url: 'https://...', events: ['invoice.issued'] });
await account.webhooks.test(webhook.id);
await account.webhooks.rotateSecret(webhook.id);
const usage = await account.usage();
await account.createClaimToken();
Resources (deprecated session-focus surface)
Deprecated: the API contract marks the whole session-focus surface (beel.invoices,
beel.customers, beel.products, beel.series, beel.configuration) as deprecated.
Prefer the company-scoped equivalents above. These keep working during the sunset window.
const { invoices, pagination } = await beel.invoices.list({ status: 'ISSUED', limit: 20 });
const invoice = await beel.invoices.get('invoice-uuid');
const created = await beel.invoices.create({ ... });
const updated = await beel.invoices.update('id', { ... });
await beel.invoices.delete('id');
await beel.invoices.issue('id');
await beel.invoices.markPaid('id');
await beel.invoices.markSent('id');
await beel.invoices.void('id', { reason: 'Duplicate invoice sent in error' });
await beel.invoices.schedule('id', { scheduled_for: '2026-04-15' });
await beel.invoices.unschedule('id');
await beel.invoices.duplicate('id');
await beel.invoices.createCorrective('id', { ... });
const { customers, pagination } = await beel.customers.list({ search: 'Acme' });
const customer = await beel.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' },
});
const { products } = await beel.products.list();
const results = await beel.products.search('consulting');
const taxConfig = await beel.configuration.getTaxConfig();
const series = await beel.series.list();
const result = await beel.nif.validate('B12345678');
Typed Errors
Every API error maps to a specific error class:
import { BeeL, BeeLNotFoundError, BeeLValidationError } from '@beel_es/sdk';
try {
await beel.invoices.get('nonexistent-id');
} catch (error) {
if (error instanceof BeeLNotFoundError) {
console.log(error.message);
console.log(error.requestId);
}
if (error instanceof BeeLValidationError) {
console.log(error.message);
console.log(error.details);
}
}
BeeLAuthError | 401, 403 | Invalid or missing API key, or no access to the resource |
BeeLNotFoundError | 404 | Resource doesn't exist |
BeeLValidationError | 422 | Invalid data or a business rule |
BeeLConflictError | 409 | Duplicate (external_ref, NIF…) or an Idempotency-Key conflict |
BeeLRateLimitError | 429 | Too many requests (retried automatically first) |
BeeLApiError | 400, 5xx | Bad request, or a server error (retried when safe) |
Every error carries statusCode, apiCode (the API's error.code, e.g.
INVOICE_DUPLICATE_EXTERNAL_REFERENCE), code (a generic code per class), details and
requestId — quote the last one when you contact support. See
docs/reference/errors.md.
Automatic Retries
See Retries, idempotency and never duplicating an invoice
for exactly what is retried. Tune it with:
const beel = new BeeL({
apiKey: 'beel_sk_live_...',
maxRetries: 5,
retryDelayMs: 1000,
maxRetryDelayMs: 60000,
});
PDF Download
import fs from 'fs';
const { buffer, fileName } = await beel.downloadPdf('invoice-uuid');
fs.writeFileSync(fileName, buffer);
Webhooks
Verify webhook signatures before processing:
import express from 'express';
import { WebhookVerifier } from '@beel_es/sdk';
const verifier = new WebhookVerifier(process.env.BEEL_WEBHOOK_SECRET!);
app.post('/webhooks/beel', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = verifier.verify(
req.body.toString('utf8'),
req.headers['beel-signature'] as string,
);
if (event.test) return res.status(200).send('OK');
if (event.type === 'verifactu.status.updated') {
console.log(event.data.new_status);
}
res.status(200).send('OK');
} catch {
res.status(400).send('Invalid signature');
}
});
Builders
Optional fluent builders for common operations:
import { InvoiceBuilder, CustomerBuilder } from '@beel_es/sdk';
const customer = CustomerBuilder.create()
.name('Acme SL')
.nif('B12345678')
.email('contact@acme.com')
.address('Calle Mayor', '1', '28001', 'Madrid', 'Madrid', 'Spain')
.build();
const invoice = InvoiceBuilder.create()
.forCustomer('customer-uuid')
.mainTax({ type: 'IVA', percentage: 21, regime_key: '01' })
.addLine('Service A', 5, 100.0)
.addLine('Service B', 3, 150.0)
.metadata({ stripe_id: 'pi_abc123' })
.build();
Environments
const sandbox = new BeeL({ apiKey: 'beel_sk_test_...' });
const prod = new BeeL({ apiKey: 'beel_sk_live_...' });
Full Configuration
const beel = new BeeL({
apiKey: 'beel_sk_live_...',
baseUrl: 'https://app.beel.es/api',
maxRetries: 3,
retryDelayMs: 500,
maxRetryDelayMs: 30000,
autoIdempotencyKey: true,
});
Documentation
License
MIT