@beel_es/sdk
v2.2.0
Published
Official Node.js SDK for BeeL Public API - Spanish invoicing platform with VeriFactu support
Maintainers
Readme
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;
createOncefor invoices you must never duplicate - Typed errors — catch
BeeLNotFoundErrorinstead of checking status codes, read the API'sapiCode - Webhook signature verification with HMAC-SHA256
- Instance-based client — multiple API keys, no global state
- ESM + CommonJS
Installation
npm install @beel_es/sdkCommon 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';
// beel_sk_test_* → sandbox (VeriFactu test mode, no quota), beel_sk_live_* → production
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!); // keep this id in your configurationCreate 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', // your order id: unique per live invoice, searchable
recipient: { customer_id: 'customer-uuid' }, // or the recipient's data inline
lines: [
{
description: 'Consulting',
quantity: 10,
unit_price: 85.5,
main_tax: { type: 'IVA', percentage: 21, regime_key: '01' }, // required on every line
},
],
};
const draft = await company.invoices.create(body);
const issued = await company.invoices.issue(draft.id);
console.log(issued.invoice_number); // e.g. "A-2026/0001"
// Or in one call:
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.
// Partial: only the difference (here, a discount granted after the sale)
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' } },
],
});
// Total: cancels everything still invoiced on the original (no lines)
await company.invoices.createCorrective(issued.id, {
rectification_type: 'TOTAL',
rectification_code: 'R1',
reason: 'Order cancelled by agreement with the customer before delivery',
});
// Issued by mistake (a test, an accidental duplicate):
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; // 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'VOIDED' | 'NOT_SUBMITTED'
current.verifactu?.qr_url; // AEAT verification URL, from submission on
current.verifactu?.error_code; // AEAT's code when it reported a problem
// The records themselves (registration, then cancellation if voided)
const records = await company.invoices.verifactuRecords(invoiceId);
// Everything AEAT rejected
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):
| Situation | Automatic retry? |
|---|---|
| 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
POSTgets anIdempotency-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 a5xxor a network error, the outcome is unknown: the invoice may exist. Callingcreate()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
2xxor a5xx; a4xxis not stored, so the corrected request can reuse the key). Retrying with the same key after a5xxreturns the same5xx.
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) {
// someone already created it: look it up by external_ref
}
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');
// Invoices — full lifecycle
const { invoices, pagination } = await company.invoices.list({ status: 'ISSUED' });
await company.invoices.issue('invoice-uuid');
// A void is only for an invoice issued by mistake; `issued_in_error: true` is required
// once it has been sent or paid. An operation that did happen gets a corrective instead.
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', // when the art. 80 circumstance happened, if any
lines: [ ... ],
});
// Exchange issued simplified invoices for a full one with the customer identified
await company.invoices.createSimplifiedExchange({
simplified_invoice_ids: ['simplified-uuid'],
recipient: { customer_id: 'customer-uuid' },
});
const records = await company.invoices.verifactuRecords('invoice-uuid'); // registration, cancellation
const pdf = await company.invoices.getPdf('invoice-uuid');
// Customers, products, series
const customer = await company.customers.create({ ... });
const product = await company.products.create({ ... });
await company.series.ensureDefaults(); // idempotent seeding of the typed default series
// Recurring invoices
await company.recurringInvoices.create({ ... });
await company.recurringInvoices.setStatus('rec-uuid', { status: 'PAUSED' });
// Scheduling — one PUT sets or moves it, one DELETE clears it
await company.invoices.schedule.set('invoice-uuid', { scheduled_for: '2026-12-01' });
await company.invoices.schedule.clear('invoice-uuid');
// Derive a new invoice from an existing one
const copy = await company.invoices.derive({ from_invoice_id: 'invoice-uuid' });
// Fiscal configuration of this NIF
const verifactu = await company.verifactuConfiguration.get();
await company.verifactuConfiguration.update({ enabled: true });
const taxes = await company.taxConfiguration.get();
// Company health
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 });
// Recovery levers for a charge that did not invoice
await events.retry('event-uuid');
const { invoice_id } = await events.draft('event-uuid'); // review before issuing
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' }); // the language belongs to the personAny 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):
// List and provision managed accounts
const { accounts } = await beel.accounts.list();
const created = await beel.accounts.provision({ external_ref: 'client-42' });
// Scope to one account
const account = beel.account(created.account_id);
// Companies (NIFs) under the account
const { companies } = await account.companies.list();
await account.companies.create({ nif: 'B12345678', ... });
// Members, roles and per-company grants
const members = await account.members.list();
await account.members.putGrant('member-id', 'company-id', { permissions: [...] });
// Invitations
await account.invitations.create({ invited_email: '[email protected]', account_role: 'MEMBER' });
// Account-scoped webhooks
const webhook = await account.webhooks.create({ url: 'https://...', events: ['invoice.issued'] });
await account.webhooks.test(webhook.id);
await account.webhooks.rotateSecret(webhook.id);
// Usage & ownership
const usage = await account.usage();
await account.createClaimToken(); // let the end user claim the accountResources (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.
// Invoices
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');
// Invoice lifecycle
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', { ... });
// Customers
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' },
});
// Products
const { products } = await beel.products.list();
const results = await beel.products.search('consulting');
// Configuration
const taxConfig = await beel.configuration.getTaxConfig();
const series = await beel.series.list();
// NIF validation
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); // "Invoice not found"
console.log(error.requestId); // "abc123" (for support)
}
if (error instanceof BeeLValidationError) {
console.log(error.message); // "El NIF no tiene un formato válido"
console.log(error.details); // { field: "nif", invalid_value: "..." }
}
}| Error class | Status | When |
|---|---|---|
| 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, // default: 3 (0 disables retries)
retryDelayMs: 1000, // default: 500
maxRetryDelayMs: 60000, // default: 30000
});PDF Download
import fs from 'fs';
const { buffer, fileName } = await beel.downloadPdf('invoice-uuid');
fs.writeFileSync(fileName, buffer);
// => factura_A-2026-0001.pdfWebhooks
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,
);
// "Send test" deliveries carry a synthetic `data`: acknowledge and stop.
if (event.test) return res.status(200).send('OK');
if (event.type === 'verifactu.status.updated') {
console.log(event.data.new_status); // 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'VOIDED'
}
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('[email protected]')
.address('Calle Mayor', '1', '28001', 'Madrid', 'Madrid', 'Spain')
.build();
const invoice = InvoiceBuilder.create()
.forCustomer('customer-uuid')
.mainTax({ type: 'IVA', percentage: 21, regime_key: '01' }) // required on every line
.addLine('Service A', 5, 100.0)
.addLine('Service B', 3, 150.0)
.metadata({ stripe_id: 'pi_abc123' })
.build();Environments
// Sandbox — no VeriFactu quota consumed, safe to test
const sandbox = new BeeL({ apiKey: 'beel_sk_test_...' });
// Production
const prod = new BeeL({ apiKey: 'beel_sk_live_...' });Full Configuration
const beel = new BeeL({
apiKey: 'beel_sk_live_...', // Required
baseUrl: 'https://app.beel.es/api', // Default
maxRetries: 3, // Default
retryDelayMs: 500, // Default
maxRetryDelayMs: 30000, // Default
autoIdempotencyKey: true, // Default
});Documentation
- SDK reference: docs/reference/README.md — every resource and
method, generated from the source and the contract (
npm run docs:reference). - API docs: docs.beel.es
License
MIT
