Skip to main content
Remark’s Product Import API allows you to import and synchronize your products directly from your systems — including physical goods with per-store inventory, classes and experiences with schedules, and any custom metadata your business needs.

Direct API

Send product data via GraphQL mutation. Best for small batches up to ~1,000 products.

SFTP Upload

Upload JSONL files to SFTP, then trigger processing. Best for large product sets.

Custom Data

Attach any metadata to products and variants with the meta: prefix.

Inventory by Location

Track per-location inventory across your physical stores and warehouses.

Import Modes

Replaces your entire product set. Products not included in the import are archived (soft-deleted).
Only use REPLACE mode for complete product syncs. Any products missing from your file will be archived.
When to use:
  • Initial product import
  • Daily overnight product refresh
  • When your export represents the complete source of truth

Before You Begin

You’ll need the following credentials from your Remark Dashboard before getting started.
RequirementDescriptionWhere to find it
Vendor IDYour unique Remark identifierDashboard → Settings
API KeyAuthentication for API requests (starts with rmrk_)Dashboard → Settings → API Keys
SFTP CredentialsUsername and SSH key (if using SFTP)Contact support

Quick Start

1

Choose your method

Direct API for small batches (≤1,000 products), SFTP for large product sets.
2

Format your data

JSON objects for API, JSONL files for SFTP.
3

Send or upload

Call the GraphQL mutation or upload to SFTP and trigger processing.
4

Verify import

Check job status to confirm successful processing.

API Endpoint & Authentication

POST https://api.remark.ai/graphql

Authentication Options


Direct API: Upsert Products

For small to medium batches (up to ~1,000 products), use the upsertProducts mutation.
The direct API always operates in UPDATE mode. Products not included are left unchanged. To archive products, set archived: true. To unpublish, set publishedAt: null.

GraphQL Mutation

mutation UpsertProducts($vendorId: ID!, $products: [UpsertProductInput!]!) {
  upsertProducts(vendorId: $vendorId, products: $products) {
    id
    externalId
    name
  }
}

Example Request

curl -X POST https://api.remark.ai/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-auth-token" \
  -d '{
    "query": "mutation UpsertProducts($vendorId: ID!, $products: [UpsertProductInput!]!) { upsertProducts(vendorId: $vendorId, products: $products) { id externalId } }",
    "variables": {
      "vendorId": "your-vendor-id",
      "products": [
        {
          "externalId": "prod-001",
          "name": "Trail Boots",
          "brandName": "SummitGear",
          "externalUrl": "https://store.example.com/trail-boots",
          "variants": [
            {
              "externalId": "var-001",
              "name": "Size 10",
              "prices": [{"price": 189.99, "currency": "USD"}]
            }
          ]
        }
      ]
    }
  }'

Response

{
  "data": {
    "upsertProducts": [
      { "id": "uuid-1", "externalId": "prod-001" }
    ]
  }
}

Archive & Publish Controls

The direct API supports archiving and publishing controls on each product:
ActionFieldValue
Archive productarchivedtrue
Unarchive productarchivedfalse
Unpublish (draft)publishedAtnull
Publish immediatelypublishedAt"2024-01-15T00:00:00Z"
Schedule publishpublishedAtFuture date
Example: Archive and unpublish products
curl -X POST https://api.remark.ai/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-auth-token" \
  -d '{
    "query": "mutation UpsertProducts($vendorId: ID!, $products: [UpsertProductInput!]!) { upsertProducts(vendorId: $vendorId, products: $products) { id externalId } }",
    "variables": {
      "vendorId": "your-vendor-id",
      "products": [
        { "externalId": "prod-discontinued", "archived": true },
        { "externalId": "prod-seasonal", "publishedAt": null },
        { "externalId": "prod-preorder", "publishedAt": "2024-06-01T00:00:00Z" }
      ]
    }
  }'
Archived vs Unpublished:
  • archived: true — Soft delete. Product is excluded from all search, recommendations, and AI.
  • publishedAt: null — Draft mode. Product exists but is not visible to customers.

SFTP File Processing

For large product sets (thousands of products), upload a JSONL file to SFTP, then trigger processing via GraphQL.

