NewProvince is only required for addresses in Spain
BeeL
Get startedMulti-NIFVeriFactuRulesStripeAPI referenceChangelog

From order to invoice

One page with typed Node.js SDK code for each case a shop meets when it turns orders into invoices, from the first issue to refunds and AEAT's answer.


Use the official BeeL. SDK for your stack. If it is not installed in the project, installing it is step one: do not hand-write HTTP calls for the flows it covers, the SDK carries the typed request bodies and the safe retries. For this page that is the Node.js / TypeScript SDK, @beel_es/sdk 2.2.0 or later: npm install @beel_es/sdk.

Your shop or platform has orders; each paid order needs one invoice, issued through BeeL. and registered with AEAT. This page gives the code for every common case, with the fiscal rule behind it. Each section stands on its own: read the one that matches your order. For the cases listed below this page is self-contained — the types, values and rules you need are here; read other pages only for a case it does not cover.

Every snippet is TypeScript for the Node.js SDK (@beel_es/sdk 2.1.0 or later), and it is compiled against the SDK and the API contract each time these docs are built, so the request bodies below are valid as published.

Not legal or tax advice

This page describes how BeeL. works. It is not legal or tax advice: confirm your own obligations, and those of the businesses you invoice for, with your tax advisor.
Your orderSectionRules
Any order, retried safelyIssue one invoice per orderLIF-004 Retry writes with the same Idempotency-Key
Business in Spain with NIFBusiness customer in SpainCNT-007 Each line describes the operation, its unit price and any discount
Consumer, no fiscal dataConsumer ticketSIM-001 A simplified invoice never exceeds 3,000 €, SIM-002 Above 400 €, a simplified invoice needs an art. 4.2 activity
Delivered or provided on another dayOperation dateDAT-004 Send the operation date when it differs from the issue date
Paid in another currencyForeign currencyTAX-013 Send every amount in euros
Goods to an EU business with a VAT numberGoods to an EU businessTAX-004 Intra-EU supplies of goods are exempt only with the buyer's EU VAT number, SIM-003 Some operations can never go on a simplified invoice
Services to a business outside the EUServices outside the EUTAX-006 Services to a business abroad are not subject to Spanish VAT
Professional services with withholdingIRPF withholdingTAX-009 IRPF withholding is not part of the total AEAT receives, TAX-010 Set the IRPF withholding when the customer must withhold
Coupon or discountDiscounts and couponsCNT-007 Each line describes the operation, its unit price and any discount
Whole order returnedFull refundCOR-002 Pick the reason code: R1–R4 for standard invoices, R5 for simplified, VOI-001 Void only an invoice that should never have been issued
Some items of a ticket returnedPartial refund of a ticketCOR-002 Pick the reason code: R1–R4 for standard invoices, R5 for simplified
Knowing AEAT accepted itWaiting for AEATREC-008 Follow submission_status and fix what AEAT rejects

Before you start

Step one: install the SDK if it is not in your project's package.json yet. The SDK types every request body against the API contract, and its retries reuse one Idempotency-Key per call, so they never create a second invoice.

npm install @beel_es/sdk

With another tool: pnpm add @beel_es/sdk, yarn add @beel_es/sdk, bun add @beel_es/sdk.

Use 2.2.0 or later.

Create one client with your API key from the environment. Use a test key (beel_sk_test_…) until the whole flow works: invoices issued with it go to AEAT's test environment only. Every invoice call on this page passes CALL, a per-request timeout that covers the automatic retries, so a request that never answers cannot hang your integration.

client.ts
import { BeeL, type RequestOptions } from '@beel_es/sdk';

export const beel = new BeeL({
  apiKey: process.env.BEEL_API_KEY!, // beel_sk_test_… in sandbox, beel_sk_live_… in production
  // baseUrl: process.env.MY_API_URL, // only for another environment or a proxy
  // Defaults: 3 automatic retries of 429, 5xx and network errors, reusing the call's
  // Idempotency-Key, so a retry never creates a second invoice.
});

// Every invoice call on this page passes it: no request can hang the integration.
export const REQUEST_TIMEOUT_MS = 20_000;
export const CALL: RequestOptions = { timeoutMs: REQUEST_TIMEOUT_MS }; // automatic retries included

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.

Invoices belong to a company (one NIF). Look its id up once and store it in your configuration, for example as BEEL_COMPANY_ID:

client.ts
// Once, at setup time: store the id in your configuration afterwards.
export async function findCompanyId(nif: string): Promise<string> {
  const { account_id } = await beel.catalogs.identity();
  const { companies } = await beel.account(account_id).companies.list({ search: nif });
  const company = companies.find((c) => c.nif === nif);
  if (!company?.id) throw new Error(`No company with NIF ${nif} in this account`);
  return company.id;
}
client.ts
export const company = beel.company(process.env.BEEL_COMPANY_ID!);

If you work through the MCP server, beel_get_setup_status answers the same question and more in one call: for each company of the account, its company_id, nif and legal_name, whether it is ready to issue, the blockers and the default series still missing, its VeriFactu and payment-connection state, and a next_action to fix what is missing. From code, company.issuingReadiness() gives the readiness of one company.

The snippets below share these types, all generated from the API contract — you do not need to open the SDK's type definitions:

types.ts
import type {
  CompanyInvoicesResource,
  CompanyTaxConfigurationResource,
  CreateInvoiceOnceRequest,
  components,
} from '@beel_es/sdk';

type Schemas = components['schemas'];

// Requests and responses, generated from the OpenAPI contract.
export type Invoice = Schemas['Invoice'];
export type CreateInvoiceRequest = Schemas['CreateInvoiceRequest'];
export type OrderInvoice = CreateInvoiceOnceRequest; // STANDARD or SIMPLIFIED, external_ref required
export type InvoiceLine = CreateInvoiceRequest['lines'][number];
export type CreateCorrectiveInvoiceRequest = Schemas['CreateCorrectiveInvoiceRequest'];
export type ProcessingOptions = NonNullable<CreateInvoiceRequest['options']>;

// The recipient and how a foreign customer is identified.
export type Recipient = CreateInvoiceRequest['recipient']; // customer_id alone, or the data inline
export type AlternativeId = NonNullable<Schemas['AlternativeIdentifier']>; // { type, number, country_code? }
export type AlternativeIdType = AlternativeId['type']; // 'NIF_IVA' | 'OTHER_DOCUMENT' | 'PASSPORT' | …

// A line's taxes.
export type MainTax = Schemas['TaxInfo']; // { type: 'IVA' | …, percentage, regime_key? }
export type ExemptionReason = Schemas['ExemptionReason']; // 'EXENTA_ART_25' | 'NO_SUJETA_LOCALIZACION' | …
export type IrpfRate = Schemas['IrpfPercentage']; // 0 | 1 | 2 | … | 15 | 19 | 24

// Statuses and correctives.
export type InvoiceType = Schemas['InvoiceType']; // 'STANDARD' | 'SIMPLIFIED' | 'CORRECTIVE' | 'PROFORMA'
export type InvoiceStatus = Schemas['InvoiceStatus']; // 'DRAFT' | 'ISSUED' | … | 'RECTIFIED' | 'VOIDED'
export type SubmissionStatus = Schemas['VeriFactuSubmissionStatus']; // 'NOT_SUBMITTED' | 'PENDING' | …
export type RectificationType = Schemas['RectificationType']; // 'TOTAL' | 'PARTIAL'
export type RectificationCode = Schemas['VeriFactuRectificationCode']; // 'R1' … 'R5'

// What the SDK methods return.
export type InvoiceListQuery = NonNullable<Parameters<CompanyInvoicesResource['list']>[0]>;
export type InvoiceList = Awaited<ReturnType<CompanyInvoicesResource['list']>>; // { invoices, pagination }
export type Pagination = Schemas['Pagination']; // { current_page, total_pages, total_items, … }
// company.taxConfiguration.get(): default_irpf_rate, withholding_options.allowed_irpf_rates, …
export type TaxConfiguration = NonNullable<Awaited<ReturnType<CompanyTaxConfigurationResource['get']>>>;

The values this page's cases use. Each type above is the full union; your editor lists its values, and Create invoice in the API reference describes each one.

