Bulk CSV import and export

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

Asynchronous export flow

1. POST /products/bulk/csv/export             → 202 { jobId }
2. GET  /products/bulk/csv/export/{jobId}     → polling; downloadUrl appears when it finishes

Get the upload URL (step 1 of the async import)

Generates a presigned S3 URL to upload the CSV directly from the client, without routing the file through the lambda.

{}
200
{
  "url": "https://fenicia-csv-imports-prod.s3.amazonaws.com/",
  "fields": {
    "key": "uploads/65f2a0b1c4d5e6f7a8b9c0aa/9c1e2f3a-....csv",
    "policy": "eyJleHBpcmF0aW9uIjoi...",
    "x-amz-signature": "..."
  },
  "s3Key": "uploads/65f2a0b1c4d5e6f7a8b9c0aa/9c1e2f3a-....csv"
}

Required permission: products:import

Limits of the presigned URL

The URL expires after 600 seconds. The uploaded file cannot exceed 25 MiB. The bucket enforces server-side encryption (SSE) on upload. The key must 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"

Structurally validates the CSV already uploaded to S3, without applying changes. Detects whether it exceeds the row or variants-per-product limits.

s3Keystringrequired

S3 key returned by upload-url.

configobject

Import configuration (see POST /products/bulk/csv/import).

{
  "s3Key": "uploads/65f2a0b1c4d5e6f7a8b9c0aa/9c1e2f3a-....csv",
  "config": { "format": "fenicia", "createIfNotExists": true }
}
200
{
  "rows": 4200,
  "validRows": 4180,
  "invalidRows": [
    { "row": 87, "sku": "CAM-ROJO-M", "errors": ["price is required"] }
  ],
  "exceedsRowCap": false,
  "exceedsVariantCap": false,
  "structuralErrors": []
}
403
{ "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.


Import by CSV

Imports products from CSV. The body determines whether execution is synchronous (inline content) or asynchronous (s3Key of an already-uploaded file).

s3Keystring

S3 key of the already-uploaded file. If present and non-empty, execution is asynchronous (202).

configobject

Async mode only. Server-sanitized allowlist: format, mappings, fieldsToUpdate, skipErrorRows, operationName, createIfNotExists, enableSideEffects.

contentstring

Sync mode only. Raw CSV content.

formatstring

Sync mode only. fenicia, shopify, or mercadolibre.

mappingsobject

Sync mode only. Mapping of CSV columns to product fields.

createIfNotExistsboolean

Sync mode only. Creates the product if the SKU doesn't exist. Default: false.

skipErrorRowsboolean

Sync mode only. Continues the import skipping rows with errors. Default: true.

batchSizenumber

Sync mode only. Processing batch size. Default: 100.

operationNamestring

Visible name of the operation in the bulk operations history.

fieldsToUpdatestring[]

Restricts the update to only these fields.

{
  "content": "sku,title,price\nCAM-ROJO-M,Camisa Roja M,499.00\n",
  "format": "fenicia",
  "createIfNotExists": true
}
{
  "s3Key": "uploads/65f2a0b1c4d5e6f7a8b9c0aa/9c1e2f3a-....csv",
  "config": { "format": "fenicia", "createIfNotExists": true }
}
200Synchronous — total success.
{
  "totalProcessed": 320,
  "created": 12,
  "updated": 305,
  "skipped": 3,
  "errors": []
}
207Synchronous — partial success. Review errors[] row by row.
{
  "totalProcessed": 320,
  "created": 10,
  "updated": 298,
  "skipped": 4,
  "errors": [
    { "row": 45, "sku": "PAN-AZUL-32", "message": "duplicate-sku" }
  ]
}
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.

curl -X POST https://api.fenicia.io/products/bulk/csv/import \
  -H "Authorization: Bearer fkapi_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "sku,title,price\nCAM-ROJO-M,Camisa Roja M,499.00\n",
    "format": "fenicia",
    "createIfNotExists": true
  }'

Check the status of an asynchronous import

Checks the progress of a queued asynchronous import.

jobIdstringrequired

ID of the job returned by POST /products/bulk/csv/import.

200
{
  "jobId": "csvimp_65f3a1b2c4d5e6f7a8b9c0e1",
  "status": "processing",
  "percent": 62,
  "processedRows": 2604,
  "totalRows": 4200,
  "created": 80,
  "updated": 2500,
  "failed": 24,
  "skipped": 0,
  "errorReportUrl": null
}
404
{ "code": "not-found", "message": "Import job not found" }

Required permission: products:read

Tip

When the job finishes with failed rows, errorReportUrl stops being null and points to a downloadable CSV with the row-by-row detail of every error.


Download a CSV template

Downloads an empty CSV template with the correct columns for the requested format.

formatstringrequired

fenicia, shopify, or mercadolibre.

{ "format": "fenicia" }
200Raw CSV, not JSON.
Content-Type: text/csv
Content-Disposition: attachment; filename="template-fenicia.csv"
 
sku,title,price,status,...
400
{ "code": "bad-request", "message": "Unsupported format" }