Step 1: Upload File to SFTP

Upload your file to the incoming/ directory:
sftp -i ~/.ssh/remark_sftp your-username@sftp.remark.ai
sftp> cd incoming
sftp> put products-2024-01-15.jsonl
sftp> exit

Step 2: Trigger Processing

Call the processProductImport mutation to start the import job. Specify just the filename (the system automatically looks in your incoming/ directory):
mutation ProcessProductImport($input: ProcessProductImportInput!) {
  processProductImport(input: $input) {
    jobId
    status
  }
}
Variables:
{
  "input": {
    "files": ["products-2024-01-15.jsonl"],
    "mode": "REPLACE"
  }
}
When using API key authentication, vendorId is optional. The vendor is automatically determined from your API key.

Example Request

curl -X POST https://api.remark.ai/graphql \
  -H "Content-Type: application/json" \
  -H "X-Vendor-Api-Key: rmrk_your-api-key" \
  -d '{
    "query": "mutation ProcessProductImport($input: ProcessProductImportInput!) { processProductImport(input: $input) { jobId status } }",
    "variables": {
      "input": {
        "files": ["products-2024-01-15.jsonl"],
        "mode": "REPLACE"
      }
    }
  }'

Response

{
  "data": {
    "processProductImport": {
      "jobId": "713aa624-1d7c-4e4a-ac23-6463e48a5fdd",
      "status": "queued"
    }
  }
}
Save the jobId to check processing status and debug any issues.
Imports are queued per-vendor — if you trigger an import while another is already running, it joins the queue (up to 20 pending jobs) and starts automatically when the current job finishes. You don’t need to wait for one import to complete before triggering the next.

Check Job Status

Query job status to track processing progress:
query ProductImportJob($jobId: ID!) {
  productImportJob(jobId: $jobId) {
    jobId
    status
    mode
    stats {
      productsProcessed
      productsCreated
      productsUpdated
      productsArchived
      productsSkipped
      errors
    }
    errorDetails {
      line
      externalId
      message
    }
    startedAt
    completedAt
  }
}

Example Request

curl -X POST https://api.remark.ai/graphql \
  -H "Content-Type: application/json" \
  -H "X-Vendor-Api-Key: rmrk_your-api-key" \
  -d '{
    "query": "query ProductImportJob($jobId: ID!) { productImportJob(jobId: $jobId) { jobId status mode stats { productsProcessed productsCreated productsUpdated productsArchived errors } errorDetails { line externalId message } completedAt } }",
    "variables": {
      "jobId": "713aa624-1d7c-4e4a-ac23-6463e48a5fdd"
    }
  }'

Job Statuses

StatusDescription
queuedJob is waiting to be processed
processingImport is in progress
completedImport finished successfully
failedImport failed — check errorDetails

Example Response

{
  "data": {
    "productImportJob": {
      "jobId": "713aa624-1d7c-4e4a-ac23-6463e48a5fdd",
      "status": "completed",
      "mode": "REPLACE",
      "stats": {
        "productsProcessed": 1523,
        "productsCreated": 45,
        "productsUpdated": 1475,
        "productsArchived": 12,
        "productsSkipped": 0,
        "errors": 3
      },
      "errorDetails": [
        { "line": 47, "externalId": "prod-bad", "message": "Missing required field: name" }
      ],
      "completedAt": "2024-01-15T10:32:45Z"
    }
  }
}
Partial failures don’t stop processing. Valid products are imported; errors are logged and returned in errorDetails.

Data Format

For the upsertProducts mutation, send an array of product objects:
[
  {
    "externalId": "prod-001",
    "name": "Trail Boots",
    "brandName": "SummitGear",
    "externalUrl": "https://store.example.com/trail-boots",
    "variants": [...]
  },
  {
    "externalId": "prod-002",
    "name": "Rain Jacket",
    "brandName": "StormShield",
    "externalUrl": "https://store.example.com/rain-jacket",
    "variants": [...]
  }
]

Product Schema

Product Fields