FieldValues used hereFull list
typeSTANDARD, SIMPLIFIEDInvoiceType
lines[].main_tax{ type: 'IVA', percentage: 21 | 10 | 4 | 0, regime_key: '01' }MainTax
lines[].exemption_reasonEXENTA_ART_25 (goods to an EU business), NO_SUJETA_LOCALIZACION (services abroad), EXENTA_ART_21 (exports of goods)ExemptionReason
lines[].irpf_rate0 (no withholding), 15 (professionals), 7 (professionals, first three years); accepted: 0, 1, 2, 2.8, 6, 7, 7.6, 9.5, 15, 19 or 24IrpfRate
recipient.alternative_id.typeNIF_IVA (EU VAT number), OTHER_DOCUMENT (any other foreign id)AlternativeIdType
rectification_typeTOTAL, PARTIALRectificationType
rectification_codeR1 (standard invoice), R5 (simplified invoice)RectificationCode
verifactu.submission_statusNOT_SUBMITTED, PENDING, ACCEPTED, REJECTED, VOIDEDSubmissionStatus

And one set of processing options for every create. The other options default to false, so they are left out:

types.ts
// Issue at once. Every other option defaults to false: no email is sent.
export const ISSUE_NOW: ProcessingOptions = { issue_directly: true };

issue_directly: true creates the invoice already issued: numbered, registered with AEAT and with its PDF. Nothing is emailed unless send_automatically is true — see Sending email.

Issue one invoice per order

Send your order id as external_ref and create the invoice with createOnce. BeeL. keeps at most one standard or simplified invoice per external_ref (a second create is rejected with 409 INVOICE_DUPLICATE_EXTERNAL_REFERENCE), and createOnce builds on it: it looks the reference up first, creates the invoice only if nothing holds it, and when the create fails with that 409 or with an unknown outcome (network error, timeout, 5xx), it returns the invoice that does exist. Calling it again after any error, or rerunning the whole batch, is safe.

issue-once.ts
import { company, CALL } from './client';
import type { Invoice, OrderInvoice } from './types';

/**
 * The invoice of one order, created at most once: `external_ref` is the order id.
 * Safe to call again after any error, and on every rerun of the batch.
 * The invoice returned may be one created earlier, VOIDED included: check its status.
 */
export function invoiceOrder(body: OrderInvoice): Promise<Invoice> {
  return company.invoices.createOnce(body, undefined, CALL);
}

What happens underneath (see Idempotency):

SituationWhat the SDK does
429Waits Retry-After and retries
Network error, timeout or 5xx on the createRetries with the same Idempotency-Key, so a create that did go through is replayed, not repeated; then createOnce looks the reference up
409 INVOICE_DUPLICATE_EXTERNAL_REFERENCEReturns the invoice that holds the reference
Any other 4xxThrows: fix the request. apiCode carries the API's code, which names the fiscal rule

When the error is yours to fix, read apiCode:

issue-once.ts
import { BeeLApiError } from '@beel_es/sdk';

/** What to tell the shop when an order cannot be invoiced. */
export function explain(err: unknown): string {
  if (!(err instanceof BeeLApiError)) return 'No answer from BeeL.: try again later';
  // apiCode is the API's own error code; each fiscal one names the rule it enforces
  switch (err.apiCode) {
    case 'SIMPLIFIED_INVOICE_EXCEEDS_LEGAL_LIMIT':
      return 'Too large for a ticket: ask the customer for their fiscal data';
    case 'SIMPLIFICADA_FORBIDS_CROSS_BORDER':
      return 'A cross-border operation needs a standard invoice';
    default:
      return `${err.statusCode} ${err.apiCode}: ${err.message}`;
  }
}

A reference stays bound after a void. createOnce returns the order's invoice whatever its status, VOIDED included: a voided invoice still holds its external_ref. Only deleting a draft frees a reference. So a rerun after a full refund returns the voided invoice and creates nothing — check its status. To invoice again an order whose invoice was voided by mistake, send a new reference, such as A-1001-2.

Corrective invoices are not covered. They may carry the reference of the invoice they correct, so createOnce does not create them. Give each one its own external_ref (the id of the return) and look it up before creating, as the refund snippets do:

issue-once.ts
/**
 * A corrective created earlier for this return, if any. createOnce does not create
 * correctives: give each one its own external_ref (the return id) and look it up first.
 */
export async function findCorrective(returnId: string): Promise<Invoice | undefined> {
  const { invoices } = await company.invoices.list({ external_ref: returnId }, CALL);
  return invoices.find((invoice) => invoice.type === 'CORRECTIVE');
}

Business customer in Spain

A business customer with a NIF gets a standard invoice (STANDARD). Send the recipient inline with its legal name, NIF and fiscal address, or register it once as a customer and send only customer_id:

standard-business.ts
// A Spanish business: legal name, NIF and fiscal address, sent inline.
// (A registered customer goes as `recipient: { customer_id }`, alone.)
const acme: Recipient = {
  legal_name: 'Acme Distribuciones SL',
  nif: 'B12345674',
  address: {
    street: 'Calle Mayor 1',
    postal_code: '28001',
    city: 'Madrid',
    province: 'Madrid',
    country_code: 'ES',
  },
  email: 'billing@acme.example',
};

Send the prices the way your shop stores them. With VAT included, send the line total the customer paid as total_including_tax; BeeL. works the base and VAT backwards so they add up to that total exactly:

standard-business.ts
// Your shop stores prices with VAT included: send what the customer paid per line.
export const vatIncluded: OrderInvoice = {
  type: 'STANDARD',
  external_ref: 'A-1001',
  recipient: acme,
  lines: [
    {
      description: 'Espresso machine',
      quantity: 2,
      total_including_tax: 605, // 2 × 302.50, VAT included
      main_tax: { type: 'IVA', percentage: 21, regime_key: '01' },
      irpf_rate: 0, // omitted, the line takes the company's default IRPF rate
    },
  ],
  options: ISSUE_NOW,
};

With net prices, send unit_price without VAT and BeeL. computes the rest:

standard-business.ts
// Your shop stores net prices: send the unit price without VAT.
export const netPrice: OrderInvoice = {
  type: 'STANDARD',
  external_ref: 'A-1002',
  recipient: acme,
  lines: [
    {
      description: 'Espresso machine',
      quantity: 2,
      unit_price: 250, // without VAT; BeeL. computes base, VAT and totals
      main_tax: { type: 'IVA', percentage: 21, regime_key: '01' },
      irpf_rate: 0,
    },
  ],
  options: ISSUE_NOW,
};

A line carries exactly one of unit_price, total_excluding_tax or total_including_tax — see Three ways to price a line. Send irpf_rate: 0 on standard lines without withholding: a line without it takes the company's default IRPF rate.

Consumer ticket

A consumer who gives no fiscal data gets a simplified invoice (SIMPLIFIED, a ticket), with an empty recipient. Each line has its own VAT rate:

simplified-ticket.ts
// A consumer who gave no fiscal data: a simplified invoice (ticket), several VAT rates.
export const ticket: OrderInvoice = {
  type: 'SIMPLIFIED',
  external_ref: 'T-2001',
  recipient: {}, // no NIF and no alternative_id: an identified recipient needs STANDARD
  lines: [
    {
      description: 'Ground coffee 1 kg',
      quantity: 2,
      total_including_tax: 26.4,
      main_tax: { type: 'IVA', percentage: 10, regime_key: '01' },
    },
    {
      description: 'Milk frother',
      quantity: 1,
      total_including_tax: 48.4,
      main_tax: { type: 'IVA', percentage: 21, regime_key: '01' },
    },
  ],
  options: ISSUE_NOW,
};

Two limits decide whether an order can be a ticket at all:

Check both before sending. Above a limit, do not issue anything: ask the customer for their name, NIF and address, and issue a standard invoice once you have them.

simplified-ticket.ts
const SIMPLIFIED_CAP = 3000; // SIM-001: never above, whatever the activity
const SIMPLIFIED_GENERAL_LIMIT = 400; // SIM-002: above it, only art. 4.2 activities (retail…)

type TicketDecision =
  | { issue: 'SIMPLIFIED' }
  | { issue: 'NONE'; reason: string; ask_customer_for: string[] };

/** Decide before sending: can this order be a ticket, or do you need the customer's data? */
export function ticketDecision(totalWithVat: number, art42Activity: boolean): TicketDecision {
  const needsData = { issue: 'NONE' as const, ask_customer_for: ['legal name', 'NIF', 'fiscal address'] };
  if (totalWithVat > SIMPLIFIED_CAP) {
    return { ...needsData, reason: `Total ${totalWithVat} € is above the simplified-invoice cap` };
  }
  if (totalWithVat > SIMPLIFIED_GENERAL_LIMIT && !art42Activity) {
    return { ...needsData, reason: `Total ${totalWithVat} € needs an art. 4.2 activity to go on a ticket` };
  }
  return { issue: 'SIMPLIFIED' };
}

A ticket carries no NIF and no alternative_id: an identified recipient needs a standard invoice. A customer who asks for a full invoice after getting the ticket gets an exchange, not a corrective.

