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
| Operation | Scope | Idempotency-Key | What it does |
|---|---|---|---|
GET /v1/accounts/imports/template | accounts:read | No | Downloads the CSV template of the accounts file. |
POST /v1/accounts/imports/preview | accounts:write | No | Classifies and prices every row. Nothing is written, provisioned or charged. |
POST /v1/accounts/imports | accounts:write | Required | Applies 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.
| Field | Required | What it is |
|---|---|---|
accounts_file | Yes | The accounts file: one row per managed account or per own tax ID, up to 100 rows. |
customers_file | No | The customers file: the customers of those accounts and tax IDs, up to 1,000 rows. |
options | No | An 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
| Option | Default | What it does |
|---|---|---|
send_email | false | We 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_company | false | Also imports, into your own company, the customers of customers_file that have no account_ref. The key needs contacts:write. See customers. |
confirm_plan_changes | false | Applies 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;operateThe 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
| Column | managed_account | own_nif | Notes |
|---|---|---|---|
mode | Required | Required | own_nif or managed_account, with no default: an empty or unknown value makes the row ERROR. |
external_ref | Required | Not used | Your 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. |
plan | Required | Not allowed | emprendedor, 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_id | Required | Required | The NIF, CIF or NIE, up to 20 characters, validated with its control character. One per row, never repeated in the file. |
legal_name | Required | Required | The legal name, up to 100 characters, and at least 2 in a managed account. |
address, postal_code, city, province, country_code | Optional | Optional | The fiscal address, of the client or of your own tax ID. Up to 255, 10, 100, 100 and 100 characters. |
owner_email | Required in a new live account | Not used | The person who will hold the account and receive the claim voucher, up to 255 characters. A test account does not need it. |
display_name | Optional | Optional | The 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. |
language | Optional | Not used | es, en or ca: the language of the account and of its voucher. |
access_level | Optional | Not used | The 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.
| What | How it reads it |
|---|---|
| Encoding | UTF-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. |
| Separator | A 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. |
| Preamble | It 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). |
| Header | Lower case and trimmed, with inner spaces and hyphens turned into _: Tax ID and external-ref read as tax_id and external_ref. |
| Rows | Blank 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. |
| Text | Cells 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
| Limit | Value | When it is exceeded |
|---|---|---|
| Size of each file | 5 MB | 413 import_file_too_large. error.param is accounts_file or customers_file. |
| Size of the whole request | 11 MB | 413 payload_too_large, before any file is inspected. |
Rows of accounts_file | 100 | 422 import_row_limit_exceeded. error.param names the file. |
Rows of customers_file | 1,000 | 422 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. |
| Previews | 10 per minute for each key | 429, with Retry-After. |
| Applications | 5 per minute for each key and 20 per day for your account | 429, with Retry-After. |
| Applications at the same time | One for your account | 409 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.
status | Applied | What it means |
|---|---|---|
VALID | Yes | The row is correct and nothing in your account prevents it. Applying it creates the account or registers the tax ID. |
WARNING | Yes | It is applied, but something in it does not change. code says what. |
ALREADY_EXISTS | Yes | The account or the tax ID already exists. The row reconciles it: it never duplicates it and never charges twice. |
BLOCKED | No | A business rule prevents the row. code names the reason. |
ERROR | No | The row is wrong. errors names the columns, or code the reason. |
The code of a WARNING says what did not change:
code | What it means |
|---|---|
managed_account_claimed | The client already claimed the account, so its details are theirs: the row changes nothing. |
access_level_below_relation | The row asks for a level lower than yours. The import never lowers a level, so it stays. |
plan_change_not_confirmed | The row asks for another plan and you did not send confirm_plan_changes. The plan does not change. |
plan_change_failed | The 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_idandlegal_name, andexternal_refandplanin a managed account; modeis notown_niformanaged_account;tax_idis not a valid NIF, CIF or NIE;planis notemprendedor,empresarioorintegral(enterpriseisBLOCKED, not an error), or anown_nifrow carries one;languageis notes,enorca,access_levelis notnone,vieworoperate, orowner_emailis not an email address;- a text is longer than its maximum, or a
legal_nameordisplay_nameof a managed account is shorter than 2 characters; external_refortax_idalready appeared in an earlier row of the file: the error goes on the second row and the following ones;tax_iddiffers from the one of the managed account that theexternal_refalready 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.
| Code | Key | When |
|---|---|---|
required | mode, tax_id, legal_name, external_ref, plan, owner_email | A required column is empty. |
invalid_mode | mode | It is not own_nif or managed_account. |
invalid_plan | plan | It is not emprendedor, empresario or integral. |
plan_not_allowed | plan | An own_nif row carries a plan. |
invalid_tax_id | tax_id | It is not a valid NIF, CIF or NIE. |
invalid_email | owner_email | It is not an email address. |
invalid_language | language | It is not es, en or ca. |
invalid_access_level | access_level | It is not none, view or operate. |
too_long | Any text column | The text is longer than its maximum. |
too_short | legal_name, display_name | A managed account carries fewer than 2 characters. |
duplicate_in_file | external_ref, tax_id | It already appeared in an earlier row. |
tax_id_mismatch | tax_id | It differs from the one of the account that the external_ref already names. |
unknown_column | The unknown column | The header has a column that is not in the template. |
duplicate_column | The repeated column | The header repeats a column. |
unnamed_column | row | A column of the header has no name. |
column_count_mismatch | row | The row has more values than the header has columns. |
invalid_value | row | The account or the tax ID rejects a value of the row. |
row_rejected | row | The domain rejects the row while it is applied. The reason is the code of the row. |
row_apply_failed | row | The execution of the row failed. |
plan_change_rejected | plan | In 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:
| Field | What it is |
|---|---|
object | Always managed_account_import_preview. |
rows | The rows of the accounts file, in file order. |
totals | The rows by status: total_rows, valid, warning, already_exists, blocked and error. The five statuses are always there, 0 when there is none. |
cost | The cost of the rows that would be applied, by mode and in total. |
own_company_customers | The outcome of apply_customers_to_own_company, or null when the option is off. See customers. |
And every row has these:
| Field | What it is |
|---|---|
line | The line of the row in the accounts file, counting from 1. |
mode | managed_account or own_nif. It is null when the row has no valid mode, and then it is ERROR. |
external_ref | Your reference in a managed_account row, and null in an own_nif row. |
tax_id | The tax ID as read from the file, or null when the row has none. |
status | One of the five statuses. |
code | The reason of a WARNING, a BLOCKED or an ERROR that is not a column error, or null. |
errors | The errors by column. |
error_codes | The code of each error of errors, with the same keys. See the codes of the errors. |
plan | The plan of a managed_account row, and null in an own_nif row: it takes the plan of your account. |
monthly_amount_cents | The monthly amount the row adds to your subscription, in cents. |
proration_amount_cents | What applying the row charges at once, in cents. |
account_id | The id of the managed account the row created or reconciled, or null while it does not exist yet. |
customers_status | none, queued or blocked. See customers. |
customers_queued | The customers queued for the tax ID of the row. |
customers_code | The 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
VALIDadd a monthly amount: the new managed accounts, charged on themanaged-accountssubscription 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.
code | When | What to do |
|---|---|---|
managed_account_plan_not_self_serve | The 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_trial | Your 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_reached | Your 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_current | The 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_required | Something 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_required | The 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_plan | An 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_reached | An 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_environment | The external_ref belongs to an account of the other environment, live or test. | Use another reference. |
managed_account_no_longer_managed | The external_ref belongs to an account you no longer manage, because its holder took over the payment. | Use another reference. |
management_ended | The 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_forbidden | The client already claimed the account and the row asks for a level above yours. | Only the client can raise your level. |
access_level_above_relation | The 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_provider | With 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_operate | With confirm_plan_changes, your level on the account is not operate. | See your access level. |
forbidden_action | An 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 account | Nothing. The row is ALREADY_EXISTS and only its customers are imported. |
| A managed account you provisioned, in the same environment and not claimed | It updates the fields of the list below that the row carries, and nothing else. |
| A managed account its holder already claimed | WARNING managed_account_claimed. Nothing changes: those details are the holder's now. |
| A managed account whose management ended | BLOCKED management_ended. |
An external_ref of the other environment, or of an account you no longer manage | BLOCKED, 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_idis anERROR. - The access level never changes. A lower level in the file is a
WARNINGand a higher one isBLOCKED. - 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_account | Its external_ref. |
own_nif | Its 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.exampleThe 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_code | Why its customers are not imported |
|---|---|
nif_not_applied | The row was not applied: it is BLOCKED or ERROR. |
operate_access_required | Your account does not have operate on the managed account. |
account_admin_required | The 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_scope | Your own tax ID is outside the tax ID scope of the key and this import did not register it. |
insufficient_scope | The key does not have contacts:write. |
customers_import_failed | The 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:
code | status | What it means |
|---|---|---|
own_company_focus_required | ERROR | Your account has several tax IDs and none is in focus. |
insufficient_scope | ERROR | The key does not have contacts:write. |
live_credential_required | ERROR | The import runs with a test key. |
account_admin_required or operate_access_required | BLOCKED | The person who issued the key cannot write in that company. |
nif_not_in_key_scope | BLOCKED | That company is outside the tax ID scope of the key. |
customers_import_failed | BLOCKED | The 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
}
}| Field | What it is |
|---|---|
id | The identifier of the execution, a UUID v7. |
object | Always managed_account_import. |
status | The state of the execution: completed when it ended. failed when it could not finish, and then the rows already applied stay applied. |
stats | The numbers of the execution: the rows by status, created, reconciled, customers_queued, monthly_amount_cents and proration_amount_cents. |
rows | The rows of the preview, in file order, with claim_token and claim_url added. |
own_company_customers | As 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:
| What | What it does |
|---|---|
Idempotency-Key | If 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 ID | They 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 time | The 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_accountrows create or reconcile test accounts: no charge, no Stripe and no voucher, soclaim_tokenandclaim_urlarenull. A test account does not count againstmax_managed_accounts.own_nifrows come outERRORwithcodelive_credential_requiredand add 0 to the total: the tax IDs of your own account are only registered in live mode. The same goes for the customers thatapply_customers_to_own_companywould load into your own company.- The environment is the key's. A test import only sees your test accounts: an
external_refthat belongs to a live account comes outBLOCKEDwithmanaged_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 mirrorsPOST /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 needsseries:write.issuing-readinesstells you whether a tax ID still lacks one:no_default_seriesis one of its blockers, as what blocks a tax ID from issuing explains. The{company}of an imported tax ID is incompanies[].idofGET /v1/accounts/{account}for a managed account, whose{account}is theaccount_idof the row, and inGET /v1/accounts/{account}/companiesfor 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:
| Code | Status | When |
|---|---|---|
insufficient_scope | 403 | The key does not list accounts:write, or accounts:read for the template. * does not count. |
privileged_scope_required | 403 | Your 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_value | 422 | accounts_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_large | 413 | A file is over 5 MB. error.param names it. |
payload_too_large | 413 | The whole request is over 11 MB. |
import_row_limit_exceeded | 422 | accounts_file has more than 100 rows, or customers_file more than 1,000. |
import_file_invalid | 422 | The file cannot be read, has no header row or lacks a column. |
idempotency_key_required | 422 | The apply carries no Idempotency-Key. |
idempotency_key_reused | 422 | That key was already used with a different request. |
import_in_progress | 409 | Another import of your account is running. |
upload_in_flight_budget_exceeded | 429 | Your 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
provision one account at a time, the claim voucher, your access level and the end of the management.
what you pay for each managed account and how a plan change is charged.
run the whole flow, with an import, using a test key and no charge.
what an own_nif row costs, which plans allow it and the cap.
receive account.import_completed and the events of the accounts you provision.
every code of the account model, with its cause and what to do.