File Upload

Upload CSV or JSON files for asynchronous product normalization. Supports files up to 50MB with progress tracking and paginated result retrieval.


Workflow

The file upload workflow has three steps:

  1. Upload — Submit your file via POST /v1/files/upload
  2. Poll — Check job status via GET /v1/files/{job_id}/status
  3. 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

StatusDescription
queuedJob is waiting to be processed
processingNormalization is in progress — check progress_percent
completedAll products normalized — results ready for retrieval
failedJob 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

FieldDescription
nameProduct name
brandBrand name
categoryProduct category
subcategoryProduct subcategory
priceRetail price
weightProduct weight/size
idExternal product ID
thcTHC content
cbdCBD content
imageProduct 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.


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

StatusDescription
400Invalid file type (must be .csv or .json) or bad column_mapping JSON
401Invalid or missing API token
413File 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.