This domain supports two ways of moving your catalog via CSV: a legacy synchronous flow (useful for small files, responds within the same request) and an asynchronous flow (uploads the file to S3, queues the processing, and you poll progress by jobId), for both import and export.
When do I use the synchronous flow vs. the asynchronous one?
The synchronous flow (POST /products/bulk/csv/import without s3Key, POST /products/export-csv) is simple but limited by the HTTP request timeout — use it only for small files. The asynchronous flow (with an s3Key involved, 202 + jobId response) is recommended for large catalogs: upload the file to S3 with a presigned URL, queue the job, and poll for status.
1. POST /products/bulk/csv/upload-url → { url, fields, s3Key }2. (the client uploads the CSV directly to S3 with those fields)3. POST /products/bulk/csv/validate → { rows, validRows, invalidRows[], ... } (optional, recommended)4. POST /products/bulk/csv/import { s3Key } → 202 { jobId }5. GET /products/bulk/csv/import/{jobId} → polling until status stops being in progress
The URL expires after 600 seconds. The uploaded file cannot exceed 25 MiB. The bucket enforces server-side encryption (SSE) on upload. The keymust always start with the prefix uploads/{tenantId}/ — if you build your own s3Key instead of using the one this endpoint returns, any other path will be rejected as unauthorized access (see forbidden/invalid-s3-key errors).
curl -X POST https://api.fenicia.io/products/bulk/csv/upload-url \ -H "Authorization: Bearer fkapi_your_api_key"
{ "code": "forbidden/invalid-s3-key", "message": "s3Key does not belong to this tenant" }
Required permission:products:import
Shape of invalidRows[] partially confirmed
The wrapper (rows, validRows, invalidRows, exceedsRowCap, exceedsVariantCap, structuralErrors) is confirmed. The exact shape of each element within invalidRows[] was not verified line by line — treat it as indicative.
202Asynchronous — the job was queued for background processing.
{ "jobId": "csvimp_65f3a1b2c4d5e6f7a8b9c0e1" }
Required permission:products:import
207 Multi-Status = partial success, don't treat it as a generic error
When the synchronous execution finishes with some failed rows, the response is 207 Multi-Status, not 200 or 4xx. If your HTTP client only distinguishes "2xx = success" from "4xx/5xx = error" without differentiating the exact code, you will treat a partially-failed import as a total success. Always check errors[], even on 2xx.
The discriminator is s3Key, not an explicit mode parameter
There is no mode: 'sync' | 'async' field. The server decides on its own: if s3Key is present and non-empty, it runs the asynchronous flow and ignores content; otherwise, it runs the legacy synchronous flow with content. The asynchronous mode's cost caps (maxRows, maxVariantsPerProduct, chunkSize) are set by the server — they're never accepted from the client, even if you send them in config.
The audit report confirms that this endpoint returns a CSVPreviewResult, but does not detail its exact keys. The example above is indicative — confirm it against your own response.
There's an anti prototype-pollution gate on mappings
If you send a malicious mappings (for example, keys like __proto__), the server rejects it with a ValidationError before processing the file.
This dry-run is not optional in the bulk edit flow
This flow's design (internally documented as ADR-019) requires running this endpoint before applying a bulk edit via CSV — it's how you review the impact (summary) and invalid rows before committing the changes.
{ "code": "not-found/no-products", "message": "No products matched the selection" }
Required permission:products:export
This endpoint is synchronous, don't confuse it with /products/bulk/csv/export
It's a different route (/products/export-csv, without bulk) from the asynchronous export flow described below. It's meant for small exports that fit within a single HTTP request — for large catalogs, use the asynchronous flow.
{ "code": "not-found", "message": "Export job not found" }
Required permission:products:read
downloadUrl is a presigned S3 URL
downloadUrl only appears once status indicates the job finished. It's a direct download URL (presigned GET) — you don't need your Fenicia API key to download it, just to have it while it's still valid before it expires.
207 Multi-Status appears only in synchronous import
Of all the endpoints on this page, only POST /products/bulk/csv/import in its synchronous branch responds 207 on partial success. The asynchronous endpoints don't have that concept in the immediate response — the partial success of an asynchronous job is reflected in the failed/skipped counters of GET .../import/{jobId}, with status 200.