FieldTypeRequiredDescription
externalIdstringYesUnique product ID from your system
namestringYes*Product name (max 255 chars)
brandNamestringYes*Brand or manufacturer name
externalUrlstringYes*URL to product page on your store
descriptionstringNoProduct description (HTML supported)
publishedAtISO 8601NoPublication date. null = draft/unpublished
archivedbooleanNotrue to archive, false to unarchive
imagesarrayNoProduct images
variantsarrayNoProduct variants
categoriesarrayNoCategory names (strings)
tagsarrayNoTag names (strings)
meta:*anyNoCustom metadata (see Metafields)
*Required for new products and REPLACE mode. For UPDATE mode on existing products, only externalId and the fields you’re changing are needed.

Variant Fields

FieldTypeRequiredDescription
externalIdstringYes**Unique variant ID from your system
namestringYes*Variant name (e.g., “Large / Blue”)
skustringNoStock keeping unit
upcstringNoUniversal Product Code
imageExternalIdstringNoLinks variant to an image by its externalId
inventorynumber | nullNoTotal stock quantity. null clears inventory tracking. Alias: quantity
inventoryByLocationobjectNoPer-store inventory (see Inventory by Location)
inventoryPolicyenumNoCONTINUE or DENY when out of stock
pricesarrayNoPricing per currency
meta:*anyNoCustom metadata (see Metafields)
**If externalId is not provided for a variant, the sku will be used as the external ID. This is convenient if your SKUs are already unique identifiers.

Price Fields

FieldTypeRequiredDescription
pricenumberYesCurrent selling price
compareAtPricenumberNoOriginal price before discount (for showing strikethrough pricing)
currencystringYesISO 4217 code (e.g., USD, EUR, GBP)
Just set price to whatever the customer pays today. If you want to show a strikethrough original price, include compareAtPrice — but it’s not required.

Image Fields

Images are deduplicated by URL — if the same URL appears multiple times (even with different externalId values), only the first occurrence is kept. Variant imageExternalId references are remapped automatically.
FieldTypeRequiredDescription
urlstringYesImage URL (max 2048 chars)
externalIdstringNoUnique ID. Defaults to MD5 hash of URL
positionnumberNoDisplay order (lower = first)

Metafields

Metafields let you attach custom data to products and variants — things like class schedules, store availability, materials, care instructions, or anything else specific to your business.

How it works

Prefix any key with meta: and we’ll extract it automatically. Metafields work on both products and variants.
{
  "externalId": "prod-001",
  "name": "Intro to Pottery",
  "meta:skillLevel": "beginner",
  "meta:durationMinutes": 90,
  "variants": [
    {
      "externalId": "var-001-sat",
      "name": "Saturday Morning",
      "meta:datetime": "2026-03-15T10:00:00Z",
      "meta:location": "STORE_42",
      "prices": [{ "price": 75.00, "currency": "USD" }]
    }
  ]
}
The meta: prefix is stripped when stored — meta:skillLevel becomes a metafield with key skillLevel. Any key without the meta: prefix that isn’t a standard field is ignored.

Value types

Values are stored as strings internally. You can pass any JSON-compatible type and we’ll coerce it:
You sendWe store
"beginner""beginner"
90"90"
true"true"
{"width": 10}"{\"width\":10}"
[1, 2, 3]"[1,2,3]"
null or ""null (field cleared)
Use whatever keys make sense for your data. The important thing is to be consistent across your products so Remark’s AI can learn what each field means.

Inventory by Location

If you sell products across multiple physical locations, use inventoryByLocation on each variant to tell us how many units are at each store.

Per-location inventory

Set inventoryByLocation to a JSON object mapping your location codes to available quantities. Locations not listed are assumed to have 0 stock:
{
  "externalId": "prod-knife-001",
  "name": "Wusthof Classic 8\" Chef's Knife",
  "variants": [
    {
      "externalId": "var-knife-001",
      "name": "8 Inch",
      "sku": "WU-CL-8CH",
      "inventory": 45,
      "inventoryByLocation": {
        "CARMEL_IN": 12,
        "FISHERS_IN": 8,
        "DOWNTOWN_INDY": 15,
        "ONLINE_WAREHOUSE": 10
      },
      "prices": [{ "price": 179.95, "currency": "USD" }]
    }
  ]
}
The inventory field is the total across all locations. inventoryByLocation breaks that total down by store.
Store codes are strings you define — store numbers ("store_1"), location codes ("CARMEL_IN"), or Shopify location IDs ("loc_12345"). Just be consistent. A store not included in the object means 0 units there.

