Factuarea APIDevelopers

Bulk import of managed accounts

Preview and apply a CSV of clients in one go: columns, modes, row statuses, limits and what a re-import does.

The bulk import loads many clients at once from a CSV file. Each row is one of two things, chosen by its mode column: a managed account, a separate account that you provision for a client, or an own tax ID, a tax ID that you add to your own account as an additional tax ID (the app shows it as "Tax ID in my account"). You first send the file to a preview, which classifies and prices every row without writing or charging anything, and then you send the same file to apply it. This guide is written for the account that provisions, the provider of managed accounts. It covers the three operations, the file and its columns, how the parser reads it, the limits, the row statuses, the preview and its cost, what a second import of the same file does, the customers you can load along with it, the apply and its idempotency, and what a test key does.

The import is declarative and not atomic: the file says what should exist, every row that can be applied is applied on its own, and a row that fails never undoes the others. It has no MCP tool and no option for document series, as the end of this guide explains.

The three operations

OperationScopeIdempotency-KeyWhat it does
GET /v1/accounts/imports/templateaccounts:readNoDownloads the CSV template of the accounts file.
POST /v1/accounts/imports/previewaccounts:writeNoClassifies and prices every row. Nothing is written, provisioned or charged.
POST /v1/accounts/importsaccounts:writeRequiredApplies the import and answers 201 Created with the result of the execution.

accounts:read and accounts:write are privileged scopes: * does not cover them, and only an account to which we granted the provider privilege can use them. Without the privilege, or when the person who issued the key is no longer an owner or admin of your account, the answer is 403 privileged_scope_required. The preview needs accounts:write too, although it writes nothing. None of the three routes has an {account} segment: the provider is always the account of the key and the environment is the one of the key, and neither goes in the request.

The usual order is: download the template, fill it in, send it to the preview, fix the rows that come out BLOCKED or ERROR and preview again, and then apply the same files.

The request

Preview and apply take the same multipart/form-data body.

FieldRequiredWhat it is
accounts_fileYesThe accounts file: one row per managed account or per own tax ID, up to 100 rows.
customers_fileNoThe customers file: the customers of those accounts and tax IDs, up to 1,000 rows.
optionsNoAn object with three options, all false by default.

Both files are CSV, preferably in UTF-8, separated by commas, semicolons or tabs, and up to 5 MB each. The parser is more tolerant than the template. To preview the file of the example below, send only the accounts file:

curl -X POST https://api.factuarea.com/v1/accounts/imports/preview \
  -H "Authorization: Bearer $FACTUAREA_API_KEY" \
  -F "accounts_file=@accounts.csv"

Do not set Content-Type yourself: your HTTP client adds the multipart/form-data header with its boundary. To apply, send the same fields to POST /v1/accounts/imports with an Idempotency-Key.

Options

OptionDefaultWhat it does
send_emailfalseWe email the claim link to the owner of every new live managed account, and the response carries claim_token and claim_url as null. With false, each new live row returns its voucher once, in the 201. See who delivers the voucher.
apply_customers_to_own_companyfalseAlso imports, into your own company, the customers of customers_file that have no account_ref. The key needs contacts:write. See customers.
confirm_plan_changesfalseApplies the plan change that a row asks for on a managed account that already exists and that you pay for. Without it, that row is WARNING and the plan does not change. See importing the same file again.

options goes as nested form fields (options[send_email]=true) or as one JSON field (options={"send_email":true}), which is how an OpenAPI client serializes an object in multipart/form-data. A boolean accepts true, false, 1, 0, on, off, yes and no. Any other key answers 422 invalid_param_value, so a misspelled option is never ignored in silence.

The accounts file

The accounts file has a header row and one row per managed account or per own tax ID, up to 100. A row carries one tax ID, and a tax ID appears once in the file.

The template

GET /v1/accounts/imports/template returns text/csv as an attachment named plantilla-importacion-cuentas.csv. It is the same for every provider and reads nothing from your account. The file is UTF-8 with a byte order mark (BOM), ; as the separator and CRLF line endings, which is what a spreadsheet in Spanish opens correctly. It holds the header row and one example row:

mode;external_ref;plan;tax_id;legal_name;address;postal_code;city;province;country_code;owner_email;display_name;language;access_level
# ejemplo: managed_account;CLIENTE-0001;empresario;B12345674;Cliente Ejemplo SL;Calle Mayor 1;28013;Madrid;Madrid;ES;titular@example.com;Cliente Ejemplo;es;operate

