POST /api/v1/products
Create or update up to 1,000 products in a single request. Each row is matched and upserted by sku (primary key) then externalId (secondary key); rows with no match are created.
This endpoint manages offerings (the entity behind the products path). Set each row's type to PRODUCT (default), SERVICE, SUBSCRIPTION, or CONTRACT — see Offerings & types.
Request
POST /api/v1/products HTTP/1.1
Host: agentlyleads.com
Authorization: Bearer alk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{
"products": [
{
"name": "4G Router",
"sku": "RTR-4G",
"price": 199,
"cost": 120,
"category": "Electronics",
"unit": "each",
"stockQty": 50,
"type": "PRODUCT"
},
{
"name": "Pro Plan",
"sku": "PRO-1",
"price": 29,
"category": "SaaS",
"type": "SUBSCRIPTION",
"billingPeriod": "MONTHLY"
}
]
}The products array must contain at least 1 item and at most 1,000.
Response — 200 (all rows succeeded)
When every row in the batch succeeds, the response status is 200:
{
"created": 1,
"updated": 1,
"errors": 0,
"results": [
{ "index": 0, "status": "created", "id": "cmq1abc2def3ghi4jkl5" },
{ "index": 1, "status": "updated", "id": "cmq6mno7pqr8stu9vwx0" }
]
}Each entry in results corresponds to the product at that position in your request array (zero-indexed).
Response — 207 Multi-Status (partial success)
When at least one row fails validation, the response status is 207. Failed rows appear in results with "status": "error":
{
"created": 1,
"updated": 0,
"errors": 1,
"results": [
{ "index": 0, "status": "created", "id": "cmq1abc2def3ghi4jkl5" },
{
"index": 1,
"status": "error",
"error": "name: String must contain at least 1 character(s); price: Number must be greater than or equal to 0"
}
]
}A row that fails validation does not abort the batch. Valid rows are written; failed rows are skipped and reported per-index in results.
Merge on update
When a row matches an existing product, only the fields present in your request body are updated. Fields you omit retain their current values — this is a merge update, not a full replace.
For example, sending only { "sku": "RTR-4G", "price": 249 } updates the price without touching name, category, stockQty, or any other field.
Images
Provide product images as an array of up to 12 items per product. Each item is either a hosted URL or a base64-encoded file:
{
"products": [
{
"name": "4G Router",
"sku": "RTR-4G",
"price": 199,
"images": [
{ "url": "https://example.com/router-front.jpg", "alt": "Front view" },
{ "url": "https://example.com/router-back.jpg", "alt": "Back ports" }
]
}
]
}Or with base64-encoded raw files (up to ~10 MB decoded):
{
"products": [
{
"name": "4G Router",
"sku": "RTR-4G",
"price": 199,
"images": [
{ "base64": "iVBORw0KGgoAAAANSUhEUgA...", "alt": "Front view", "filename": "router-front.png" }
]
}
]
}URL images are fetched asynchronously — each product appears in the response immediately, but its images populate in the background. The imagesQueued field in the response indicates how many image URLs were accepted for background processing. Non-image or unreachable URLs are skipped without failing the product import.
Base64 images are stored immediately on the same request — no background processing needed.
CSV imports use URL-only — CSV files are plain-text and cannot embed image data. To use base64, call the JSON API directly.
Quickstart
export AL_KEY="alk_live_xxx"
# Push your catalog — run this on a schedule to keep it in sync
curl -s -X POST https://agentlyleads.com/api/v1/products \
-H "Authorization: Bearer $AL_KEY" \
-H "Content-Type: application/json" \
-d '{
"products": [
{
"name": "4G Router",
"sku": "RTR-4G",
"price": 199,
"cost": 120,
"category": "Electronics"
}
]
}'
# Verify
curl -s "https://agentlyleads.com/api/v1/products?sku=RTR-4G" \
-H "Authorization: Bearer $AL_KEY"A typical integration is a nightly job in your ERP or e-commerce backend that reads its product list and POSTs it here. The first run creates everything; every later run updates by SKU.