> ## Documentation Index
> Fetch the complete documentation index at: https://docs.remark.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Product Import API

> Import and sync your products to Remark via API or SFTP

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.

<CardGroup cols={2}>
  <Card title="Direct API" icon="bolt" href="#direct-api-upsert-products">
    Send product data via GraphQL mutation. Best for small batches up to \~1,000 products.
  </Card>

  <Card title="SFTP Upload" icon="server" href="#sftp-file-processing">
    Upload JSONL files to SFTP, then trigger processing. Best for large product sets.
  </Card>

  <Card title="Custom Data" icon="tags" href="#metafields-&-custom-data">
    Attach any metadata to products and variants with the `meta:` prefix.
  </Card>

  <Card title="Inventory by Location" icon="map-pin" href="#inventory-by-location">
    Track per-location inventory across your physical stores and warehouses.
  </Card>
</CardGroup>

***

## Import Modes

<Tabs>
  <Tab title="REPLACE Mode">
    **Replaces your entire product set.** Products not included in the import are archived (soft-deleted).

    <Warning>
      Only use REPLACE mode for complete product syncs. Any products missing from your file will be archived.
    </Warning>

    **When to use:**

    * Initial product import
    * Daily overnight product refresh
    * When your export represents the complete source of truth
  </Tab>

  <Tab title="UPDATE Mode">
    **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
  </Tab>
</Tabs>

***

## Before You Begin

<Info>
  You'll need the following credentials from your Remark Dashboard before getting started.
</Info>

| 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](mailto:support@remark.ai) |

***

## Quick Start

<Steps>
  <Step title="Choose your method">
    **Direct API** for small batches (≤1,000 products), **SFTP** for large product sets.
  </Step>

  <Step title="Format your data">
    JSON objects for API, JSONL files for SFTP.
  </Step>

  <Step title="Send or upload">
    Call the GraphQL mutation or upload to SFTP and trigger processing.
  </Step>

  <Step title="Verify import">
    Check job status to confirm successful processing.
  </Step>
</Steps>

***

## API Endpoint & Authentication

<Tabs>
  <Tab title="GraphQL API">
    ```
    POST https://api.remark.ai/graphql
    ```
  </Tab>

  <Tab title="REST API">
    ```
    POST https://api.remark.ai/api/v1/products/import      # Start import
    GET  https://api.remark.ai/api/v1/products/import/:jobId  # Check status
    ```
  </Tab>
</Tabs>

### Authentication Options

<Tabs>
  <Tab title="API Key (Recommended for automation)">
    Use an integration 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.
  </Tab>

  <Tab title="Bearer Token (User auth)">
    Use a bearer token for user-authenticated requests:

    ```
    Authorization: Bearer {your-auth-token}
    ```
  </Tab>
</Tabs>

***

## Direct API: Upsert Products

For small to medium batches (up to \~1,000 products), use the `upsertProducts` mutation.