Store locations

Store codes are resolved against your vendor’s store location records, which include the store name and address. You can manage store locations via the GraphQL API — see Store Location API below.
FieldTypeRequiredDescription
storeCodestringYesYour identifier (must match keys in inventoryByLocation)
namestringYesHuman-readable name (e.g., “Clay Terrace - Carmel, IN”)
isActivebooleanNoWhether the location is active (default true). Inactive locations are skipped during sync
phonestringNoLocation phone number
latitude / longitudefloatNoGPS coordinates
streetAddressstringNoStreet address
streetAddress2stringNoSuite, unit, floor, etc.
citystringNoCity
regionstringNoState / province
countrystringNoISO 3166-1 alpha-2 code (e.g., US)
postalCodestringNoZIP / postal code

Store Location API

Manage your physical store and warehouse locations via GraphQL. Store locations must be created before they can be referenced in inventoryByLocation.

Create a Store Location

GraphQL Mutation

mutation CreateStoreLocation($input: CreateStoreLocationInput!) {
  createStoreLocation(input: $input) {
    id
    storeCode
    name
    isActive
  }
}

Example Request

curl -X POST https://api.remark.ai/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-auth-token" \
  -d '{
    "query": "mutation CreateStoreLocation($input: CreateStoreLocationInput!) { createStoreLocation(input: $input) { id storeCode name isActive } }",
    "variables": {
      "input": {
        "vendorId": "your-vendor-id",
        "storeCode": "CARMEL_IN",
        "name": "Clay Terrace - Carmel, IN",
        "isActive": true,
        "phone": "+1-317-555-0100",
        "streetAddress": "14390 Clay Terrace Blvd",
        "city": "Carmel",
        "region": "IN",
        "country": "US",
        "postalCode": "46032",
        "latitude": 39.9784,
        "longitude": -86.1260
      }
    }
  }'

Response

{
  "data": {
    "createStoreLocation": {
      "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
      "storeCode": "CARMEL_IN",
      "name": "Clay Terrace - Carmel, IN",
      "isActive": true
    }
  }
}

List Store Locations

GraphQL Query

query StoreLocations($vendorId: ID!) {
  storeLocations(vendorId: $vendorId) {
    id
    storeCode
    name
    isActive
    phone
    latitude
    longitude
    streetAddress
    streetAddress2
    city
    region
    country
    postalCode
  }
}

Example Request

curl -X POST https://api.remark.ai/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-auth-token" \
  -d '{
    "query": "query StoreLocations($vendorId: ID!) { storeLocations(vendorId: $vendorId) { id storeCode name isActive streetAddress city region country postalCode } }",
    "variables": {
      "vendorId": "your-vendor-id"
    }
  }'

Response

{
  "data": {
    "storeLocations": [
      {
        "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
        "storeCode": "CARMEL_IN",
        "name": "Clay Terrace - Carmel, IN",
        "isActive": true,
        "streetAddress": "14390 Clay Terrace Blvd",
        "city": "Carmel",
        "region": "IN",
        "country": "US",
        "postalCode": "46032"
      },
      {
        "id": "b2c3d4e5-6789-01bc-defg-2345678901bc",
        "storeCode": "DOWNTOWN_INDY",
        "name": "Downtown Indianapolis",
        "isActive": true,
        "streetAddress": "123 Monument Circle",
        "city": "Indianapolis",
        "region": "IN",
        "country": "US",
        "postalCode": "46204"
      }
    ]
  }
}

Update a Store Location

GraphQL Mutation

mutation UpdateStoreLocation($input: UpdateStoreLocationInput!) {
  updateStoreLocation(input: $input) {
    id
    storeCode
    name
    isActive
  }
}
Only include the fields you want to change. All fields except id are optional on update.

Example Request