The first cell of the example row starts with # ejemplo, and the import always ignores a row that does. Without that marker it would be a valid managed_account row, so remove it only when you mean it. The set of columns is closed: a column that is not in the template makes every row ERROR in that column, and so does a column that appears twice.

Columns

Columnmanaged_accountown_nifNotes
modeRequiredRequiredown_nif or managed_account, with no default: an empty or unknown value makes the row ERROR.
external_refRequiredNot usedYour own reference for the client, up to 128 characters, unique in the file and for your account. It is what makes a second import reconcile instead of duplicating.
planRequiredNot allowedemprendedor, empresario or integral. enterprise is only assigned by contract and its row is BLOCKED. In an own_nif row a plan is an ERROR: the tax ID takes the plan of your account.
tax_idRequiredRequiredThe NIF, CIF or NIE, up to 20 characters, validated with its control character. One per row, never repeated in the file.
legal_nameRequiredRequiredThe legal name, up to 100 characters, and at least 2 in a managed account.
address, postal_code, city, province, country_codeOptionalOptionalThe fiscal address, of the client or of your own tax ID. Up to 255, 10, 100, 100 and 100 characters.
owner_emailRequired in a new live accountNot usedThe person who will hold the account and receive the claim voucher, up to 255 characters. A test account does not need it.
display_nameOptionalOptionalThe visible name. In a managed account it is also the name of the account, from 2 to 100 characters. In an own tax ID it is up to 255 characters, and the legal_name when empty.
languageOptionalNot usedes, en or ca: the language of the account and of its voucher.
access_levelOptionalNot usedThe level your account keeps on the new managed account: none, view or operate. It is operate when empty.

mode, plan, access_level and language are read in lower case, and every value is trimmed. A cell you leave empty counts as absent. owner_email, language and access_level are checked for their form in every row, even when the mode does not use them.

How the parser reads the file

The parser reads what a spreadsheet exports, so it is more tolerant than the template. It decides the type of the file by its content and never by its name or extension.

WhatHow it reads it
EncodingUTF-8 with or without BOM, UTF-16 with BOM (the "Unicode text" of Excel) and, when the file is not valid UTF-8, Windows-1252.
SeparatorA semicolon, a comma or a tab, detected on the header row. If more than one fits, the one that gives more columns, and on a tie the first of those three.
PreambleIt skips up to 20 lines before the header row. The header row is the first line with a cell mode (account_ref in the customers file).
HeaderLower case and trimmed, with inner spaces and hyphens turned into _: Tax ID and external-ref read as tax_id and external_ref.
RowsBlank lines and the # ejemplo row are skipped. Cells are trimmed, non-breaking spaces included. A value between double quotes can hold the separator and line breaks.
TextCells are literal text and nothing is evaluated: a formula reads as the text it contains.

What it does not read: a spreadsheet (.xlsx or .xls), a PDF, an image, an archive or any binary file is 422 import_file_invalid even when it is named .csv, and so is a file with no header row after the preamble, with an unbalanced double quote or with control characters. Save the sheet as CSV before you upload it.

Limits

LimitValueWhen it is exceeded
Size of each file5 MB413 import_file_too_large. error.param is accounts_file or customers_file.
Size of the whole request11 MB413 payload_too_large, before any file is inspected.
Rows of accounts_file100422 import_row_limit_exceeded. error.param names the file.
Rows of customers_file1,000422 import_row_limit_exceeded. error.param names the file.
An unreadable file, a missing header row or a missing column—422 import_file_invalid.
Bytes of the files being uploaded at the same time, for your account—429 upload_in_flight_budget_exceeded, with Retry-After.
Previews10 per minute for each key429, with Retry-After.
Applications5 per minute for each key and 20 per day for your account429, with Retry-After.
Applications at the same timeOne for your account409 import_in_progress.

Size and rows are checked before any row is read, so when one of them fails nothing is classified, written or charged. Rows are counted without the header, the blank lines and the example row. To load more than 100 clients, split the file and send the parts one after another: every part reconciles by external_ref, so you can send a part again.

Row statuses

The preview and the apply classify every row with the same rules and give it one of five statuses. Only the first three are applied.