<Note>
  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`.
</Note>

### GraphQL Mutation

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

### Example Request

<CodeGroup>
  ```bash cURL theme={null}
  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"}]
              }
            ]
          }
        ]
      }
    }'
  ```

  ```javascript Node.js theme={null}
  const response = await fetch('https://api.remark.ai/graphql', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer your-auth-token'
    },
    body: JSON.stringify({
      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' }]
              }
            ]
          }
        ]
      }
    })
  });

  const data = await response.json();
  ```

  ```python Python theme={null}
  import requests

  response = requests.post(
      'https://api.remark.ai/graphql',
      headers={
          'Content-Type': 'application/json',
          'Authorization': 'Bearer your-auth-token'
      },
      json={
          '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'}]
                          }
                      ]
                  }
              ]
          }
      }
  )

  data = response.json()
  ```
</CodeGroup>

### Response

```json theme={null}
{
  "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     | `archived`    | `true`                   |
| Unarchive product   | `archived`    | `false`                  |
| Unpublish (draft)   | `publishedAt` | `null`                   |
| Publish immediately | `publishedAt` | `"2024-01-15T00:00:00Z"` |
| Schedule publish    | `publishedAt` | Future date              |

**Example: Archive and unpublish products**

```bash theme={null}
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" }
      ]
    }
  }'
```

<Tip>
  **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.
</Tip>

***

## 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:

```bash theme={null}
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):

```graphql theme={null}
mutation ProcessProductImport($input: ProcessProductImportInput!) {
  processProductImport(input: $input) {
    jobId
    status
  }
}
```

**Variables:**

```json theme={null}
{
  "input": {
    "files": ["products-2024-01-15.jsonl"],
    "mode": "REPLACE"
  }
}
```

<Tip>
  When using API key authentication, `vendorId` is optional. The vendor is automatically determined from your API key.
</Tip>

### Example Request

<CodeGroup>
  ```bash cURL (GraphQL) theme={null}
  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"
        }
      }
    }'
  ```

  ```bash cURL (REST) theme={null}
  curl -X POST https://api.remark.ai/api/v1/products/import \
    -H "Content-Type: application/json" \
    -H "X-Vendor-Api-Key: rmrk_your-api-key" \
    -d '{
      "files": ["products-2024-01-15.jsonl"],
      "mode": "REPLACE"
    }'
  ```

  ```javascript Node.js theme={null}
  const response = await fetch('https://api.remark.ai/graphql', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Vendor-Api-Key': 'rmrk_your-api-key'
    },
    body: JSON.stringify({
      query: `
        mutation ProcessProductImport($input: ProcessProductImportInput!) {
          processProductImport(input: $input) {
            jobId
            status
          }
        }
      `,
      variables: {
        input: {
          files: ['products-2024-01-15.jsonl'],
          mode: 'REPLACE'
        }
      }
    })
  });

  const { data } = await response.json();
  console.log('Job ID:', data.processProductImport.jobId);
  ```
</CodeGroup>

### Response

```json theme={null}
{
  "data": {
    "processProductImport": {
      "jobId": "713aa624-1d7c-4e4a-ac23-6463e48a5fdd",
      "status": "queued"
    }
  }
}
```

<Tip>
  Save the `jobId` to check processing status and debug any issues.
</Tip>

<Info>
  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.
</Info>

***

## Check Job Status

Query job status to track processing progress:

```graphql theme={null}
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

<CodeGroup>
  ```bash cURL (GraphQL) theme={null}
  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"
      }
    }'
  ```

  ```bash cURL (REST) theme={null}
  curl https://api.remark.ai/api/v1/products/import/713aa624-1d7c-4e4a-ac23-6463e48a5fdd \
    -H "X-Vendor-Api-Key: rmrk_your-api-key"
  ```

  ```javascript Node.js theme={null}
  const response = await fetch('https://api.remark.ai/graphql', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Vendor-Api-Key': 'rmrk_your-api-key'
    },
    body: JSON.stringify({
      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'
      }
    })
  });

  const { data } = await response.json();
  console.log('Status:', data.productImportJob.status);
  ```
</CodeGroup>

### Job Statuses

| Status       | Description                          |
| ------------ | ------------------------------------ |
| `queued`     | Job is waiting to be processed       |
| `processing` | Import is in progress                |
| `completed`  | Import finished successfully         |
| `failed`     | Import failed — check `errorDetails` |

### Example Response

```json theme={null}
{
  "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"
    }
  }
}
```

<Note>
  Partial failures don't stop processing. Valid products are imported; errors are logged and returned in `errorDetails`.
</Note>

***

## Data Format

<Tabs>
  <Tab title="Direct API (JSON)">
    For the `upsertProducts` mutation, send an array of product objects:

    ```json theme={null}
    [
      {
        "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": [...]
      }
    ]
    ```
  </Tab>

  <Tab title="SFTP Files (JSONL)">
    For file uploads, use **JSON Lines** format — each line is a complete, valid JSON product:

    ```jsonl theme={null}
    {"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": [...]}
    ```

    <Warning>
      Each line must be valid JSON. No trailing commas, no multi-line objects. A malformed line will be skipped and logged as an error.
    </Warning>
  </Tab>
</Tabs>

***

## Product Schema

### Product Fields

