Skip to main content

Data import via blob storage

The bulk path into SpendOne. Your ETL drops CSV files into a write-only landing zone in SpendOne's object storage; a worker picks them up on a schedule, validates them and reports the outcome row by row. A clean master-data foundation is what lets the platform route approvals, allocate cost and reconcile cards correctly, so this is usually the first substantial integration a new customer builds.

The same pipeline also accepts a direct HTTP upload, for a one-off file or an admin correction. Both land in the same inbox and produce the same job.

Every route on this page is gated on the ADMIN role.

How it works​

your ETL SpendOne
│
│ S3 PUT (SigV4, write-only key)
▼
tenant/{tenantID}/imports/inbox/<kind>/<file>.csv
│
│ poll worker, every 3 minutes
▼
claim: the file is moved out of your reach into a processing zone
│
▼
dispatch: the importer for <kind> validates and writes
│
▼
import job: SUCCEEDED / PARTIAL / FAILED, with per-row errors

Three properties follow from that shape, and all three matter when you build against it:

  • The landing zone is write-only. The access key can PUT inside your tenant's inbox prefix and nothing else: no read, no list, no access to any other prefix. You cannot read back what you wrote, which is why the job endpoints exist.
  • Import is asynchronous. Dropping a file tells you nothing about whether the rows were valid. There is no synchronous validation pass and no dry-run flag.
  • The same bytes never import twice. Claiming is keyed on the (tenant, object key, etag) triple, so a retry of an identical file is deduplicated. Overwriting a path with changed content is a new etag and therefore a new import. To re-run identical content deliberately, use a new file name.

Configuring it​

1. Issue an access key​

curl -X POST https://api.spendone.tech/api/v1/admin/dataimport/credentials \
-H "Authorization: Bearer $TOKEN"
{
"data": {
"access_key_id": "…",
"secret_key": "…"
}
}

The secret is returned in this response only. SpendOne never persists it, so it cannot appear in a later response or a log line. Store it when you create it.

GET /api/v1/admin/dataimport/credentials # live keys, IDs only
POST /api/v1/admin/dataimport/credentials # issue one
DELETE /api/v1/admin/dataimport/credentials/{id} # revoke, immediately

Several keys can be live at once, which is how you rotate: create the new one, deploy it, delete the old one. There is no automatic rotation and no grace period on a delete.

2. Find your push URL​

curl https://api.spendone.tech/api/v1/admin/dataimport/kinds \
-H "Authorization: Bearer $TOKEN"
{
"data": {
"kinds": [
{
"kind": "suppliers",
"inbox_folder": "imports/inbox/suppliers/",
"uri": "https://…/<bucket>/tenant/<tenant-id>/imports/inbox/suppliers/"
}
]
}
}

uri is the full, ready-to-use destination, rendered for your tenant and this environment's storage endpoint and bucket. Read it from here rather than composing it: the endpoint and bucket differ per environment, and only registered kinds are listed, so this is also the authoritative answer to which kinds this deployment accepts.

3. Push a file​

Any S3 client that speaks SigV4 works:

aws s3 cp suppliers-2026-09.csv \
"s3://<bucket>/tenant/<tenant-id>/imports/inbox/suppliers/suppliers-2026-09.csv" \
--endpoint-url https://<storage-endpoint>

The poll runs every 3 minutes and takes up to 20 files per tenant per cycle.

4. Read the outcome​

GET /api/v1/admin/dataimport/jobs # newest first; filter by kind and status
GET /api/v1/admin/dataimport/jobs/{code} # one job, with its row errors

A job carries kind, status, source, object_key, etag, file_size, the counters total / successful / failed / skipped, started_at and completed_at, and on failure an error_message. GET …/jobs/{code} adds the per-row errors, each with row, column and message, up to 1,000 stored per job.

Statuses are pending, running, succeeded, partial and failed. partial is the one to watch: the file imported, but some rows did not, and only the row errors say which.