Operation date

When the goods were delivered or the service provided on a day other than the day you invoice, send that day as operation_date (DAT-004 Send the operation date when it differs from the issue date). The issue date is not an input: BeeL. sets it when the invoice is issued. operation_date is today or a past date.

operation-date.ts
// Service done on 23 September, paid and invoiced on 28 September.
export const lateInvoice: OrderInvoice = {
  type: 'STANDARD',
  external_ref: 'A-1003',
  operation_date: '2026-09-23', // the day of the service; issue_date is set by BeeL. when issued
  recipient: { customer_id: '4f244735-980b-8d9c-80e8-6331fa0b1958' },
  lines: [
    {
      description: 'Installation of the espresso machine',
      quantity: 1,
      unit_price: 90,
      main_tax: { type: 'IVA', percentage: 21, regime_key: '01' },
      irpf_rate: 0,
    },
  ],
  options: ISSUE_NOW,
};

Details in Dates.

Foreign currency

The API has no currency field: every amount is read as euros (TAX-013 Send every amount in euros). Convert with your rate before building the lines, and mention the original amount and rate in notes if you want them on the invoice:

foreign-currency.ts
// Paid 54.00 USD (VAT included). The API reads every amount as euros: convert first.
const paidUsd = 54;
const eurPerUsd = 0.92; // your rate for the day of the operation

const paidEur = Math.round(paidUsd * eurPerUsd * 100) / 100; // 49.68

export const usdOrder: OrderInvoice = {
  type: 'SIMPLIFIED',
  external_ref: 'U-3001',
  recipient: {},
  lines: [
    {
      description: 'Coffee subscription, 3 months',
      quantity: 1,
      total_including_tax: paidEur,
      main_tax: { type: 'IVA', percentage: 10, regime_key: '01' },
    },
  ],
  notes: `Paid ${paidUsd.toFixed(2)} USD. Exchange rate applied: ${eurPerUsd} EUR/USD.`,
  options: ISSUE_NOW,
};

Goods to an EU business