| Field                      | Type           | Required  | Description                                                                       |
| -------------------------- | -------------- | --------- | --------------------------------------------------------------------------------- |
| `externalId`               | string         | **Yes**   | Unique product ID from your system                                                |
| `name`                     | string         | **Yes**\* | Product name (max 255 chars)                                                      |
| `brandName`                | string         | **Yes**\* | Brand or manufacturer name                                                        |
| `externalUrl`              | string         | **Yes**\* | URL to product page on your store                                                 |
| `description`              | string         | No        | Product description (HTML supported)                                              |
| `publishedAt`              | ISO 8601       | No        | Publication date. `null` = draft/unpublished                                      |
| `archived`                 | boolean        | No        | `true` to archive, `false` to unarchive                                           |
| `images`                   | array          | No        | Product images                                                                    |
| `variants`                 | array          | No        | Product variants                                                                  |
| `categories`               | array          | No        | Category names (strings)                                                          |
| `tags`                     | array          | No        | Tag names (strings)                                                               |
| `fitmentTargetExternalIds` | array          | No        | Fitment target external IDs (see [Fitment](/guides/fitment))                      |
| `sizeChartExternalId`      | string \| null | No        | External ID of a size chart to associate (see [Size Charts](/guides/size-charts)) |
| `meta:*`                   | any            | No        | Custom metadata (see [Metafields](#metafields))                                   |

<Note>
  \*Required for new products and REPLACE mode. For UPDATE mode on existing products, only `externalId` and the fields you're changing are needed.
</Note>

### Variant Fields

| Field                      | Type           | Required    | Description                                                                       |
| -------------------------- | -------------- | ----------- | --------------------------------------------------------------------------------- |
| `externalId`               | string         | **Yes**\*\* | Unique variant ID from your system                                                |
| `name`                     | string         | **Yes**\*   | Variant name (e.g., "Large / Blue")                                               |
| `sku`                      | string         | No          | Stock keeping unit                                                                |
| `upc`                      | string         | No          | Universal Product Code                                                            |
| `imageExternalId`          | string         | No          | Links variant to an image by its `externalId`                                     |
| `inventory`                | number \| null | No          | Total stock quantity. `null` clears inventory tracking. Alias: `quantity`         |
| `inventoryByLocation`      | object         | No          | Per-store inventory (see [Inventory by Location](#inventory-by-location))         |
| `inventoryPolicy`          | enum           | No          | `CONTINUE` or `DENY` when out of stock                                            |
| `prices`                   | array          | No          | Pricing per currency                                                              |
| `fitmentTargetExternalIds` | array          | No          | Fitment target external IDs (see [Fitment](/guides/fitment))                      |
| `sizeChartExternalId`      | string \| null | No          | External ID of a size chart to associate (see [Size Charts](/guides/size-charts)) |
| `meta:*`                   | any            | No          | Custom metadata (see [Metafields](#metafields))                                   |

<Tip>
  \*\*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.
</Tip>

### Price Fields

| Field            | Type   | Required | Description                                                        |
| ---------------- | ------ | -------- | ------------------------------------------------------------------ |
| `price`          | number | **Yes**  | Current selling price                                              |
| `compareAtPrice` | number | No       | Original price before discount (for showing strikethrough pricing) |
| `currency`       | string | **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                            |
| ------------ | ------ | -------- | -------------------------------------- |
| `url`        | string | **Yes**  | Image URL (max 2048 chars)             |
| `externalId` | string | No       | Unique ID. Defaults to MD5 hash of URL |
| `position`   | number | No       | Display 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.

```json theme={null}
{
  "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) |

<Tip>
  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.
</Tip>

***

## 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:

```json theme={null}
{
  "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.

<Note>
  **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.
</Note>

### 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](#store-location-api) below.

| Field                    | Type    | Required | Description                                                                                 |
| ------------------------ | ------- | -------- | ------------------------------------------------------------------------------------------- |
| `storeCode`              | string  | **Yes**  | Your identifier (must match keys in `inventoryByLocation`)                                  |
| `name`                   | string  | **Yes**  | Human-readable name (e.g., "Clay Terrace - Carmel, IN")                                     |
| `isActive`               | boolean | No       | Whether the location is active (default `true`). Inactive locations are skipped during sync |
| `phone`                  | string  | No       | Location phone number                                                                       |
| `latitude` / `longitude` | float   | No       | GPS coordinates                                                                             |
| `streetAddress`          | string  | No       | Street address                                                                              |
| `streetAddress2`         | string  | No       | Suite, unit, floor, etc.                                                                    |
| `city`                   | string  | No       | City                                                                                        |
| `region`                 | string  | No       | State / province                                                                            |
| `country`                | string  | No       | ISO 3166-1 alpha-2 code (e.g., `US`)                                                        |
| `postalCode`             | string  | 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

```graphql theme={null}
mutation CreateStoreLocation($input: CreateStoreLocationInput!) {
  createStoreLocation(input: $input) {
    id
    storeCode
    name
    isActive
  }
}
```

#### Example Request

<CodeGroup>
  ```bash cURL theme={null}
  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
        }
      }
    }'
  ```

  ```javascript Node.js theme={null}
  const response = await fetch('https://api.remark.ai/graphql', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer your-auth-token'
    },
    body: JSON.stringify({
      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
        }
      }
    })
  });

  const data = await response.json();
  ```

  ```python Python theme={null}
  import requests

  response = requests.post(
      'https://api.remark.ai/graphql',
      headers={
          'Content-Type': 'application/json',
          'Authorization': 'Bearer your-auth-token'
      },
      json={
          '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
              }
          }
      }
  )

  data = response.json()
  ```
</CodeGroup>

#### Response

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

### List Store Locations

#### GraphQL Query

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

#### Example Request

<CodeGroup>
  ```bash cURL theme={null}
  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"
      }
    }'
  ```

  ```javascript Node.js theme={null}
  const response = await fetch('https://api.remark.ai/graphql', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer your-auth-token'
    },
    body: JSON.stringify({
      query: `
        query StoreLocations($vendorId: ID!) {
          storeLocations(vendorId: $vendorId) {
            id
            storeCode
            name
            isActive
            streetAddress
            city
            region
            country
            postalCode
          }
        }
      `,
      variables: {
        vendorId: 'your-vendor-id'
      }
    })
  });

  const data = await response.json();
  ```

  ```python Python theme={null}
  import requests

  response = requests.post(
      'https://api.remark.ai/graphql',
      headers={
          'Content-Type': 'application/json',
          'Authorization': 'Bearer your-auth-token'
      },
      json={
          'query': '''
              query StoreLocations($vendorId: ID!) {
                  storeLocations(vendorId: $vendorId) {
                      id
                      storeCode
                      name
                      isActive
                      streetAddress
                      city
                      region
                      country
                      postalCode
                  }
              }
          ''',
          'variables': {
              'vendorId': 'your-vendor-id'
          }
      }
  )

  data = response.json()
  ```
</CodeGroup>

#### Response

```json theme={null}
{
  "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

```graphql theme={null}
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

<CodeGroup>
  ```bash cURL theme={null}
  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"
        }
      }
    }'
  ```

  ```javascript Node.js theme={null}
  const response = await fetch('https://api.remark.ai/graphql', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer your-auth-token'
    },
    body: JSON.stringify({
      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'
        }
      }
    })
  });

  const data = await response.json();
  ```

  ```python Python theme={null}
  import requests

  response = requests.post(
      'https://api.remark.ai/graphql',
      headers={
          'Content-Type': 'application/json',
          'Authorization': 'Bearer your-auth-token'
      },
      json={
          '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'
              }
          }
      }
  )

  data = response.json()
  ```
</CodeGroup>

#### Response

```json theme={null}
{
  "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

```graphql theme={null}
mutation DeleteStoreLocation($id: ID!) {
  deleteStoreLocation(id: $id) {
    id
  }
}
```

<Warning>
  Deleting a store location removes all associated inventory records for that location. Products that had inventory at the deleted location will be reindexed automatically.
</Warning>

#### Example Request

<CodeGroup>
  ```bash cURL theme={null}
  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"
      }
    }'
  ```

  ```javascript Node.js theme={null}
  const response = await fetch('https://api.remark.ai/graphql', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer your-auth-token'
    },
    body: JSON.stringify({
      query: `
        mutation DeleteStoreLocation($id: ID!) {
          deleteStoreLocation(id: $id) {
            id
          }
        }
      `,
      variables: {
        id: 'a1b2c3d4-5678-90ab-cdef-1234567890ab'
      }
    })
  });

  const data = await response.json();
  ```

  ```python Python theme={null}
  import requests

  response = requests.post(
      'https://api.remark.ai/graphql',
      headers={
          'Content-Type': 'application/json',
          'Authorization': 'Bearer your-auth-token'
      },
      json={
          'query': '''
              mutation DeleteStoreLocation($id: ID!) {
                  deleteStoreLocation(id: $id) {
                      id
                  }
              }
          ''',
          'variables': {
              'id': 'a1b2c3d4-5678-90ab-cdef-1234567890ab'
          }
      }
  )

  data = response.json()
  ```
</CodeGroup>

#### Response

```json theme={null}
{
  "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:

```bash theme={null}
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

<AccordionGroup>
  <Accordion title="Full Product with Location Inventory" icon="package-open" defaultOpen>
    A physical product with metafields and per-store inventory:

    ```json theme={null}
    {
      "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"]
    }
    ```
  </Accordion>

  <Accordion title="Experience / Class" icon="calendar">
    Products that are classes or scheduled experiences. Each variant is a specific session with its own date, time, and location:

    ```json theme={null}
    {
      "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)
  </Accordion>

  <Accordion title="Minimal New Product" icon="sprout">
    The minimum required to create a new product:

    ```json theme={null}
    {
      "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" }]
        }
      ]
    }
    ```
  </Accordion>

  <Accordion title="Inventory & Location Update (UPDATE)" icon="warehouse">
    Update total inventory and per-store breakdown:

    ```json theme={null}
    {
      "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:

    ```json theme={null}
    {
      "externalId": "prod-001",
      "variants": [
        { "externalId": "var-001-sm", "inventory": 5 },
        { "externalId": "var-001-md", "inventory": 12 }
      ]
    }
    ```
  </Accordion>

  <Accordion title="Price Update (UPDATE)" icon="tags">
    Update pricing for a variant — just set `price` to the current selling price:

    ```json theme={null}
    {
      "externalId": "prod-001",
      "variants": [
        {
          "externalId": "var-001-sm",
          "prices": [
            { "price": 139.99, "currency": "USD" }
          ]
        }
      ]
    }
    ```
  </Accordion>

  <Accordion title="Archive Product (UPDATE)" icon="archive">
    Archive a discontinued product:

    ```json theme={null}
    { "externalId": "prod-discontinued", "archived": true }
    ```

    Unarchive a product:

    ```json theme={null}
    { "externalId": "prod-discontinued", "archived": false }
    ```
  </Accordion>

  <Accordion title="Unpublish / Publish (UPDATE)" icon="eye-off">
    Unpublish a product (set to draft):

    ```json theme={null}
    { "externalId": "prod-seasonal", "publishedAt": null }
    ```

    Publish immediately:

    ```json theme={null}
    { "externalId": "prod-seasonal", "publishedAt": "2024-01-15T00:00:00Z" }
    ```

    Schedule future publish:

    ```json theme={null}
    { "externalId": "prod-preorder", "publishedAt": "2024-06-01T00:00:00Z" }
    ```
  </Accordion>

  <Accordion title="Product with Fitment Targets" icon="car">
    A product linked to specific vehicle fitment targets. Fitment targets must be created via the [Fitment API](/guides/fitment) before referencing them in imports.

    ```json theme={null}
    {
      "externalId": "brake-pad-001",
      "name": "Performance Brake Pads - Front",
      "brandName": "StopTech",
      "externalUrl": "https://store.example.com/products/brake-pad-001",
      "publishedAt": "2024-03-01T00:00:00Z",
      "fitmentTargetExternalIds": [
        "2024-honda-crf450r",
        "2023-honda-crf450r",
        "2024-honda-crf450rx"
      ],
      "variants": [
        {
          "externalId": "bp-001-organic",
          "name": "Organic Compound",
          "sku": "ST-BP-ORG-001",
          "prices": [{ "price": 49.99, "currency": "USD" }],
          "inventory": 150,
          "fitmentTargetExternalIds": ["2024-honda-crf450r"]
        },
        {
          "externalId": "bp-001-sintered",
          "name": "Sintered Compound",
          "sku": "ST-BP-SIN-001",
          "prices": [{ "price": 64.99, "currency": "USD" }],
          "inventory": 85
        }
      ]
    }
    ```

    <Note>
      Fitment target external IDs that don't match existing targets are ignored with a warning — they won't cause the import to fail.
    </Note>
  </Accordion>
</AccordionGroup>

***

## SFTP Setup

### Generate SSH Keys

Create an ED25519 key pair for secure authentication:

```bash theme={null}
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)

<Warning>
  Never share your private key. Only send the `.pub` file to Remark.
</Warning>

### Register Your Key

Email your **public key** to [support@remark.ai](mailto: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

```bash theme={null}
sftp -i ~/.ssh/remark_sftp your-username@sftp.remark.ai
```

### Upload Files

Always upload to the `incoming/` directory:

```bash theme={null}
sftp> cd incoming
sftp> put products-full-2024-01-15.jsonl
sftp> exit
```

<Note>
  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.
</Note>

### File Naming Conventions

Include date/time and mode for easier debugging:

| Type         | Pattern                            | Example                                  |
| ------------ | ---------------------------------- | ---------------------------------------- |
| Full sync    | `products-full-{date}.jsonl`       | `products-full-2024-01-15.jsonl`         |
| Delta update | `products-delta-{timestamp}.jsonl` | `products-delta-2024-01-15-103000.jsonl` |
| Inventory    | `inventory-{timestamp}.jsonl`      | `inventory-2024-01-15-103000.jsonl`      |

### Compression

Gzip compression is supported for faster uploads:

```bash theme={null}
gzip products-full-2024-01-15.jsonl
sftp> cd incoming
sftp> put products-full-2024-01-15.jsonl.gz
```

<Tip>
  Files on SFTP are retained for **30 days**, then automatically deleted.
</Tip>

***

## 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  |

<Info>
  Use REPLACE mode sparingly — once daily is typically sufficient. For all other updates, use UPDATE mode to avoid accidentally archiving products.
</Info>

***

## Error Handling

### GraphQL Errors

Authentication or permission errors return in the `errors` array:

```json theme={null}
{
  "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:

```json theme={null}
{
  "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

<CardGroup cols={2}>
  <Card title="Use UPDATE for frequent updates" icon="bolt">
    Reserve REPLACE mode for daily syncs. Use UPDATE for inventory, prices, and incremental changes.
  </Card>

  <Card title="Keep external IDs stable" icon="fingerprint-pattern">
    Never change a product's `externalId`. Changing it creates a duplicate product.
  </Card>

  <Card title="Use consistent meta: keys" icon="key">
    Pick a naming convention for your metafield keys and stick with it. Remark's AI uses these to understand your products.
  </Card>

  <Card title="Keep location codes stable" icon="map-pin">
    Use the same location codes in `inventoryByLocation` that match your configured store locations.
  </Card>

  <Card title="Validate JSONL locally" icon="check">
    Validate each line is valid JSON before uploading. Use `jq` or similar tools.
  </Card>

  <Card title="Monitor job status" icon="eye">
    Always check job status after imports to catch and address errors quickly.
  </Card>
</CardGroup>

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="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
  </Accordion>

  <Accordion title="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
  </Accordion>

  <Accordion title="SFTP connection refused">
    * 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
  </Accordion>

  <Accordion title="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
  </Accordion>

  <Accordion title="Metafields not appearing on products">
    * 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`)
  </Accordion>

  <Accordion title="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
  </Accordion>

  <Accordion title="Malformed JSON errors">
    * 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
  </Accordion>
</AccordionGroup>

***

## REST API Reference

<Accordion title="View REST API endpoints" icon="code">
  ### Start Import

  ```
  POST /api/v1/products/import
  ```

  **Request Body:**

  ```json theme={null}
  {
    "files": ["products-2024-01-15.jsonl"],
    "mode": "REPLACE"
  }
  ```

  **Response:**

  ```json theme={null}
  {
    "success": true,
    "jobId": "713aa624-1d7c-4e4a-ac23-6463e48a5fdd",
    "status": "queued",
    "mode": "REPLACE"
  }
  ```

  ### Check Job Status

  ```
  GET /api/v1/products/import/:jobId
  ```

  **Response:**

  ```json theme={null}
  {
    "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"
  }
  ```
</Accordion>

***

## GraphQL Schema Reference

<Accordion title="View full GraphQL schema" icon="code">
  ```graphql theme={null}
  # === 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
  }
  ```
</Accordion>

***

## Need Help?

<Card title="Contact Support" icon="mail" href="mailto:support@remark.ai">
  Email **[support@remark.ai](mailto:support@remark.ai)** with your Vendor ID, job ID (if applicable), error messages, and sample data.
</Card>