statusAppliedWhat it means
VALIDYesThe row is correct and nothing in your account prevents it. Applying it creates the account or registers the tax ID.
WARNINGYesIt is applied, but something in it does not change. code says what.
ALREADY_EXISTSYesThe account or the tax ID already exists. The row reconciles it: it never duplicates it and never charges twice.
BLOCKEDNoA business rule prevents the row. code names the reason.
ERRORNoThe row is wrong. errors names the columns, or code the reason.

The code of a WARNING says what did not change:

codeWhat it means
managed_account_claimedThe client already claimed the account, so its details are theirs: the row changes nothing.
access_level_below_relationThe row asks for a level lower than yours. The import never lowers a level, so it stays.
plan_change_not_confirmedThe row asks for another plan and you did not send confirm_plan_changes. The plan does not change.
plan_change_failedThe details were updated, but the confirmed plan change failed. errors.plan holds the reason.

Rows are classified in the order of the file, so the valid rows before one use up the caps that it would need: a file with more new live accounts than your cap leaves the last ones BLOCKED. The code of a row is a reason of the import report and not always a code of the error catalog: the ones that have a page link to it.

Errors by column

A row that is wrong comes out ERROR with code null and an errors object. Its key is the column, or row for an error of the whole row, and its value is a message in Spanish. The object is always there, and it is {} when the row has nothing wrong. A row fails when:

  • a required column is empty: mode, tax_id and legal_name, and external_ref and plan in a managed account;
  • mode is not own_nif or managed_account;
  • tax_id is not a valid NIF, CIF or NIE;
  • plan is not emprendedor, empresario or integral (enterprise is BLOCKED, not an error), or an own_nif row carries one;
  • language is not es, en or ca, access_level is not none, view or operate, or owner_email is not an email address;
  • a text is longer than its maximum, or a legal_name or display_name of a managed account is shorter than 2 characters;
  • external_ref or tax_id already appeared in an earlier row of the file: the error goes on the second row and the following ones;
  • tax_id differs from the one of the managed account that the external_ref already names;
  • a new live managed account has no owner_email;
  • the header has a column that is not in the template or repeats one (the error goes on that column, in every row), or a row has more values than the header has columns (the error goes on row).

A value that the account or the tax ID rejects while the row is being applied is also an ERROR, with code null and its message in errors.row. A failure of the execution itself, such as a database or payment provider error, is an ERROR with code row_apply_failed. Neither stops the rest of the import.

Codes of the errors

Every entry of errors has its stable code in error_codes, an object with the same keys: {column: code}. The message of errors is in Spanish and may be reworded; the code is not translated and does not change, so react to the code, or translate it, and show the message only to a person. Like errors, it is always there and it is {} when the row has nothing wrong, and so it is in an import created before the field existed. A BLOCKED row comes out with both objects empty: its reason is the code of the row. The field is additive: errors does not change.

CodeKeyWhen
requiredmode, tax_id, legal_name, external_ref, plan, owner_emailA required column is empty.
invalid_modemodeIt is not own_nif or managed_account.
invalid_planplanIt is not emprendedor, empresario or integral.
plan_not_allowedplanAn own_nif row carries a plan.
invalid_tax_idtax_idIt is not a valid NIF, CIF or NIE.
invalid_emailowner_emailIt is not an email address.
invalid_languagelanguageIt is not es, en or ca.
invalid_access_levelaccess_levelIt is not none, view or operate.
too_longAny text columnThe text is longer than its maximum.
too_shortlegal_name, display_nameA managed account carries fewer than 2 characters.
duplicate_in_fileexternal_ref, tax_idIt already appeared in an earlier row.
tax_id_mismatchtax_idIt differs from the one of the account that the external_ref already names.
unknown_columnThe unknown columnThe header has a column that is not in the template.
duplicate_columnThe repeated columnThe header repeats a column.
unnamed_columnrowA column of the header has no name.
column_count_mismatchrowThe row has more values than the header has columns.
invalid_valuerowThe account or the tax ID rejects a value of the row.
row_rejectedrowThe domain rejects the row while it is applied. The reason is the code of the row.
row_apply_failedrowThe execution of the row failed.
plan_change_rejectedplanIn a WARNING row, the confirmed plan change is rejected. The reason is the code of the row.

The preview