curl -X POST https://api.remark.ai/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-auth-token" \
  -d '{
    "query": "mutation UpdateStoreLocation($input: UpdateStoreLocationInput!) { updateStoreLocation(input: $input) { id storeCode name isActive } }",
    "variables": {
      "input": {
        "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
        "name": "Clay Terrace - Carmel, IN (Renovated)",
        "phone": "+1-317-555-0200"
      }
    }
  }'

Response

{
  "data": {
    "updateStoreLocation": {
      "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
      "storeCode": "CARMEL_IN",
      "name": "Clay Terrace - Carmel, IN (Renovated)",
      "isActive": true
    }
  }
}

Delete a Store Location

GraphQL Mutation

mutation DeleteStoreLocation($id: ID!) {
  deleteStoreLocation(id: $id) {
    id
  }
}
Deleting a store location removes all associated inventory records for that location. Products that had inventory at the deleted location will be reindexed automatically.

Example Request

curl -X POST https://api.remark.ai/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-auth-token" \
  -d '{
    "query": "mutation DeleteStoreLocation($id: ID!) { deleteStoreLocation(id: $id) { id } }",
    "variables": {
      "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
    }
  }'

Response

{
  "data": {
    "deleteStoreLocation": {
      "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
    }
  }
}

Deactivate vs Delete

To temporarily stop syncing inventory to a location without losing data, update isActive to false instead of deleting:
curl -X POST https://api.remark.ai/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your-auth-token" \
  -d '{
    "query": "mutation UpdateStoreLocation($input: UpdateStoreLocationInput!) { updateStoreLocation(input: $input) { id storeCode isActive } }",
    "variables": {
      "input": {
        "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
        "isActive": false
      }
    }
  }'
Inactive locations are skipped during inventory sync but retain their configuration.

Examples

Full Product with Location Inventory

A physical product with metafields and per-store inventory:
{
  "externalId": "prod-12345",
  "name": "Ultralight Hiking Backpack",
  "brandName": "TrailMaster",
  "description": "<p>A lightweight 40L backpack perfect for multi-day hikes.</p>",
  "externalUrl": "https://store.example.com/products/ultralight-backpack",
  "publishedAt": "2024-01-15T00:00:00Z",
  "meta:material": "Ripstop nylon",
  "meta:weightOz": 28,
  "images": [
    { "url": "https://cdn.example.com/backpack-main.jpg", "position": 0 },
    { "url": "https://cdn.example.com/backpack-side.jpg", "position": 1 }
  ],
  "variants": [
    {
      "externalId": "var-sm-blue",
      "name": "Small / Blue",
      "sku": "TM-ULB-SM-BL",
      "inventory": 25,
      "inventoryPolicy": "DENY",
      "inventoryByLocation": { "WAREHOUSE_1": 15, "STORE_NYC": 10 },
      "prices": [
        { "price": 149.99, "currency": "USD" }
      ]
    },
    {
      "externalId": "var-md-blue",
      "name": "Medium / Blue",
      "sku": "TM-ULB-MD-BL",
      "inventory": 42,
      "inventoryByLocation": { "WAREHOUSE_1": 30, "STORE_NYC": 12 },
      "prices": [
        { "price": 159.99, "currency": "USD" }
      ]
    }
  ],
  "categories": ["Backpacks", "Hiking Gear"],
  "tags": ["ultralight", "waterproof"]
}
Products that are classes or scheduled experiences. Each variant is a specific session with its own date, time, and location:
{
  "externalId": "class-pastry-101",
  "name": "Intro to French Pastry",
  "brandName": "Sur La Table",
  "externalUrl": "https://surlatable.com/classes/pastry-101",
  "description": "Learn the fundamentals of French pastry.",
  "meta:skillLevel": "beginner",
  "meta:durationMinutes": 150,
  "meta:instructor": "Chef Marie Laurent",
  "variants": [
    {
      "externalId": "class-pastry-101-sat",
      "name": "Saturday March 15 - 10:00 AM",
      "inventory": 4,
      "inventoryPolicy": "DENY",
      "meta:datetime": "2026-03-15T15:00:00Z",
      "meta:location": "CLAY_TERRACE_CARMEL_IN",
      "prices": [{ "price": 89.00, "currency": "USD" }]
    },
    {
      "externalId": "class-pastry-101-sun",
      "name": "Sunday March 16 - 2:00 PM",
      "inventory": 8,
      "inventoryPolicy": "DENY",
      "meta:datetime": "2026-03-16T19:00:00Z",
      "meta:location": "KEYSTONE_INDY",
      "prices": [{ "price": 89.00, "currency": "USD" }]
    }
  ],
  "categories": ["Classes", "Baking Classes"],
  "tags": ["hands-on", "beginner-friendly"]
}
Key conventions for classes:
  • meta:datetime — ISO 8601 in UTC (e.g., "2026-03-15T15:00:00Z")
  • meta:location — your store/location code
  • meta:durationMinutes — class length in minutes
  • meta:instructor — instructor name (optional)
  • meta:skillLevel — e.g., "beginner", "intermediate", "all_levels"
  • inventory — seats remaining (use inventoryPolicy: "DENY" so classes can’t be overbooked)
