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
PUTinside 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
| Kind | Loads |
|---|---|
organisational_units | The organization structure: org units and their cost centers, keyed by legal entity |
users | People, with their permission profile, org unit, manager and led units |
suppliers | Supplier master data |
general_ledger_accounts | Chart-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
413and problem typedataimport-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
| Column | Notes |
|---|---|
Employee ID | Your stable identifier. The key, and the target of Manager Employee ID, which is an employee ID, never an email address |
Permission Profile | The name of an existing permission set, exactly as configured |
Org Unit Code | Must exist, or come from the organisation file |
Leads Org Unit Code | The 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 |
Active | Yes 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.
Related
- Push users and permissions for the REST alternative and the ordering rules.
- SSO and user provisioning for SCIM, which is the better answer when your IdP owns the directory.
- API Basics for the response envelope and error model.