POST /v1/accounts/imports/preview classifies and prices the files without any effect: it writes nothing, provisions no account and charges nothing. To price the rows it only queries Stripe, for the end of your billing period and, for every confirmed plan change, the prorated amount of that change, so it has its own limit of 10 per minute. It needs no Idempotency-Key: it is a query that uses POST because it uploads files.

Take this accounts file:

mode;external_ref;plan;tax_id;legal_name;address;postal_code;city;province;country_code;owner_email;display_name;language;access_level
managed_account;cliente-0042;empresario;B12345674;Talleres García SL;Calle Mayor 1;28013;Madrid;Madrid;ES;laura@talleresgarcia.example;Talleres García;es;operate
own_nif;;;B87654323;Distribuciones Norte SL;;;;;;;;;
managed_account;cliente-0043;emprendedor;A58818501;Panadería Sol SA;;;;;;marta@panaderiasol.example;;es;
managed_account;cliente-0044;emprendedor;;Gráficas Levante SL;;;;;;ivan@graficaslevante.example;;;

It has four rows: a managed account, an own tax ID, a second managed account that does not fit in your cap and one that forgot its tax ID. The preview answers 200:

{
  "data": {
    "object": "managed_account_import_preview",
    "rows": [
      {
        "line": 2,
        "mode": "managed_account",
        "external_ref": "cliente-0042",
        "tax_id": "B12345674",
        "status": "VALID",
        "code": null,
        "errors": {},
        "error_codes": {},
        "plan": "empresario",
        "monthly_amount_cents": 3500,
        "proration_amount_cents": 1750,
        "account_id": null,
        "customers_status": "none",
        "customers_queued": 0,
        "customers_code": null
      },
      {
        "line": 3,
        "mode": "own_nif",
        "external_ref": null,
        "tax_id": "B87654323",
        "status": "VALID",
        "code": null,
        "errors": {},
        "error_codes": {},
        "plan": null,
        "monthly_amount_cents": 1500,
        "proration_amount_cents": 750,
        "account_id": null,
        "customers_status": "none",
        "customers_queued": 0,
        "customers_code": null
      },
      {
        "line": 4,
        "mode": "managed_account",
        "external_ref": "cliente-0043",
        "tax_id": "A58818501",
        "status": "BLOCKED",
        "code": "managed_accounts_limit_reached",
        "errors": {},
        "error_codes": {},
        "plan": "emprendedor",
        "monthly_amount_cents": 0,
        "proration_amount_cents": 0,
        "account_id": null,
        "customers_status": "none",
        "customers_queued": 0,
        "customers_code": null
      },
      {
        "line": 5,
        "mode": "managed_account",
        "external_ref": "cliente-0044",
        "tax_id": null,
        "status": "ERROR",
        "code": null,
        "errors": {
          "tax_id": "La columna tax_id es obligatoria: cada fila da de alta un NIF."
        },
        "error_codes": {
          "tax_id": "required"
        },
        "plan": null,
        "monthly_amount_cents": 0,
        "proration_amount_cents": 0,
        "account_id": null,
        "customers_status": "none",
        "customers_queued": 0,
        "customers_code": null
      }
    ],
    "totals": {
      "total_rows": 4,
      "valid": 2,
      "warning": 0,
      "already_exists": 0,
      "blocked": 1,
      "error": 1
    },
    "cost": {
      "currency": "EUR",
      "managed_account": {
        "monthly_amount_cents": 3500,
        "proration_amount_cents": 1750
      },
      "own_nif": {
        "monthly_amount_cents": 1500,
        "proration_amount_cents": 750
      },
      "total": {
        "monthly_amount_cents": 5000,
        "proration_amount_cents": 2500
      }
    },
    "own_company_customers": null
  }
}

The body has these fields:

FieldWhat it is
objectAlways managed_account_import_preview.
rowsThe rows of the accounts file, in file order.
totalsThe rows by status: total_rows, valid, warning, already_exists, blocked and error. The five statuses are always there, 0 when there is none.
costThe cost of the rows that would be applied, by mode and in total.
own_company_customersThe outcome of apply_customers_to_own_company, or null when the option is off. See customers.

And every row has these:

FieldWhat it is
lineThe line of the row in the accounts file, counting from 1.
modemanaged_account or own_nif. It is null when the row has no valid mode, and then it is ERROR.
external_refYour reference in a managed_account row, and null in an own_nif row.
tax_idThe tax ID as read from the file, or null when the row has none.
statusOne of the five statuses.
codeThe reason of a WARNING, a BLOCKED or an ERROR that is not a column error, or null.
errorsThe errors by column.
error_codesThe code of each error of errors, with the same keys. See the codes of the errors.
planThe plan of a managed_account row, and null in an own_nif row: it takes the plan of your account.
monthly_amount_centsThe monthly amount the row adds to your subscription, in cents.
proration_amount_centsWhat applying the row charges at once, in cents.
account_idThe id of the managed account the row created or reconciled, or null while it does not exist yet.
customers_statusnone, queued or blocked. See customers.
customers_queuedThe customers queued for the tax ID of the row.
customers_codeThe reason of customers_status: blocked, or null.

What the import costs

Every row carries monthly_amount_cents, the monthly amount it adds to your subscription, and proration_amount_cents, what applying it charges at once for what is left of your current billing period. cost adds them up for the managed_account rows, for the own_nif rows and in total, with the currency. Amounts are integer cents of taxable base, without VAT: the name of the key says the unit.

In the example, the two VALID rows add 3500 and 1500 cents a month, 50.00 EUR, and applying the file charges 2500 cents, 25.00 EUR, at once. The proration is half of the monthly amount, as when half of your billing period is left. The BLOCKED and the ERROR rows add nothing.

  • Only what is charged counts. Only new rows that are VALID add a monthly amount: the new managed accounts, charged on the managed-accounts subscription of your account, and the new own tax IDs, charged as additional tax IDs. A row that already exists adds 0.
  • A confirmed plan change adds only its proration. With confirm_plan_changes, the row of an existing account has no monthly amount but the proration that a single plan change would charge. An upgrade is charged at once. A downgrade takes effect at the end of your period and adds 0 now.
  • Some imports cost 0. It is 0 for a test import, while your account is in its trial and when your account is not charged.
  • The apply charges what the preview announced. The proration announced for a file is what applying the same files charges. The apply classifies the file again at the moment you send it, with the state of your account then, so a preview you made hours ago can differ.

Rows that come out BLOCKED

The code of a BLOCKED row is the reason. A row that is BLOCKED changes nothing, neither fields nor plan.

codeWhenWhat to do
managed_account_plan_not_self_serveThe row asks for enterprise, or for a change on an account under an enterprise contract.We assign enterprise by contract: ask for emprendedor, empresario or integral.
managed_account_provider_in_trialYour account is in its trial and the row would create a live managed account, which is charged from the first day.Use a fact_test_ key to create test accounts, or wait until you start paying. See your account in trial.
managed_accounts_limit_reachedYour account is at its cap of live managed accounts, counting the valid rows before this one.End the management of an account you no longer run, or ask us to raise the cap.
billing_not_currentThe subscription that pays your managed accounts is not up to date, for example after a failed renewal.Bring your billing up to date and preview again.
payment_method_requiredSomething in the row is charged and your account has no payment method. The import never opens a checkout.Register a payment method in the app and preview again.
nif_transfer_requiredThe tax ID is already in an account. A managed account needs a tax ID that is in none, yours included, and an own tax ID cannot be in another account. We never say which one.Move the tax ID with a tax ID transfer.
additional_nif_not_in_planAn own_nif row and your plan does not allow additional tax IDs: they start at empresario.See the gate and its four codes.
additional_nif_limit_reachedAn own_nif row and your account is at its cap of additional tax IDs, counting the valid rows before this one.See the cap.
managed_account_external_ref_in_other_environmentThe external_ref belongs to an account of the other environment, live or test.Use another reference.
managed_account_no_longer_managedThe external_ref belongs to an account you no longer manage, because its holder took over the payment.Use another reference.
management_endedThe management of the account with that external_ref ended, and the import does not reactivate a management.Before the client claims it, provision that external_ref with POST /v1/accounts.
access_level_raise_forbiddenThe client already claimed the account and the row asks for a level above yours.Only the client can raise your level.
access_level_above_relationThe account is not claimed and the row asks for a level above yours.The import never raises a level. See your access level.
plan_change_not_paid_by_providerWith confirm_plan_changes, the client took over the payment, so you no longer pay for the account.Only the account that pays can change the plan.
plan_change_requires_operateWith confirm_plan_changes, your level on the account is not operate.See your access level.
forbidden_actionAn own_nif row and the key does not reach the titular company of your account, which pays the additional tax IDs.Use a key that reaches it.

