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
Updates only the products you include. Everything else remains unchanged. Variants not included in the payload are preserved — only variants you explicitly send are created or updated.When to use:
Inventory updates throughout the day
Price changes
Adding new products
Archiving or unpublishing specific products
Before You Begin
You’ll need the following credentials from your Remark Dashboard before getting started.
Requirement Description Where to find it Vendor ID Your unique Remark identifier Dashboard → Settings API Key Authentication for API requests (starts with rmrk_) Dashboard → Settings → API Keys SFTP Credentials Username and SSH key (if using SFTP) Contact support
Quick Start
Choose your method
Direct API for small batches (≤1,000 products), SFTP for large product sets.
Format your data
JSON objects for API, JSONL files for SFTP.
Send or upload
Call the GraphQL mutation or upload to SFTP and trigger processing.
Verify import
Check job status to confirm successful processing.
API Endpoint & Authentication
POST https://api.remark.ai/graphql
POST https://api.remark.ai/api/v1/products/import # Start import
GET https://api.remark.ai/api/v1/products/import/:jobId # Check status
Authentication Options
Use a vendor API key for server-to-server integrations: X-Vendor-Api-Key: {your-api-key}
API keys start with rmrk_ and are scoped to a specific vendor with specific permissions. Use a bearer token for user-authenticated requests: Authorization: Bearer {your-auth-token}
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:
Action Field Value Archive product archivedtrueUnarchive product archivedfalseUnpublish (draft) publishedAtnullPublish immediately publishedAt"2024-01-15T00:00:00Z"Schedule publish publishedAtFuture 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 (GraphQL)
cURL (REST)
Node.js
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 (GraphQL)
cURL (REST)
Node.js
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
Status Description 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.
Direct API (JSON)
SFTP Files (JSONL)
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" : [ ... ]
}
]
For file uploads, use JSON Lines format — each line is a complete, valid JSON product: { "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" : [ ... ]}
{ "externalId" : "prod-003" , "name" : "Hiking Pack" , "brandName" : "TrailMaster" , "externalUrl" : "https://store.example.com/hiking-pack" , "variants" : [ ... ]}
Each line must be valid JSON. No trailing commas, no multi-line objects. A malformed line will be skipped and logged as an error.
Product Schema
Product Fields
Field Type Required Description externalIdstring Yes Unique product ID from your system namestring Yes *Product name (max 255 chars) brandNamestring Yes *Brand or manufacturer name externalUrlstring Yes *URL to product page on your store descriptionstring No Product description (HTML supported) publishedAtISO 8601 No Publication date. null = draft/unpublished archivedboolean No true to archive, false to unarchiveimagesarray No Product images variantsarray No Product variants categoriesarray No Category names (strings) tagsarray No Tag names (strings) meta:*any No Custom 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
Field Type Required Description externalIdstring Yes **Unique variant ID from your system namestring Yes *Variant name (e.g., “Large / Blue”) skustring No Stock keeping unit upcstring No Universal Product Code imageExternalIdstring No Links variant to an image by its externalId inventorynumber | null No Total stock quantity. null clears inventory tracking. Alias: quantity inventoryByLocationobject No Per-store inventory (see Inventory by Location ) inventoryPolicyenum No CONTINUE or DENY when out of stockpricesarray No Pricing per currency meta:*any No Custom 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
Field Type Required Description pricenumber Yes Current selling price compareAtPricenumber No Original price before discount (for showing strikethrough pricing) currencystring Yes ISO 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.
Field Type Required Description urlstring Yes Image URL (max 2048 chars) externalIdstring No Unique ID. Defaults to MD5 hash of URL positionnumber No Display order (lower = first)
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 send We 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.
Field Type Required Description storeCodestring Yes Your identifier (must match keys in inventoryByLocation) namestring Yes Human-readable name (e.g., “Clay Terrace - Carmel, IN”) isActiveboolean No Whether the location is active (default true). Inactive locations are skipped during sync phonestring No Location phone number latitude / longitudefloat No GPS coordinates streetAddressstring No Street address streetAddress2string No Suite, unit, floor, etc. citystring No City regionstring No State / province countrystring No ISO 3166-1 alpha-2 code (e.g., US) postalCodestring No ZIP / 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" }]
}
]
}
Inventory & Location Update (UPDATE)
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 / Publish (UPDATE)
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_sftp — Private key (keep secure, never share)
~/.ssh/remark_sftp.pub — Public 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:
Type Pattern Example Full sync products-full-{date}.jsonlproducts-full-2024-01-15.jsonlDelta update products-delta-{timestamp}.jsonlproducts-delta-2024-01-15-103000.jsonlInventory inventory-{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.
Recommended Update Cadence
Data Type Frequency Method Mode Full product sync Daily (overnight) SFTP REPLACE Inventory & location inventory Every 15–60 min SFTP or API UPDATE Price changes As needed SFTP or API UPDATE New products As needed API UPDATE Class/experience schedules As sessions are added SFTP or API UPDATE
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
Products not appearing after import
Check that publishedAt is set (null = draft/unpublished)
Verify required fields: externalId, name, brandName, externalUrl
Query the job status and check for errors
Products unexpectedly archived
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
Job stuck in 'processing'
Large files may take several minutes to process
Check job status periodically
Contact support if stuck for more than 30 minutes
Metafields not appearing on products
Location inventory not updating
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
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.