The minimum required to create a new product:
{
  "externalId": "prod-new",
  "name": "New Product",
  "brandName": "Acme",
  "externalUrl": "https://store.example.com/new-product",
  "variants": [
    {
      "externalId": "var-default",
      "name": "Default",
      "prices": [{ "price": 29.99, "currency": "USD" }]
    }
  ]
}
Update total inventory and per-store breakdown:
{
  "externalId": "prod-001",
  "variants": [
    {
      "externalId": "var-001-sm",
      "inventory": 17,
      "inventoryByLocation": { "STORE_NYC": 5, "STORE_LA": 2, "WAREHOUSE_1": 10 }
    },
    {
      "externalId": "var-001-md",
      "inventory": 3,
      "inventoryByLocation": { "STORE_NYC": 1, "WAREHOUSE_1": 2 }
    }
  ]
}
Or update just the total without per-store detail:
{
  "externalId": "prod-001",
  "variants": [
    { "externalId": "var-001-sm", "inventory": 5 },
    { "externalId": "var-001-md", "inventory": 12 }
  ]
}
Update pricing for a variant — just set price to the current selling price:
{
  "externalId": "prod-001",
  "variants": [
    {
      "externalId": "var-001-sm",
      "prices": [
        { "price": 139.99, "currency": "USD" }
      ]
    }
  ]
}
Archive a discontinued product:
{ "externalId": "prod-discontinued", "archived": true }
Unarchive a product:
{ "externalId": "prod-discontinued", "archived": false }
Unpublish a product (set to draft):
{ "externalId": "prod-seasonal", "publishedAt": null }
Publish immediately:
{ "externalId": "prod-seasonal", "publishedAt": "2024-01-15T00:00:00Z" }
Schedule future publish:
{ "externalId": "prod-preorder", "publishedAt": "2024-06-01T00:00:00Z" }

SFTP Setup

Generate SSH Keys

Create an ED25519 key pair for secure authentication:
ssh-keygen -t ed25519 -C "your-email@company.com" -f ~/.ssh/remark_sftp
This creates two files:
  • ~/.ssh/remark_sftpPrivate key (keep secure, never share)
  • ~/.ssh/remark_sftp.pubPublic key (send to Remark)
Never share your private key. Only send the .pub file to Remark.

Register Your Key

Email your public key to support@remark.ai with:
  • Your company name
  • Your Remark Vendor ID
  • Whether this is for staging or production
We’ll confirm when registered and provide your SFTP username.

Connect to SFTP

sftp -i ~/.ssh/remark_sftp your-username@sftp.remark.ai

Upload Files

Always upload to the incoming/ directory:
sftp> cd incoming
sftp> put products-full-2024-01-15.jsonl
sftp> exit
When triggering the import via API, you only need to specify the filename (e.g., products-full-2024-01-15.jsonl). The system automatically looks in your incoming/ directory.

File Naming Conventions

Include date/time and mode for easier debugging:
TypePatternExample
Full syncproducts-full-{date}.jsonlproducts-full-2024-01-15.jsonl
Delta updateproducts-delta-{timestamp}.jsonlproducts-delta-2024-01-15-103000.jsonl
Inventoryinventory-{timestamp}.jsonlinventory-2024-01-15-103000.jsonl

Compression