A row that the domain rejects while it is being applied, for example a cap that filled up between the preview and the apply, is also BLOCKED, with the public code of that rejection. Treat a code you do not know as the reason of a BLOCKED row.

An own_nif row also needs companies:write on the key, besides accounts:write: without it the row is ERROR with code insufficient_scope. If the key is limited to some tax IDs, each tax ID that the import registers is added to that limit, as when you register a tax ID with a single call.

Importing the same file again

Applying the same file again never duplicates an account and never charges twice. The external_ref of a managed_account row identifies the account, and the tax_id identifies an own_nif row. A row that names something that already exists comes out ALREADY_EXISTS, or WARNING or BLOCKED when what it asks for cannot be applied.

The row is about…What the import does
An own tax ID that is already in your accountNothing. The row is ALREADY_EXISTS and only its customers are imported.
A managed account you provisioned, in the same environment and not claimedIt updates the fields of the list below that the row carries, and nothing else.
A managed account its holder already claimedWARNING managed_account_claimed. Nothing changes: those details are the holder's now.
A managed account whose management endedBLOCKED management_ended.
An external_ref of the other environment, or of an account you no longer manageBLOCKED, with nothing written.

What a second import updates

Only a list of fields is updated, and only while the account is not claimed: display_name (the visible name, which is also the name of the account and of its tax ID), language, legal_name, address, postal_code, city, province and country_code. A field changes only when its cell has a value, and a value equal to the current one is not rewritten. Everything else is out of reach:

  • The tax ID of an existing account cannot change: a different tax_id is an ERROR.
  • The access level never changes. A lower level in the file is a WARNING and a higher one is BLOCKED.
  • The plan changes only as the next paragraph says.

A row that asks for a different plan on an existing account is WARNING plan_change_not_confirmed and changes nothing, unless you send confirm_plan_changes. With it, the change is applied when you still pay for the account, your level on it is operate and it is not under an enterprise contract. If not, the row is BLOCKED with plan_change_not_paid_by_provider, plan_change_requires_operate or managed_account_plan_not_self_serve. The change is priced like a single plan change. If the profile was updated and the plan change fails afterwards, the row is WARNING with the reason in errors.plan.

Customers

customers_file loads the customers of each account and tax ID in the same import. It has a header row with the column account_ref and, in the other columns, the columns of the contacts import of a company: the header of the template of GET /v1/companies/{company}/contacts/import/template. Each customer goes to a row of the accounts file through its account_ref:

The row of the accounts file is…Its customers carry an account_ref equal to…
managed_accountIts external_ref.
own_nifIts tax_id.

With the accounts file above, this customers file sends three customers to cliente-0042:

account_ref;name;kind;tax_id;email
cliente-0042;Bar La Plaza SL;company;B22222228;compras@barlaplaza.example
cliente-0042;Ferretería Ortiz SL;company;B33333337;info@ferreteriaortiz.example
cliente-0042;Hotel Mirador SA;company;A44444446;reservas@hotelmirador.example

The customers go through the contacts importer that already exists, with the role customer and the conflict strategy reject: a customer that clashes with an existing contact is rejected and the existing one stays as it is. Export and import explains what that importer accepts. The customers are queued: customers_queued counts what was queued, and the contacts are created afterwards, so the count is not proof that they already exist.

A row's customers are imported only into a tax ID that the row applied and where you can write. The state of each row is in customers_status, which is none (the row has no customers), queued or blocked, and in the second case customers_code says why:

customers_codeWhy its customers are not imported
nif_not_appliedThe row was not applied: it is BLOCKED or ERROR.
operate_access_requiredYour account does not have operate on the managed account.
account_admin_requiredThe customers go to one of your own tax IDs and the person who issued the key is not an owner or admin of your account.
nif_not_in_key_scopeYour own tax ID is outside the tax ID scope of the key and this import did not register it.
insufficient_scopeThe key does not have contacts:write.
customers_import_failedThe contacts importer failed.

With a key, accounts:write is not enough for the customers: the key needs contacts:write too.

Customers without an account: apply_customers_to_own_company