A file dropped into an unregistered folder is claimed anyway and recorded as a failed job reading "unknown import kind", rather than being silently left behind.

The import kinds​

KindLoads
organisational_unitsThe organization structure: org units and their cost centers, keyed by legal entity
usersPeople, with their permission profile, org unit, manager and led units
suppliersSupplier master data
general_ledger_accountsChart-of-accounts entries, including a raw DATEV Sachkontenstamm export

Order matters across kinds: organisational_units before users, because the users file references org unit codes. Legal entities are not an import kind and must exist first; create them with POST /legal-entities. Products are not an import kind either; use POST /products.

GET /admin/dataimport/kinds/{kind}/example downloads the template for one kind: a commented CSV with the exact header row the importer expects and example rows to edit or delete. Start from that file rather than from this page: it is generated from the importer itself and cannot drift from it.

File format​

  • The delimiter is a semicolon (;), not a comma. This is the most common cause of a rejected file.
  • UTF-8, with a header row first. Lines starting with # are comments and are ignored.
  • Header matching is case-insensitive and ignores surrounding whitespace. Only the canonical English column names are recognized; there are no localized aliases.
  • Maximum 50 MB per file (larger is rejected with 413 and problem type dataimport-upload-too-large) and at most 50,000 rows.

organisational_units​

One row per org unit. Columns must appear in this exact order.

Legal Entity Code;Org Unit Code;Org Unit Name;Parent Org Unit Code;Cost Centre Code;Cost Centre Name
1011;OU-1000;Finance;;CC-1000;Finance
1011;OU-1010;Accounting;OU-1000;CC-1010;Accounting

Legal Entity Code must already exist. Parent Org Unit Code may name a unit defined further down the same file or already in SpendOne; leave it blank for a top-level unit. Cycles are rejected. The British spelling of "Centre" in those two headers is deliberate; match it exactly.

users​

One row per person. Order of columns is free, because the header is matched by name; order of rows does not matter either, because the writer reorders each batch managers-first.

Employee ID;First Name;Last Name;Email;Permission Profile;Org Unit Code;Manager Employee ID;Leads Org Unit Code;Active
E1000;Erika;Musterfrau;erika@example.com;Approver;OU-1000;;OU-1000;Yes
E1001;Max;Mustermann;max@example.com;Standard;OU-1010;E1000;;Yes
ColumnNotes
Employee IDYour stable identifier. The key, and the target of Manager Employee ID, which is an employee ID, never an email address
Permission ProfileThe name of an existing permission set, exactly as configured
Org Unit CodeMust exist, or come from the organisation file
Leads Org Unit CodeThe units this person leads, comma-separated for several. This is the complete list: a unit they lead today but that is missing here loses its leader, and a blank cell removes them as leader everywhere
ActiveYes or No; No offboards, which also removes them as leader everywhere. Blank defaults to Yes

An unrecognized column is ignored, so an HR export carrying extra fields still imports.

suppliers and general_ledger_accounts​

Both are upserts keyed on a code you control: the supplier code, and for accounts the (entity_code, code) pair. Blank optional cells keep the current value on update. An empty entity_code loads the account into the tenant-wide chart of accounts; a named entity must have its own chart enabled or the row is rejected rather than pooled. Download the example CSVs for the full column contracts.

Uploading over HTTP instead​

For a single file or an admin correction, the same pipeline takes a multipart upload:

POST /api/v1/admin/dataimport/kinds/{kind}/upload
Authorization: Bearer <access-token>
Content-Type: multipart/form-data

One form field, file. A successful upload answers 202 Accepted:

{ "data": { "file_name": "users-2026-09.csv", "kind": "users" } }

202 means the file is stored durably and a job is enqueued. It does not mean the rows were valid or applied. Read the job, exactly as for the blob path.

Retention​

Finished import artifacts (the processing-zone copy of the file, the job row and its errors) are kept for 3 months, then removed by a daily cleanup. Pull anything you need to keep into your own system inside that window.