# 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](/sdks/node) (`@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 order | Section | Rules |
|---|---|---|
| Any order, retried safely | [Issue one invoice per order](#issue-once) | [LIF-004 · Retry writes with the same Idempotency-Key](/rules/lifecycle#lif-004) |
| Business in Spain with NIF | [Business customer in Spain](#standard-invoice) | [CNT-007 · Each line describes the operation, its unit price and any discount](/rules/contents#cnt-007) |
| Consumer, no fiscal data | [Consumer ticket](#simplified-invoice) | [SIM-001 · A simplified invoice never exceeds 3,000 €](/rules/simplified#sim-001), [SIM-002 · Above 400 €, a simplified invoice needs an art. 4.2 activity](/rules/simplified#sim-002) |
| Delivered or provided on another day | [Operation date](#operation-date) | [DAT-004 · Send the operation date when it differs from the issue date](/rules/dates#dat-004) |
| Paid in another currency | [Foreign currency](#foreign-currency) | [TAX-013 · Send every amount in euros](/rules/taxes#tax-013) |
| Goods to an EU business with a VAT number | [Goods to an EU business](#intra-eu-goods) | [TAX-004 · Intra-EU supplies of goods are exempt only with the buyer's EU VAT number](/rules/taxes#tax-004), [SIM-003 · Some operations can never go on a simplified invoice](/rules/simplified#sim-003) |
| Services to a business outside the EU | [Services outside the EU](#services-outside-eu) | [TAX-006 · Services to a business abroad are not subject to Spanish VAT](/rules/taxes#tax-006) |
| Professional services with withholding | [IRPF withholding](#irpf-withholding) | [TAX-009 · IRPF withholding is not part of the total AEAT receives](/rules/taxes#tax-009), [TAX-010 · Set the IRPF withholding when the customer must withhold](/rules/taxes#tax-010) |
| Coupon or discount | [Discounts and coupons](#discounts) | [CNT-007 · Each line describes the operation, its unit price and any discount](/rules/contents#cnt-007) |
| Whole order returned | [Full refund](#full-refund) | [COR-002 · Pick the reason code: R1–R4 for standard invoices, R5 for simplified](/rules/corrective#cor-002), [VOI-001 · Void only an invoice that should never have been issued](/rules/void#voi-001) |
| Some items of a ticket returned | [Partial refund of a ticket](#partial-refund-simplified) | [COR-002 · Pick the reason code: R1–R4 for standard invoices, R5 for simplified](/rules/corrective#cor-002) |
| Knowing AEAT accepted it | [Waiting for AEAT](#aeat-outcome) | [REC-008 · Follow submission_status and fix what AEAT rejects](/rules/records#rec-008) |

## Before you start [#setup]

**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.

```bash
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.

```ts title="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`:

```ts title="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;
}
```

```ts title="client.ts"
export const company = beel.company(process.env.BEEL_COMPANY_ID!);
```

If you work through the [MCP server](/mcp), `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:

```ts title="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](/invoices/createCompanyInvoice) in the API reference describes each one.

| Field | Values used here | Full list |
|---|---|---|
| `type` | `STANDARD`, `SIMPLIFIED` | `InvoiceType` |
| `lines[].main_tax` | `{ type: 'IVA', percentage: 21 \| 10 \| 4 \| 0, regime_key: '01' }` | `MainTax` |
| `lines[].exemption_reason` | `EXENTA_ART_25` (goods to an EU business), `NO_SUJETA_LOCALIZACION` (services abroad), `EXENTA_ART_21` (exports of goods) | `ExemptionReason` |
| `lines[].irpf_rate` | `0` (no withholding), `15` (professionals), `7` (professionals, first three years); accepted: 0, 1, 2, 2.8, 6, 7, 7.6, 9.5, 15, 19 or 24 | `IrpfRate` |
| `recipient.alternative_id.type` | `NIF_IVA` (EU VAT number), `OTHER_DOCUMENT` (any other foreign id) | `AlternativeIdType` |
| `rectification_type` | `TOTAL`, `PARTIAL` | `RectificationType` |
| `rectification_code` | `R1` (standard invoice), `R5` (simplified invoice) | `RectificationCode` |
| `verifactu.submission_status` | `NOT_SUBMITTED`, `PENDING`, `ACCEPTED`, `REJECTED`, `VOIDED` | `SubmissionStatus` |

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

```ts title="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](/guides/sending-email).

## Issue one invoice per order [#issue-once]

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`](/errors/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.

```ts title="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](/guides/idempotency)):

| Situation | What the SDK does |
|---|---|
| `429` | Waits `Retry-After` and retries |
| Network error, timeout or `5xx` on the create | Retries 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_REFERENCE`](/errors/INVOICE_DUPLICATE_EXTERNAL_REFERENCE) | Returns the invoice that holds the reference |
| Any other `4xx` | Throws: fix the request. `apiCode` carries the API's code, which names the fiscal rule |

When the error is yours to fix, read `apiCode`:

```ts title="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](#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:

```ts title="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 [#standard-invoice]

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`:

```ts title="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:

```ts title="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:

```ts title="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](/guides/amounts-and-rounding#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 [#simplified-invoice]

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:

```ts title="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:

- Above 3,000 €, VAT included, never: [SIM-001 · A simplified invoice never exceeds 3,000 €](/rules/simplified#sim-001). The API rejects it with `400` [`SIMPLIFIED_INVOICE_EXCEEDS_LEGAL_LIMIT`](/errors/SIMPLIFIED_INVOICE_EXCEEDS_LEGAL_LIMIT).
- Above 400 €, only for the activities of art. 4.2 of RD 1619/2012, such as retail sales and hospitality: [SIM-002 · Above 400 €, a simplified invoice needs an art. 4.2 activity](/rules/simplified#sim-002). That one is yours to check.

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](#standard-invoice) once you have them.

```ts title="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](/verifactu/simplified-vs-standard#upgrading-an-f2-to-f1-the-canje-case), not a corrective.

## Operation date [#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](/rules/dates#dat-004)). The issue date is not an input: BeeL. sets it when the invoice is issued. `operation_date` is today or a past date.

```ts title="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](/guides/invoice-lifecycle#dates).

## Foreign currency [#foreign-currency]

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

```ts title="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 [#intra-eu-goods]

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](/rules/taxes#tax-004)). 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`](/errors/EXEMPTION_REQUIRES_RECIPIENT_ID_TYPE).

```ts title="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`](/errors/SIMPLIFICADA_FORBIDS_CROSS_BORDER) ([SIM-003 · Some operations can never go on a simplified invoice](/rules/simplified#sim-003)). 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](/verifactu/international-customers).

## Services outside the EU [#services-outside-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](/rules/taxes#tax-006)). 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:

```ts title="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](/verifactu/international-customers#exports-of-goods-non-eu).

## IRPF withholding [#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](/rules/taxes#tax-010)):

```ts title="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](/rules/taxes#tax-009)). A simplified invoice never carries a withholding: any `irpf_rate` other than 0 on a ticket is rejected with `422` [`SIMPLIFICADA_FORBIDS_IRPF`](/errors/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:

```ts title="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 [#discounts]

Send a coupon as `discount_percentage`, not folded into the price ([CNT-007 · Each line describes the operation, its unit price and any discount](/rules/contents#cnt-007)). A declared total already includes any discount, so `total_including_tax` takes none (`422` [`LINE_DECLARED_TOTAL_FORBIDS_DISCOUNT`](/errors/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:

```ts title="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 [#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](/rules/void#voi-001)). 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](/rules/corrective#cor-002)):

```ts title="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](#issue-once)). `circumstance_date` is the day of the return; which code fits which cause is in [Corrective invoices](/verifactu/corrective-invoices#pick-the-reason-r1r5).

## Partial refund of a ticket [#partial-refund-simplified]

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](/rules/corrective#cor-002)) — and one line per returned item, with a negative quantity and the unit price and tax of the original line:

```ts title="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 [#aeat-outcome]

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](/rules/records#rec-008)):

```text
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.

```ts title="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:

```ts title="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](/webhooks/events), 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](/verifactu/handling-rejections#reconcile-do-not-only-listen). A `REJECTED` invoice is not registered: read its `error_code` and `error_message` and fix it ([Handling AEAT rejections](/verifactu/handling-rejections)). What each status means is in [Submission states](/verifactu/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](/verifactu/international-customers).

## Related

<Related>

- [Idempotency](/guides/idempotency) — how keys and `external_ref` work together
- [Node.js SDK](/sdks/node) — every resource and method
- [Simplified vs standard](/verifactu/simplified-vs-standard) — which invoice an order needs
- [Corrective invoices](/verifactu/corrective-invoices) — every R1–R5 case

</Related>

---

Full OpenAPI spec: https://docs.beel.es/api/openapi