Goods shipped to a business in another Member State that gave you its EU VAT number are exempt under art. 25 LIVA (TAX-004 Intra-EU supplies of goods are exempt only with the buyer's EU VAT number). Identify the buyer with alternative_id of type NIF_IVA (the country prefix and the number), and send each line at 0 % with exemption_reason: 'EXENTA_ART_25'. Without that identifier the line is rejected with 400 EXEMPTION_REQUIRES_RECIPIENT_ID_TYPE.

intra-eu-goods.ts
// Goods shipped to a business in Ireland that gave its EU VAT number.
export const intraEuGoods: OrderInvoice = {
  type: 'STANDARD', // never SIMPLIFIED: rejected with SIMPLIFICADA_FORBIDS_CROSS_BORDER
  external_ref: 'E-4001',
  recipient: {
    legal_name: 'Example Trading Ltd',
    alternative_id: { type: 'NIF_IVA', number: 'IE6388047V', country_code: 'IE' },
    address: {
      street: 'Barrow Street 4',
      postal_code: 'D04 E5W5',
      city: 'Dublin',
      province: 'Dublin',
      country_code: 'IE',
    },
  },
  lines: [
    {
      description: 'Espresso machine (shipped from Madrid)',
      quantity: 10,
      unit_price: 250, // exempt: base = price, no VAT
      main_tax: { type: 'IVA', percentage: 0, regime_key: '01' },
      exemption_reason: 'EXENTA_ART_25', // intra-EU supply of goods (E5)
      irpf_rate: 0,
    },
  ],
  options: ISSUE_NOW,
};

It is always a standard invoice: a simplified one is rejected with 400 SIMPLIFICADA_FORBIDS_CROSS_BORDER (SIM-003 Some operations can never go on a simplified invoice). Check the number in VIES first: a well-formed number that VIES does not know is accepted by the API and rejected by AEAT later. Every other cross-border combination is in International customers.

Services outside the EU

A service to a business established outside the EU is located where the customer is, so it is not subject to Spanish VAT (TAX-006 Services to a business abroad are not subject to Spanish VAT). Send each line at 0 % with exemption_reason: 'NO_SUJETA_LOCALIZACION', and identify the customer with alternative_id of type OTHER_DOCUMENT and its country:

services-outside-eu.ts
// Remote support for a business in the United States (identified by its EIN).
export const nonEuServices: OrderInvoice = {
  type: 'STANDARD',
  external_ref: 'S-4003',
  recipient: {
    legal_name: 'Example Robotics Inc.',
    alternative_id: { type: 'OTHER_DOCUMENT', number: '12-3456789', country_code: 'US' },
    address: {
      street: '500 Market Street',
      postal_code: '94105',
      city: 'San Francisco',
      province: 'California',
      country_code: 'US',
    },
  },
  lines: [
    {
      description: 'Remote technical support, September',
      quantity: 1,
      unit_price: 1200,
      main_tax: { type: 'IVA', percentage: 0, regime_key: '01' },
      exemption_reason: 'NO_SUJETA_LOCALIZACION', // not subject: place of supply (N2)
      irpf_rate: 0,
    },
  ],
  options: ISSUE_NOW,
};

Goods exported outside the EU are different: EXENTA_ART_21 with regime_key: '02' — see Exports of goods.

IRPF withholding

When a self-employed professional invoices a business in Spain, the customer usually withholds IRPF from the payment. Set irpf_rate on each line (TAX-010 Set the IRPF withholding when the customer must withhold):

irpf-withholding.ts
// A self-employed professional invoices consulting to a Spanish business,
// which withholds IRPF from the payment.
export const consulting: OrderInvoice = {
  type: 'STANDARD',
  external_ref: 'C-4002',
  recipient: { customer_id: '4f244735-980b-8d9c-80e8-6331fa0b1958' },
  lines: [
    {
      description: 'Consulting, 10 hours',
      quantity: 10,
      unit_price: 60,
      main_tax: { type: 'IVA', percentage: 21, regime_key: '01' },
      irpf_rate: 15, // per line; 0 on lines without withholding
    },
  ],
  options: ISSUE_NOW,
};
// Base 600.00 + VAT 126.00 = total 726.00 (the total AEAT receives).
// Withholding 90.00 → the customer pays 636.00.

The invoice shows the withholding and takes it off the amount payable, but the total AEAT receives is the total before it (TAX-009 IRPF withholding is not part of the total AEAT receives). A simplified invoice never carries a withholding: any irpf_rate other than 0 on a ticket is rejected with 422 SIMPLIFICADA_FORBIDS_IRPF. The company's default_irpf_rate is the rate a line gets when it omits the field, and withholding_options.allowed_irpf_rates lists the rates this issuer may use:

client.ts
// The IRPF rate a standard line gets when it omits irpf_rate, and the rates this issuer may use.
export async function irpfDefaults() {
  const config = await company.taxConfiguration.get();
  return {
    defaultRate: config?.default_irpf_rate, // undefined: the company has none
    allowed: config?.withholding_options?.allowed_irpf_rates ?? [],
  };
}

Discounts and coupons

Send a coupon as discount_percentage, not folded into the price (CNT-007 Each line describes the operation, its unit price and any discount). A declared total already includes any discount, so total_including_tax takes none (422 LINE_DECLARED_TOTAL_FORBIDS_DISCOUNT): for a coupon on a VAT-included price, send the net unit price (up to 4 decimals) and the discount:

discount-coupon.ts
// A 10 % coupon on a product priced 24.20 € with 21 % VAT included.
// A declared total takes no discount, so send the net unit price and the coupon apart.
const priceWithVat = 24.2;
const vatRate = 21;
const unitPrice = Math.round((priceWithVat / (1 + vatRate / 100)) * 10_000) / 10_000; // 20.0000

export const withCoupon: OrderInvoice = {
  type: 'SIMPLIFIED',
  external_ref: 'P-4006',
  recipient: {},
  lines: [
    {
      description: 'Coffee grinder',
      quantity: 1,
      unit_price: unitPrice, // up to 4 decimals
      discount_percentage: 10, // coupon WELCOME10
      main_tax: { type: 'IVA', percentage: vatRate, regime_key: '01' },
    },
  ],
  options: ISSUE_NOW,
};
// Base 18.00 + VAT 3.78 = 21.78, the 24.20 price less 10 %.

Full refund

The customer returned the whole order. The sale did happen, so the invoice is corrected, not voided: voiding is only for an invoice that should never have been issued (VOI-001 Void only an invoice that should never have been issued). Issue a TOTAL corrective invoice, with no lines — it takes back everything still invoiced — and the reason code for the invoice type: R1 for a standard invoice, R5 for a simplified one (COR-002 Pick the reason code: R1–R4 for standard invoices, R5 for simplified):

full-refund.ts
// The customer returned the whole order A-1001: a TOTAL corrective, never a void.
export async function refundInFull(original: Invoice, returnId: string): Promise<Invoice> {
  const existing = await findCorrective(returnId);
  if (existing) return existing; // this return was already refunded

  const body: CreateCorrectiveInvoiceRequest = {
    rectification_type: 'TOTAL', // no `lines`: it takes back everything still invoiced
    rectification_code: original.type === 'SIMPLIFIED' ? 'R5' : 'R1',
    reason: 'Goods returned by the customer, order cancelled',
    circumstance_date: '2026-09-27', // the day of the return
    external_ref: returnId, // the id of the return, to find it again
    options: ISSUE_NOW,
  };
  return company.invoices.createCorrective(original.id, body, CALL);
}
// The original becomes VOIDED with void_cause TOTAL_CORRECTIVE; its record stays registered.

The corrective is a new invoice with its own number, in the company's corrective series. It carries the return's id as external_ref, so a rerun finds it instead of refunding twice. The original becomes VOIDED and keeps its external_ref: createOnce does not invoice the order again on a rerun (see Issue one invoice per order). circumstance_date is the day of the return; which code fits which cause is in Corrective invoices.

Partial refund of a ticket

Some items of a ticket came back. Issue a PARTIAL corrective with R5 — a simplified invoice is always corrected with R5 (COR-002 Pick the reason code: R1–R4 for standard invoices, R5 for simplified) — and one line per returned item, with a negative quantity and the unit price and tax of the original line:

partial-refund-simplified.ts
// One item of ticket T-2001 came back: an R5 PARTIAL corrective with the returned lines.
export async function refundLines(
  ticket: Invoice,
  returned: { description: string; quantity: number }[],
  returnId: string,
): Promise<Invoice> {
  const existing = await findCorrective(returnId);
  if (existing) return existing; // this return was already refunded

  const lines: CreateCorrectiveInvoiceRequest['lines'] = returned.map((item) => {
    const line = ticket.lines?.find((l) => l.description === item.description);
    if (!line) throw new Error(`"${item.description}" is not on ${ticket.invoice_number}`);
    return {
      description: `Return - ${item.description}`,
      quantity: -Math.abs(item.quantity), // negative: amounts taken back
      unit_price: line.unit_price, // the net unit price the ticket recorded
      discount_percentage: line.discount_percentage,
      main_tax: line.main_tax, // same rate as the original line
    };
  });

  const body: CreateCorrectiveInvoiceRequest = {
    rectification_type: 'PARTIAL',
    rectification_code: 'R5', // a simplified invoice is always corrected with R5
    reason: 'The customer returned one item of the ticket',
    circumstance_date: '2026-09-28',
    lines,
    external_ref: returnId,
    options: ISSUE_NOW,
  };
  return company.invoices.createCorrective(ticket.id, body, CALL);
}

The original stays valid and becomes RECTIFIED. A partial corrective of a standard invoice has the same shape, with R1.

Waiting for AEAT

Issuing returns at once; the registration with AEAT follows asynchronously. verifactu.submission_status on the invoice tells where it is (REC-008 Follow submission_status and fix what AEAT rejects):

NOT_SUBMITTED ──► PENDING ──► ACCEPTED
 (just issued)   (queued)  └► REJECTED

NOT_SUBMITTED right after issuing is normal: the submission is still on its way. AEAT can take a minute or two to answer, in the sandbox too, so never wait order by order: issue every order first, then wait for the whole batch together, with one deadline.

batch.ts
/** Issue every order first, then wait for AEAT on all of them together. */
export async function invoiceOrders(orderIds: string[]) {
  const issued: Invoice[] = [];
  const failed: { orderId: string; error: unknown }[] = [];

  for (const orderId of orderIds) {
    try {
      issued.push(await invoiceOrder(buildInvoice(orderId)));
    } catch (error) {
      failed.push({ orderId, error }); // see explain(): the other orders go on
    }
  }

  const { settled, pending } = await waitForAeat(issued);
  return {
    accepted: settled.filter((i) => i.verifactu?.submission_status === 'ACCEPTED'),
    rejected: settled.filter((i) => i.verifactu?.submission_status === 'REJECTED'), // read error_code / error_message
    notRegistered: settled.filter((i) => !i.verifactu?.enabled), // VeriFactu off for this NIF
    pending, // still NOT_SUBMITTED or PENDING: the verifactu.status.updated webhook reports them
    failed,
  };
}

waitForAeat costs two list calls per tick, whatever the size of the batch: it lists the invoices still NOT_SUBMITTED or PENDING (the list filters by verifactu_status, one status per call, and by issue date), and reads once each invoice that has left those lists. The deadline and the interval are the two constants at the top:

wait-for-aeat.ts
export const AEAT_DEADLINE_MS = 120_000; // how long this run waits for AEAT, for the whole batch
export const AEAT_POLL_INTERVAL_MS = 5_000; // one round of list calls per tick

const WAITING: SubmissionStatus[] = ['NOT_SUBMITTED', 'PENDING'];

/**
 * Waits for AEAT on a whole batch at once, with one deadline. Returns the invoices
 * AEAT answered for (ACCEPTED or REJECTED) and the ones still waiting when the
 * deadline passed — those are not errors: the webhook will report them.
 */
export async function waitForAeat(issued: Invoice[]): Promise<{ settled: Invoice[]; pending: Invoice[] }> {
  const settled: Invoice[] = issued.filter((invoice) => !isWaiting(invoice));
  const pending = new Map(issued.filter(isWaiting).map((invoice) => [invoice.id, invoice]));
  const until = Date.now() + AEAT_DEADLINE_MS;
  const since = earliestIssueDate([...pending.values()]);

  while (pending.size > 0 && Date.now() < until) {
    await sleep(AEAT_POLL_INTERVAL_MS);
    // Two list calls per tick, whatever the batch size: what is still waiting since `since`.
    const stillWaiting = new Set<string>();
    for (const status of WAITING) {
      for (const invoice of await listAll(status, since)) stillWaiting.add(invoice.id);
    }
    // Whatever left those lists has an answer: read it once.
    for (const id of [...pending.keys()].filter((id) => !stillWaiting.has(id))) {
      const invoice = await company.invoices.get(id, CALL);
      if (isWaiting(invoice)) continue; // listed a moment before it moved
      settled.push(invoice);
      pending.delete(id);
    }
  }
  return { settled, pending: [...pending.values()] };
}

function isWaiting(invoice: Invoice): boolean {
  // No verifactu block, or enabled = false: never registered, nothing to wait for.
  if (!invoice.verifactu?.enabled) return false;
  const status = invoice.verifactu.submission_status;
  return status === undefined || WAITING.includes(status);
}

async function listAll(status: SubmissionStatus, since: string | undefined): Promise<Invoice[]> {
  const out: Invoice[] = [];
  for (let page = 1; ; page++) {
    const { invoices, pagination } = await company.invoices.list(
      { verifactu_status: status, date_from: since, limit: 100, page },
      CALL,
    );
    out.push(...invoices);
    if (page >= pagination.total_pages) return out;
  }
}

function earliestIssueDate(invoices: Invoice[]): string | undefined {
  const dates = invoices.map((invoice) => invoice.issue_date).filter((d): d is string => !!d);
  return dates.sort()[0];
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

What is still pending at the deadline is not a failure: report it as pending and move on. In production, do not wait in the request at all. Subscribe to the verifactu.status.updated webhook, which fires when an invoice leaves PENDING, and run a periodic reconciliation for the ones that stay PENDING or NOT_SUBMITTED — see Reconcile, do not only listen. A REJECTED invoice is not registered: read its error_code and error_message and fix it (Handling AEAT rejections). What each status means is in Submission states.

Gotchas

  • An omitted irpf_rate is not 0. On a standard invoice it takes the company's default rate. Send irpf_rate: 0 on every line without withholding.
  • Do not hand-write the HTTP calls. The SDK's createOnce, typed bodies and key-reusing retries are what make a rerun safe; a fetch loop has to rebuild all three.
  • external_ref is the order id, not the idempotency key. It stays bound to the invoice once issued, voided included; only deleting a draft frees it.
  • Pass a timeout on every call. { timeoutMs } as the last argument of the company.invoices methods; without it a request that never answers waits forever.
  • Corrective invoices are not covered by the one-invoice-per-reference rule. Give each one its own external_ref (the id of the return) and look it up before creating it, as the refund snippets do.
  • A 0 % IVA line needs an exemption_reason. Without one it is rejected; see International customers.