The customers that have no account_ref go nowhere, unless you send apply_customers_to_own_company. With it, they are imported into your own company in focus: the only tax ID in the scope of the key, or the only tax ID of your account. The destination is always a real tax ID of your own account, never one that another account granted to you. The key needs contacts:write, and the outcome is in own_company_customers:

codestatusWhat it means
own_company_focus_requiredERRORYour account has several tax IDs and none is in focus.
insufficient_scopeERRORThe key does not have contacts:write.
live_credential_requiredERRORThe import runs with a test key.
account_admin_required or operate_access_requiredBLOCKEDThe person who issued the key cannot write in that company.
nif_not_in_key_scopeBLOCKEDThat company is outside the tax ID scope of the key.
customers_import_failedBLOCKEDThe contacts importer failed.

own_company_customers has status (VALID, BLOCKED or ERROR), code, customers_queued and unmatched_customers, which counts the customers whose account_ref matches no row of the accounts file: they are imported nowhere, and this way they are not lost in silence.

Apply the import

POST /v1/accounts/imports takes the body of the request and applies it. It applies every row that is VALID, WARNING or ALREADY_EXISTS on its own, in the order of the file: a row that fails comes out BLOCKED or ERROR and the rest go on. It never opens a checkout: it charges your existing subscription, and a row that cannot be charged comes out BLOCKED with its reason.

curl -X POST https://api.factuarea.com/v1/accounts/imports \
  -H "Authorization: Bearer $FACTUAREA_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -F "accounts_file=@accounts.csv" \
  -F "customers_file=@customers.csv" \
  -F "options[confirm_plan_changes]=false"

The application is synchronous: the answer arrives when the last row is done, and it can take a few minutes with a full file, so allow at least 5 minutes in your client. Suppose you took out the two rows that came out BLOCKED and ERROR and you send the customers file above. The answer is 201 Created:

{
  "data": {
    "id": "0199a1f0-1c22-7a10-b111-8a8b8c8d8e8f",
    "object": "managed_account_import",
    "status": "completed",
    "stats": {
      "total_rows": 2,
      "valid": 2,
      "warning": 0,
      "already_exists": 0,
      "blocked": 0,
      "error": 0,
      "created": 2,
      "reconciled": 0,
      "customers_queued": 3,
      "monthly_amount_cents": 5000,
      "proration_amount_cents": 2500
    },
    "rows": [
      {
        "line": 2,
        "mode": "managed_account",
        "external_ref": "cliente-0042",
        "tax_id": "B12345674",
        "status": "VALID",
        "code": null,
        "errors": {},
        "error_codes": {},
        "plan": "empresario",
        "monthly_amount_cents": 3500,
        "proration_amount_cents": 1750,
        "account_id": "0199a1f0-1c22-7a10-b111-6a6b6c6d6e6f",
        "customers_status": "queued",
        "customers_queued": 3,
        "customers_code": null,
        "claim_token": "e3c3e3c3e3c3e3c3e3c3e3c3e3c3e3c3e3c3e3c3e3c3e3c3",
        "claim_url": "https://app.factuarea.com/account-claims/accept?token=e3c3e3c3e3c3e3c3e3c3e3c3e3c3e3c3e3c3e3c3e3c3e3c3"
      },
      {
        "line": 3,
        "mode": "own_nif",
        "external_ref": null,
        "tax_id": "B87654323",
        "status": "VALID",
        "code": null,
        "errors": {},
        "error_codes": {},
        "plan": null,
        "monthly_amount_cents": 1500,
        "proration_amount_cents": 750,
        "account_id": null,
        "customers_status": "none",
        "customers_queued": 0,
        "customers_code": null,
        "claim_token": null,
        "claim_url": null
      }
    ],
    "own_company_customers": null
  }
}
FieldWhat it is
idThe identifier of the execution, a UUID v7.
objectAlways managed_account_import.
statusThe state of the execution: completed when it ended. failed when it could not finish, and then the rows already applied stay applied.
statsThe numbers of the execution: the rows by status, created, reconciled, customers_queued, monthly_amount_cents and proration_amount_cents.
rowsThe rows of the preview, in file order, with claim_token and claim_url added.
own_company_customersAs in the preview.

The amounts of stats and of each row are what the apply charged: the proration that the preview announced, calculated again at that moment. In the example, the managed account of line 2 returns its claim_token and claim_url, and the own tax ID returns null, because it has no voucher.

