Bulk import
Send a whole billing history in one job — up to 1,000 objects per call — instead of a request per object.
Bulk import
POST https://api.faurya.com/api/v1/billing/imports
POST https://api.faurya.com/api/v1/billing/imports/{id}/items
POST https://api.faurya.com/api/v1/billing/imports/{id}/commit
POST https://api.faurya.com/api/v1/billing/imports/{id}/cancel
GET https://api.faurya.com/api/v1/billing/imports/{id}Requires Bearer Token authentication with your Faurya API key.
The single-object routes are for your live billing flow: one request as each thing happens. Bulk import is for the other job — moving years of history in, from your own database or from another analytics tool — without making tens of thousands of requests or hitting the rate limit.
The transport is different. Nothing else is. Each item is validated by the same parser the single route uses, and recorded by the same code, so an invoice sent in bulk gets the same rules, the same refusals and the same derived subscriptions as one sent on its own.
The flow
- Open an import.
- Upload your objects, up to 1,000 per call, over as many calls as you need. Any order.
- Commit it. Faurya closes the import and queues the work.
- Poll
GET /imports/{id}until it finishes.
const auth = {
Authorization: `Bearer ${process.env.FAURYA_API_KEY}`,
"Content-Type": "application/json",
};
// 1. open
const opened = await fetch("https://api.faurya.com/api/v1/billing/imports", {
method: "POST",
headers: auth,
body: JSON.stringify({ external_id: "migration_2026_03" }),
}).then((r) => r.json());
const importId = opened.data.import_id;
// 2. upload
await fetch(`https://api.faurya.com/api/v1/billing/imports/${importId}/items`, {
method: "POST",
headers: auth,
body: JSON.stringify({
items: [
{ type: "customer", external_id: "cus_42", email: "ada@example.com" },
{
type: "plan",
external_id: "plan_pro_monthly",
name: "Pro",
interval_unit: "month",
interval_count: 1,
},
{
type: "invoice",
external_id: "inv_1042",
customer_external_id: "cus_42",
currency: "USD",
invoiced_at: "2026-03-01T00:00:00Z",
line_items: [
{
external_id: "li_1",
type: "subscription",
subscription_external_id: "sub_900",
plan_external_id: "plan_pro_monthly",
amount: 49,
service_period_start: "2026-03-01T00:00:00Z",
service_period_end: "2026-04-01T00:00:00Z",
},
],
},
{
type: "transaction",
external_id: "txn_7781",
invoice_external_id: "inv_1042",
transaction_type: "payment",
amount: 49,
occurred_at: "2026-03-01T09:14:00Z",
},
],
}),
}).then((r) => r.json());
// 3. commit
await fetch(`https://api.faurya.com/api/v1/billing/imports/${importId}/commit`, {
method: "POST",
headers: auth,
});
// 4. poll
const status = await fetch(
`https://api.faurya.com/api/v1/billing/imports/${importId}`,
{ headers: auth },
).then((r) => r.json());1. Open an import
POST /imports takes one optional field:
external_id(string): Your own reference for this migration.
It is worth sending. If your request times out and you retry, the same reference hands back the same open import (created: false) instead of silently splitting your migration in two. A reference whose import has already been committed is a different matter — that work is done or running, and reusing the name is refused with 409 IMPORT_NOT_OPEN.
{
"object": "import",
"external_id": "cl9x7k2p40000qzrm1a2b3c4d",
"created": true,
"data": {
"import_id": "cl9x7k2p40000qzrm1a2b3c4d",
"external_id": "migration_2026_03",
"status": "open",
"accepted_count": 0,
"rejected_count": 0,
"items": { "total": 0, "pending": 0, "done": 0, "failed": 0, "skipped": 0 },
"by_type": [],
"failures": [],
"created_at": "2026-03-20T10:00:00.000Z",
"committed_at": null,
"started_at": null,
"finished_at": null,
"expires_at": "2026-03-27T10:00:00.000Z"
},
"warnings": [],
"request_id": "req_01JQ8Z0Y5N6W3K2M4P7R9T1V3X"
}data.import_id is what every later call uses. A site may hold 3 imports open at once; a fourth is 409 TOO_MANY_OPEN_IMPORTS.
2. Upload items
POST /imports/{id}/items takes exactly one field:
items(array, required): 1 to 1,000 items.
Each item is a single-route body with two extra fields:
type(string, required):customer,plan,invoice,transactionorsubscription_event.external_id(string, required): the id that would have been in the path.
Everything else is the body that route documents. The one exception: a transaction's own kind is sent as transaction_type, because type already names the item kind.
One bad item does not cost you the call
The call answers 200 with everything it accepted and everything it refused, named by index:
{
"object": "import_items",
"external_id": "cl9x7k2p40000qzrm1a2b3c4d",
"created": false,
"data": {
"accepted": 998,
"replaced": [{ "type": "invoice", "external_id": "inv_1042" }],
"rejected": [
{
"index": 17,
"type": "invoice",
"external_id": "inv_1059",
"errors": [
{
"field": "items[17].line_items[0].amount",
"code": "TOO_MANY_DECIMALS",
"message": "USD amounts have at most 2 decimal places"
}
]
}
]
},
"warnings": [],
"request_id": "req_01JQ8Z0Y5N6W3K2M4P7R9T1V3X"
}accepted— how many items were staged.replaced— ids you had already sent in this import. A resend is an upsert: the later statement wins, exactly as a secondPUTwould.rejected— every refused item, with its index in your array and every problem in it. Rejections are never truncated — a refused item is work you have to redo, so each one is named.warnings— up to 100 per call, each with anitems[i].…path. If there were more,data.warnings_truncatedsays how many were left out.
Only a broken envelope fails the whole call: a body that is not an object, a missing or empty items, more than 1,000 items, or an unknown top-level field.
Order does not matter here
Upload customers, invoices and transactions interleaved, across any number of calls. The import runs them in dependency order for you:
customers → plans → invoices → transactions → subscription eventsCross-references are not checked at upload — the customer an invoice names may be in a later call. They are checked when the import runs, and an item whose reference is still missing then is reported in failures.
3. Commit
POST /imports/{id}/commit takes an empty body. It closes the import and queues the work, answering 202 Accepted with the import's current state. The processing itself happens in the background — a migration is a job, not a request.
Re-sending a commit is safe: a committed or processing import answers 202 again and nothing is queued twice.
4. Watch it run
GET /imports/{id} is where progress lives.
{
"object": "import",
"external_id": "cl9x7k2p40000qzrm1a2b3c4d",
"data": {
"import_id": "cl9x7k2p40000qzrm1a2b3c4d",
"external_id": "migration_2026_03",
"status": "processing",
"accepted_count": 4210,
"rejected_count": 3,
"items": { "total": 4208, "pending": 1200, "done": 3005, "failed": 3, "skipped": 0 },
"by_type": [
{ "type": "customer", "total": 900, "pending": 0, "done": 900, "failed": 0, "skipped": 0 },
{ "type": "plan", "total": 8, "pending": 0, "done": 8, "failed": 0, "skipped": 0 },
{ "type": "invoice", "total": 2100, "pending": 0, "done": 2097, "failed": 3, "skipped": 0 },
{ "type": "transaction", "total": 1100, "pending": 1100, "done": 0, "failed": 0, "skipped": 0 },
{ "type": "subscription_event", "total": 100, "pending": 100, "done": 0, "failed": 0, "skipped": 0 }
],
"failures": [
{ "type": "invoice", "external_id": "inv_2200", "error": "CUSTOMER_NOT_FOUND" }
],
"created_at": "2026-03-20T10:00:00.000Z",
"committed_at": "2026-03-20T10:42:00.000Z",
"started_at": "2026-03-20T10:42:05.000Z",
"finished_at": null,
"expires_at": "2026-03-27T10:00:00.000Z"
},
"request_id": "req_01JQ8Z0Y5N6W3K2M4P7R9T1V3X"
}by_type is in processing order, so you can see which stage the run is on rather than one opaque percentage. failures lists items a run refused, each with the error code it was refused with — most often CUSTOMER_NOT_FOUND or PLAN_NOT_FOUND for a reference that was never uploaded. Items the upload refused are not here; the /items call that refused them named every one.
Statuses
status | Meaning |
|---|---|
open | Accepting items. |
queued | Committed and waiting for a worker. |
processing | Running. |
succeeded | Every item recorded. |
succeeded_with_errors | Finished; some items failed. See failures. |
failed | The run could not complete. |
cancelled | You cancelled it. |
expired | It sat open past its deadline and was swept. |
Every item the run records marks its customer for a revenue refresh, so your MRR, subscriber and churn figures catch up on their own as the import works through — there is nothing to trigger afterwards.
Cancel
POST /imports/{id}/cancel takes an empty body and abandons the import. From open or queued nothing has run, so every staged item is skipped at once. From processing the run stops between pages and skips what it never reached. A finished import cannot be cancelled (409 IMPORT_NOT_OPEN).
Limits
Items per /items call | 1,000 |
| Request body | 5 MB |
| Open imports per site | 3 |
| How long an import may stay open | 7 days |
| How long a run has to finish | 14 days from opening |
| How long failures stay readable | 30 days after finishing |
| Staged data per import | 1 GiB compressed |
An import that would exceed its data ceiling is refused with 409 IMPORT_TOO_LARGE; commit it and open another for the rest.
Response codes
200 OK— items accepted, import cancelled, or a status read.201 Created— a new import was opened.202 Accepted— the import was committed and queued.400 Bad Request— a broken envelope, or an unknown top-level field.401 Unauthorized— missing or invalid API key.404 Not Found— no import with that id.409 Conflict—IMPORT_NOT_OPEN,IMPORT_TOO_LARGEorTOO_MANY_OPEN_IMPORTS.413 Payload Too Large— the body is over 5 MB. Send fewer items per call.429 Too Many Requests— over the rate limit.503 Service Unavailable— we could not stage your payload. Nothing was stored; retry.