Gzip compression is supported for faster uploads:
gzip products-full-2024-01-15.jsonl
sftp> cd incoming
sftp> put products-full-2024-01-15.jsonl.gz
Files on SFTP are retained for 30 days, then automatically deleted.

Data TypeFrequencyMethodMode
Full product syncDaily (overnight)SFTPREPLACE
Inventory & location inventoryEvery 15–60 minSFTP or APIUPDATE
Price changesAs neededSFTP or APIUPDATE
New productsAs neededAPIUPDATE
Class/experience schedulesAs sessions are addedSFTP or APIUPDATE
Use REPLACE mode sparingly — once daily is typically sufficient. For all other updates, use UPDATE mode to avoid accidentally archiving products.

Error Handling

GraphQL Errors

Authentication or permission errors return in the errors array:
{
  "errors": [
    {
      "message": "You do not have permission to upsert products",
      "extensions": { "code": "FORBIDDEN" }
    }
  ]
}

Job Errors

When a job completes with errors, check errorDetails for specifics:
{
  "data": {
    "productImportJob": {
      "status": "completed",
      "stats": {
        "productsProcessed": 1520,
        "errors": 3
      },
      "errorDetails": [
        { "line": 47, "externalId": "prod-bad", "message": "Missing required field: name" },
        { "line": 123, "externalId": "prod-invalid", "message": "Invalid URL format for externalUrl" },
        { "line": 891, "externalId": null, "message": "Malformed JSON" }
      ]
    }
  }
}

Best Practices

Use UPDATE for frequent updates

Reserve REPLACE mode for daily syncs. Use UPDATE for inventory, prices, and incremental changes.

Keep external IDs stable

Never change a product’s externalId. Changing it creates a duplicate product.

Use consistent meta: keys

Pick a naming convention for your metafield keys and stick with it. Remark’s AI uses these to understand your products.

Keep location codes stable

Use the same location codes in inventoryByLocation that match your configured store locations.

Validate JSONL locally

Validate each line is valid JSON before uploading. Use jq or similar tools.

Monitor job status

Always check job status after imports to catch and address errors quickly.

Troubleshooting

  • Check that publishedAt is set (null = draft/unpublished)
  • Verify required fields: externalId, name, brandName, externalUrl
  • Query the job status and check for errors
  • This happens with REPLACE mode when products are missing from your file
  • Ensure your export includes all active products
  • Use UPDATE mode for partial updates
  • Verify firewall allows outbound connections to port 22
  • Check you’re using the correct private key: -i ~/.ssh/remark_sftp
  • Ensure key permissions: chmod 600 ~/.ssh/remark_sftp
  • Confirm your public key was registered by Remark
  • Large files may take several minutes to process
  • Check job status periodically
  • Contact support if stuck for more than 30 minutes
  • Ensure keys are prefixed with meta: (e.g., "meta:material", not "material")
  • Keys without the meta: prefix that aren’t standard fields are silently ignored
  • Check that values aren’t empty strings (empty strings are stored as null)
  • Verify your location codes in inventoryByLocation match the storeCode values on your store locations
  • Location codes are case-sensitive — "STORE_1" and "store_1" are different
  • Query storeLocations(vendorId) to confirm your locations exist and are active
  • Validate each line before upload
  • Run: cat file.jsonl | jq -c . > /dev/null to check for issues
  • Check for trailing commas, unescaped quotes, or multi-line objects

REST API Reference

Start Import

POST /api/v1/products/import
Request Body:
{
  "files": ["products-2024-01-15.jsonl"],
  "mode": "REPLACE"
}
Response:
{
  "success": true,
  "jobId": "713aa624-1d7c-4e4a-ac23-6463e48a5fdd",
  "status": "queued",
  "mode": "REPLACE"
}

Check Job Status

GET /api/v1/products/import/:jobId
Response:
{
  "success": true,
  "jobId": "713aa624-1d7c-4e4a-ac23-6463e48a5fdd",
  "status": "completed",
  "mode": "REPLACE",
  "stats": {
    "productsProcessed": 1523,
    "productsCreated": 45,
    "productsUpdated": 1475,
    "productsArchived": 12,
    "productsSkipped": 0,
    "errors": 3
  },
  "errorDetails": [
    { "line": 47, "externalId": "prod-bad", "message": "\"name\" is required" }
  ],
  "completedAt": "2024-01-15T10:32:45Z"
}