The voucher of every new live managed_account row comes back once, in this response. A replay of the same request returns it as null, and so does a row when send_email is true, because the link went by email, and every row of a test import. If you lose one, reissue it. The response has no Location header, because an execution has no public read operation. The app keeps the history and the report of every import, under Client accounts, Import, Import history, and when an import ends we publish account.import_completed to the account webhooks of your account, with the id of the execution and its stats.

Idempotency-Key and one import at a time

The apply cannot be taken back, so it requires an Idempotency-Key: without it the answer is 422 idempotency_key_required before anything is read. The preview does not need one. What each protection does:

WhatWhat it does
Idempotency-KeyIf the connection drops and you repeat the same request with the same key, we replay the first response, with Idempotent-Replayed: true, and apply nothing twice. Every voucher comes back as null: we never store it in the clear, not even in the idempotency cache. The same key with another request is 422 idempotency_key_reused. See idempotency.
external_ref and the tax IDThey make the file reconcile: a new application of the same file, even with a new key, creates nothing twice and charges nothing twice.
One import at a timeThe applications of your account are serialized. While one is running, the next answers 409 import_in_progress and applies nothing.

After a 409, wait for the running import to end and send the same files again with a new Idempotency-Key: the rows the first one already applied come back ALREADY_EXISTS, never duplicated and never charged twice. An import that never finished stops blocking after 15 minutes. A 429 applies nothing: wait what Retry-After says and send the request again.

With a test key

With a fact_test_ key the import works on your test accounts, and nothing is charged:

  • managed_account rows create or reconcile test accounts: no charge, no Stripe and no voucher, so claim_token and claim_url are null. A test account does not count against max_managed_accounts.
  • own_nif rows come out ERROR with code live_credential_required and add 0 to the total: the tax IDs of your own account are only registered in live mode. The same goes for the customers that apply_customers_to_own_company would load into your own company.
  • The environment is the key's. A test import only sees your test accounts: an external_ref that belongs to a live account comes out BLOCKED with managed_account_external_ref_in_other_environment, and the reverse.
  • The cost is 0, in every row and in the totals.

The whole flow with a test key is in test managed accounts.

Two things the import does not do, by design

  • No MCP tool. The three operations are privileged and the import reads two uploaded files, which the MCP server does not carry. The alternative is the REST API, or the import of the app. From an agent, provision the accounts one at a time with the tool provision_managed_account, which mirrors POST /v1/accounts: see from the MCP server.
  • No series. The file has no column or option for document series: it registers accounts and tax IDs, not their numbering. Create or change the series of an imported tax ID with the operations of the company axis, for example POST /v1/companies/{company}/series/bootstrap, which creates the missing default series in one call and needs series:write. issuing-readiness tells you whether a tax ID still lacks one: no_default_series is one of its blockers, as what blocks a tax ID from issuing explains. The {company} of an imported tax ID is in companies[].id of GET /v1/accounts/{account} for a managed account, whose {account} is the account_id of the row, and in GET /v1/accounts/{account}/companies for your own tax IDs.

Errors

The rules that would reject a single provisioning reject a row here, never the request: they come in the 201 or the 200, in the status and code of the row. These are the errors of the request:

CodeStatusWhen
insufficient_scope403The key does not list accounts:write, or accounts:read for the template. * does not count.
privileged_scope_required403Your account does not hold the provider privilege, or the person who issued the key is no longer an owner or admin of it.
invalid_param_value422accounts_file is missing or is not a file, or options holds an unknown key or a value that is not a boolean. error.param names it.
import_file_too_large413A file is over 5 MB. error.param names it.
payload_too_large413The whole request is over 11 MB.
import_row_limit_exceeded422accounts_file has more than 100 rows, or customers_file more than 1,000.
import_file_invalid422The file cannot be read, has no header row or lacks a column.
idempotency_key_required422The apply carries no Idempotency-Key.
idempotency_key_reused422That key was already used with a different request.
import_in_progress409Another import of your account is running.
upload_in_flight_budget_exceeded429Your account is already uploading too many bytes at the same time.

A 429 without a code of this table is a limit of the import: a preview over 10 per minute, or an apply over 5 per minute or over 20 per day. Wait what Retry-After says.

Next steps

On this page

Need a hand?Contact support