Required permission: no additional permission confirmed beyond the authenticated session.


Preview an import (dry-run)

Parses the CSV and returns the result without persisting anything — for review before importing.

contentstringrequired

Raw CSV content.

formatstring

fenicia, shopify, or mercadolibre.

mappingsobject

Mapping of columns to product fields.

createIfNotExistsboolean

Simulates creation if the SKU doesn't exist.

fieldsToUpdatestring[]

Restricts the preview to these fields.

{
  "content": "sku,title,price\nCAM-ROJO-M,Camisa Roja M,499.00\n",
  "format": "fenicia",
  "createIfNotExists": true
}
200
{
  "toCreate": 12,
  "toUpdate": 305,
  "toSkip": 3,
  "errors": []
}

Required permission: products:import

Indicative shape, not verified field by field

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.


Preview a bulk edit by CSV (mandatory dry-run)

Like preview, but for the bulk edit flow: you must always run this dry-run before applying the changes.

contentstringrequired

Raw CSV content with the changes to apply.

formatstring

fenicia, shopify, or mercadolibre.

mappingsobject

Mapping of columns to product fields.

createIfNotExistsboolean

Simulates creation if the SKU doesn't exist.

{
  "content": "sku,price\nCAM-ROJO-M,549.00\n",
  "format": "fenicia"
}
200
{
  "products": [],
  "invalidRows": [],
  "summary": { "toUpdate": 305, "toCreate": 12, "invalid": 3 }
}

Required permission: products:update

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.


Export the catalog to CSV (synchronous)

Exports products to CSV synchronously. Only supported format: fenicia.

formatstringrequired

Only supported value: fenicia.

selectionstringrequired

selected, filter, or all.

productIdsstring[]

Required when selection is selected.

filtersobject

Required when selection is filter.

{ "format": "fenicia", "selection": "all" }
200Raw CSV, not JSON.
Content-Type: text/csv
Content-Disposition: attachment; filename="productos-2026-04-11.csv"
 
sku,title,price,status
CAM-ROJO-M,Camisa Roja M,499.00,active
400
{ "code": "bad-request", "message": "Unsupported format" }
404
{ "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.


Export the catalog to CSV (asynchronous)

Queues a bulk CSV export. Always responds 202 with a jobId.

formatstringrequired

Only supported value: fenicia.

selectionstringrequired

selected, filter, or all.

productIdsstring[]

Required when selection is selected. Maximum 5000 IDs.

filtersobject

Required when selection is filter.

{ "format": "fenicia", "selection": "all" }
202
{ "jobId": "csvexp_65f3a1b2c4d5e6f7a8b9c0e2" }
400
{ "code": "too-many-products", "message": "productIds exceeds the maximum of 5000" }

Required permission: products:export

curl -X POST https://api.fenicia.io/products/bulk/csv/export \
  -H "Authorization: Bearer fkapi_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "format": "fenicia", "selection": "all" }'

Check the status of an asynchronous export

Checks the progress of a queued asynchronous export. When it finishes, it includes the download URL.

jobIdstringrequired

ID of the job returned by POST /products/bulk/csv/export.

200
{
  "jobId": "csvexp_65f3a1b2c4d5e6f7a8b9c0e2",
  "status": "completed",
  "percent": 100,
  "processedRows": 8300,
  "totalRows": 8300,
  "count": 8300,
  "downloadUrl": "https://fenicia-csv-imports-prod.s3.amazonaws.com/exports/65f2a0b1c4d5e6f7a8b9c0aa/csvexp_.../productos.csv?X-Amz-Signature=..."
}
404
{ "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.


Errors

CodeStatusDescription
bad-request400A required field is missing from the body (depending on the endpoint).
bad-request/missing-s3-key400The asynchronous import body doesn't carry s3Key.
forbidden/invalid-s3-key403The s3Key doesn't start with your tenant's prefix (uploads/{tenantId}/) — it doesn't belong to you.
not-found/s3-object404The object referenced by s3Key doesn't exist in the bucket.
bad-request/file-too-large400The uploaded CSV exceeds the maximum allowed size.
bad-request/invalid-config400The import configuration (config) is invalid.
bad-request/invalid-import-file400The file doesn't have a valid CSV structure.
rate-limited429The limit of concurrent asynchronous operations / per time window was reached.
unsupported-format400The requested format isn't supported (asynchronous export only supports fenicia).
invalid-selection400The selection value isn't selected, filter, or all.
missing-products400selection: 'selected' without productIds.
too-many-products400productIds exceeds the maximum of 5000.
missing-filters400selection: 'filter' without filters.
not-found/no-products404(synchronous export) The selection resolved no products.
duplicate-sku409A row's SKU already exists (when createIfNotExists doesn't allow overwriting it).
not-found404No job (jobId) exists with that identifier.
auth:invalid_token401The API key is invalid or has been revoked.
auth:permission_denied403The API key doesn't have the required permission (products:import, products:export, or products:update depending on the endpoint).

See the full error catalog for the rest of the possible codes.

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.

Next steps