File Upload
Upload CSV or JSON files for asynchronous product normalization. Supports files up to 50MB with progress tracking and paginated result retrieval.
For small datasets (under 500 products), the synchronous Batch Normalization endpoint may be simpler.
Workflow
The file upload workflow has three steps:
- Upload — Submit your file via
POST /v1/files/upload - Poll — Check job status via
GET /v1/files/{job_id}/status - Retrieve — Fetch results via
GET /v1/files/{job_id}/results
Step 1: Upload File
POST https://api.cannmenus.com/v1/files/upload
Content-Type: multipart/form-data
Parameters
- Name
file- Type
- file
- Description
CSV or JSON file containing raw product data. Max size: 50MB.
- Name
provider- Type
- string
- Description
POS provider name (e.g.,
"dutchie","generic").
- Name
state- Type
- string
- Description
US state abbreviation (e.g.,
"CO","CA").
- Name
match_existing- Type
- boolean
- Description
Whether to attempt UUID matching against existing products. Default:
true.
- Name
column_mapping- Type
- string
- Description
JSON string mapping your CSV column names to standard field names. See Column Mapping.
Example
curl -X POST "https://api.cannmenus.com/v1/files/upload" \
-H "X-Token: YOUR_API_TOKEN" \
-F "file=@products_export.csv" \
-F "provider=generic" \
-F "state=CO" \
-F "match_existing=true" \
-F 'column_mapping={"Product Name": "name", "Category": "category", "Vendor": "brand", "Weight": "weight", "Price": "price"}'
Response
{
"job_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"status": "queued",
"filename": "products_export.csv",
"file_size_bytes": 2048576,
"estimated_products": null,
"created_at": "2026-02-01T12:00:00Z"
}
Save the job_id — you'll need it to check status and retrieve results.
Step 2: Poll Status
GET https://api.cannmenus.com/v1/files/{job_id}/status
Example
curl "https://api.cannmenus.com/v1/files/f47ac10b-58cc-4372-a567-0e02b2c3d479/status" \
-H "X-Token: YOUR_API_TOKEN"
Response
{
"job_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"status": "processing",
"progress_percent": 45.2,
"total_products": 1500,
"products_processed": 678,
"products_matched": 423,
"errors_count": 3,
"created_at": "2026-02-01T12:00:00Z",
"started_at": "2026-02-01T12:00:01Z",
"completed_at": null,
"error_message": null
}
Job Statuses
| Status | Description |
|---|---|
queued | Job is waiting to be processed |
processing | Normalization is in progress — check progress_percent |
completed | All products normalized — results ready for retrieval |
failed | Job failed — check error_message for details |
Polling Pattern (Python)
import time
import requests
API_URL = "https://api.cannmenus.com/v1"
headers = {"X-Token": "YOUR_API_TOKEN"}
job_id = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
while True:
status = requests.get(
f"{API_URL}/files/{job_id}/status",
headers=headers,
).json()
print(f"Status: {status['status']} — {status.get('progress_percent', 0)}%")
if status["status"] == "completed":
break
elif status["status"] == "failed":
print(f"Job failed: {status['error_message']}")
break
time.sleep(5) # Poll every 5 seconds
Step 3: Retrieve Results
GET https://api.cannmenus.com/v1/files/{job_id}/results
Parameters
- Name
offset- Type
- integer
- Description
Number of products to skip. Default:
0.
- Name
limit- Type
- integer
- Description
Maximum products to return. Default:
100.
Example
# First 100 results
curl "https://api.cannmenus.com/v1/files/f47ac10b-58cc-4372-a567-0e02b2c3d479/results?offset=0&limit=100" \
-H "X-Token: YOUR_API_TOKEN"
# Next 100 results
curl "https://api.cannmenus.com/v1/files/f47ac10b-58cc-4372-a567-0e02b2c3d479/results?offset=100&limit=100" \
-H "X-Token: YOUR_API_TOKEN"
Response
{
"job_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"status": "completed",
"total_products": 1500,
"total_matched": 1123,
"offset": 0,
"limit": 100,
"products": [
{
"display_product_name": "Dog Patch",
"raw_product_name": "Happy Valley - Dog Patch - 3.5g",
"cann_product_category": "Flower",
"brand_name": "Happy Valley",
"brand_fuzzy_match_score": 0.95,
"display_weight": "3.5g",
"current_price": 52.0,
"state": "MA"
}
],
"errors": []
}
The products array contains the same Normalized Product fields as the batch endpoint.
Fetching All Results (Python)
import requests
API_URL = "https://api.cannmenus.com/v1"
headers = {"X-Token": "YOUR_API_TOKEN"}
job_id = "f47ac10b-58cc-4372-a567-0e02b2c3d479"
all_products = []
offset = 0
limit = 100
while True:
results = requests.get(
f"{API_URL}/files/{job_id}/results",
headers=headers,
params={"offset": offset, "limit": limit},
).json()
all_products.extend(results["products"])
if offset + limit >= results["total_products"]:
break
offset += limit
print(f"Retrieved {len(all_products)} normalized products")
Column Mapping
When uploading CSV files with non-standard column names, provide a column_mapping to map your columns to CANN's expected field names.
Standard Field Names
| Field | Description |
|---|---|
name | Product name |
brand | Brand name |
category | Product category |
subcategory | Product subcategory |
price | Retail price |
weight | Product weight/size |
id | External product ID |
thc | THC content |
cbd | CBD content |
image | Product image URL |
Example
If your CSV has columns like "Product Name", "Vendor Name", "Unit Weight":
{
"Product Name": "name",
"Vendor Name": "brand",
"Category": "category",
"Unit Weight": "weight",
"Sale Price": "price",
"Product ID": "id",
"THC %": "thc",
"CBD %": "cbd"
}
Pass this as a JSON string in the column_mapping form field.
If you don't provide a column mapping, the generic normalizer will use heuristics to detect common column name patterns (e.g., "product_name", "Product", "item_name" all map to name).
Supported File Formats
CSV
Standard comma-separated file. First row must be column headers. Example:
Product Name,Category,Brand,Weight,Price,THC
Blue Dream 3.5g,Flower,Cookies,3.5g,55.00,28%
Gummy Bears 100mg,Edible,Wana,100mg,25.00,10mg THC
Live Resin Cart 1g,Vaporizer,Select,1g,45.00,85%
JSON
Array of product objects:
[
{
"name": "Blue Dream 3.5g",
"category": "Flower",
"brand": "Cookies",
"weight": "3.5g",
"price": 55.00,
"thc": "28%"
}
]
Error Handling
Upload Errors
| Status | Description |
|---|---|
400 | Invalid file type (must be .csv or .json) or bad column_mapping JSON |
401 | Invalid or missing API token |
413 | File too large (max 50MB) |
Job Failures
If a job fails, the status response will include an error_message explaining what went wrong. Common causes:
- Malformed CSV/JSON that can't be parsed
- Empty file with no product data
- Server-side processing errors
Individual product normalization errors are reported in the errors array of the results response, not as job-level failures.