GraphQL Schema Reference

# === Inputs ===

input ProcessProductImportInput {
  vendorId: ID           # Optional with API key auth (derived from key)
  files: [String!]!
  mode: ProductImportMode!
}

input UpsertProductInput {
  externalId: String!
  name: String
  brandName: String
  externalUrl: String
  description: String
  publishedAt: DateTime
  archived: Boolean
  images: [ImageInput!]
  variants: [VariantInput!]
  categories: [String!]
  tags: [String!]
  metafields: [MetafieldInput!]  # Product-level custom data
}

input VariantInput {
  externalId: String  # Falls back to sku if not provided
  name: String
  sku: String
  upc: String
  imageExternalId: String  # Links variant to an image by its externalId
  inventory: Int
  inventoryByLocation: JSON  # { "LOCATION_CODE": quantity, ... }
  inventoryPolicy: InventoryPolicy
  prices: [PriceInput!]
  metafields: [MetafieldInput!]  # Variant-level custom data
}

input MetafieldInput {
  key: String!
  value: String!
}

input PriceInput {
  price: Float!
  compareAtPrice: Float
  currency: String!
}

input ImageInput {
  url: String!
  externalId: String
  position: Int
}

# === Enums ===

enum ProductImportMode {
  REPLACE  # Replace entire product set
  UPDATE   # Update only included products
}

enum ProductImportJobStatus {
  queued
  processing
  completed
  failed
}

enum InventoryPolicy {
  CONTINUE  # Sell when out of stock
  DENY      # Stop selling when out of stock
}

# === Types ===

type ProductImportJob {
  jobId: ID!
  status: ProductImportJobStatus!
  mode: ProductImportMode!
  stats: ProductImportStats
  errorDetails: [ProductImportError!]
  startedAt: DateTime
  completedAt: DateTime
}

type ProductImportStats {
  productsProcessed: Int!
  productsCreated: Int!
  productsUpdated: Int!
  productsArchived: Int!
  productsSkipped: Int!
  errors: Int!
}

type ProductImportError {
  line: Int
  externalId: String
  message: String!
}

# === Store Locations ===

type StoreLocation {
  id: ID!
  vendorId: ID!
  storeCode: String!
  name: String!
  isActive: Boolean!
  phone: String
  latitude: Float
  longitude: Float
  streetAddress: String
  streetAddress2: String
  city: String
  region: String
  country: String
  postalCode: String
}

input CreateStoreLocationInput {
  vendorId: ID!
  storeCode: String!
  name: String!
  isActive: Boolean        # Default: true
  phone: String
  latitude: Float
  longitude: Float
  streetAddress: String
  streetAddress2: String
  city: String
  region: String
  country: String          # ISO 3166-1 alpha-2 (e.g., "US")
  postalCode: String
}

input UpdateStoreLocationInput {
  id: ID!
  storeCode: String
  name: String
  isActive: Boolean
  phone: String
  latitude: Float
  longitude: Float
  streetAddress: String
  streetAddress2: String
  city: String
  region: String
  country: String
  postalCode: String
}

# === Mutations ===

type Mutation {
  # Direct product upsert (UPDATE mode only)
  upsertProducts(
    vendorId: ID!
    products: [UpsertProductInput!]!
  ): [Product!]!

  # Process SFTP files
  processProductImport(
    input: ProcessProductImportInput!
  ): ProductImportJob!

  # Store locations
  createStoreLocation(input: CreateStoreLocationInput!): StoreLocation!
  updateStoreLocation(input: UpdateStoreLocationInput!): StoreLocation!
  deleteStoreLocation(id: ID!): StoreLocation!
}

# === Queries ===

type Query {
  productImportJob(jobId: ID!): ProductImportJob
  storeLocations(vendorId: ID!): [StoreLocation!]!
  storeLocation(id: ID!): StoreLocation
}

Need Help?

Contact Support

Email support@remark.ai with your Vendor ID, job ID (if applicable), error messages, and sample data.