# Rovela Store Data API

> Read-only REST API for exporting a Rovela store's data: orders, customers, products, and categories. Base URL: https://rovela.ai/api/v1. Authentication: per-store API key in the `x-api-key` header, created in the store dashboard under Settings then API. Human-readable docs: https://rovela.ai/developers. OpenAPI 3.1 spec: https://rovela.ai/api/v1/openapi.json.

## Introduction

The Store Data API gives you programmatic, read-only access to your store's data: orders, customers, products, and categories. Use it to sync your store with spreadsheets, BI dashboards, ERPs, accounting tools, or automation platforms like Zapier and Make.

All endpoints are `GET`, return JSON, and live under a stable versioned prefix:

**Base URL**

```bash
https://rovela.ai/api/v1
```

> Building with an AI assistant? The whole reference is available as plain markdown at [rovela.ai/developers.md](/developers.md) and as an OpenAPI 3.1 spec at [rovela.ai/api/v1/openapi.json](/api/v1/openapi.json). Paste either into your assistant and ask it to write your integration.

## Quickstart

From zero to your first JSON response in about two minutes.

### 1. Create an API key

Open [your dashboard](/projects), pick a store, and go to **Settings → API → Create key**. The full key (it starts with `rvk_`) is shown exactly once, so copy it somewhere safe. Each key belongs to one store.

### 2. Check the connection

```bash
curl -H "x-api-key: rvk_YOUR_KEY" \
  https://rovela.ai/api/v1/store
```

**Response**

```json
{
  "data": {
    "id": "7b1e9c40-52aa-4f0e-9d2b-8f6c3a1d5e77",
    "name": "Aurora Ceramics",
    "store_name": "Aurora Ceramics",
    "store_email": "hello@auroraceramics.com",
    "store_currency": "USD",
    "store_timezone": "America/New_York",
    "tax_included_in_prices": false,
    "shipping_enabled": true,
    "guest_checkout": true,
    "customer_accounts": true,
    "counts": { "products": 142, "categories": 9, "customers": 830, "orders": 1204 }
  }
}
```

### 3. Pull your orders

```bash
curl -H "x-api-key: rvk_YOUR_KEY" \
  "https://rovela.ai/api/v1/orders?limit=100"
```

When `meta.has_more` is true, pass `meta.next_cursor` back to get the next page. See [Pagination](#pagination) for the full walk.

## Authentication

Every request needs your API key in the `x-api-key` header. Keys are scoped to exactly one store, which is why no store identifier appears in any URL.

```bash
curl -H "x-api-key: rvk_YOUR_KEY" \
  https://rovela.ai/api/v1/orders
```

A store can have up to 5 active keys, managed in **Settings → API**. Only a hash of each key is stored, so a lost key cannot be recovered, only replaced. Revoking a key takes effect on the very next request.

> **Keep keys on your server.** The API deliberately sends no CORS headers, so browser calls fail by design. A key embedded in a website or mobile app would be readable by anyone, and it grants access to your customer data.

## Response format

Every endpoint uses the same envelope. List endpoints return an array plus pagination metadata; detail endpoints return a single object; errors return a code and a human-readable message.

**List response**

```json
{
  "data": [ { "id": "…" } ],
  "meta": { "next_cursor": "MjAyNi0wNi0xNC…", "has_more": true }
}
```

**Error response**

```json
{
  "error": { "code": "INVALID_API_KEY", "message": "Invalid or revoked API key." }
}
```

| Convention | Detail |
| --- | --- |
| Field names | snake_case, matching the store database. |
| Money | Strings with 2 decimals, for example "49.90", never floats. The store currency is on /api/v1/store. |
| Timestamps | ISO 8601 in UTC, for example "2026-06-14T09:21:33.000Z". |
| Ids | UUIDs, unique within a store. |
| Guest checkout | orders.customer_id is null for guest orders; orders.email is always present. |
| Forward compatibility | New fields may appear at any time. Write clients that ignore fields they do not recognize. |

## Pagination

List endpoints are cursor-paginated, which stays correct even while orders are being written mid-export (offset pagination would skip or repeat rows). Rows come back oldest first by creation time. Cursors are opaque: pass them back exactly as received and never parse them.

| Parameter | Description |
| --- | --- |
| limit | Page size, 1 to 250. Defaults to 50. Use 250 for bulk exports. |
| cursor | The meta.next_cursor value from the previous page. |

**Walk every page**

```javascript
const API_KEY = process.env.ROVELA_API_KEY;

async function exportAllOrders() {
  const orders = [];
  let cursor = null;

  do {
    const url = new URL('https://rovela.ai/api/v1/orders');
    url.searchParams.set('limit', '250');
    if (cursor) url.searchParams.set('cursor', cursor);

    const res = await fetch(url, { headers: { 'x-api-key': API_KEY } });
    if (res.status === 429) {
      const wait = Number(res.headers.get('Retry-After') ?? 5);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    if (!res.ok) throw new Error(`Export failed: ${res.status}`);

    const { data, meta } = await res.json();
    orders.push(...data);
    cursor = meta.has_more ? meta.next_cursor : null;
  } while (cursor);

  return orders;
}
```

## Incremental sync

For a recurring sync (hourly, nightly) you do not need full exports. Orders and products support `updated_since`; customers support `created_since` (customer records carry no updated timestamp).

1. On the first run, record your own `last_synced_at` timestamp taken **before** the run starts, then walk every page.
2. On every following run, request only what changed and upsert by `id`.
3. On success, advance `last_synced_at` to the new run's start time.

**Hourly order sync**

```javascript
const url = new URL('https://rovela.ai/api/v1/orders');
url.searchParams.set('limit', '250');
url.searchParams.set('updated_since', lastSyncedAt); // ISO 8601

const res = await fetch(url, { headers: { 'x-api-key': API_KEY } });
const { data } = await res.json();
for (const order of data) upsertByOrderId(order);
```

> Taking the timestamp before the run (not after) means an order updated while the run was in flight is picked up again next time. Duplicate upserts by id are harmless; missed rows are not.

## Rate limits

Each key allows 120 requests per minute. Above that the API returns `429 RATE_LIMITED` with a `Retry-After` header (seconds). Treat the limit as a target rather than a contract: it can be slightly more generous under horizontal scaling.

A full export of a large store fits comfortably inside the limit: 120 pages of 250 rows moves 30,000 rows per minute. If you hit 429s, request bigger pages instead of retrying faster.

## Errors

Every error is JSON with a stable machine code and a human message. The codes below are the complete catalog, with what each one means and whether a retry can succeed.

| Status | Code | When it happens | What to do | Retry? |
| --- | --- | --- | --- | --- |
| 401 | INVALID_API_KEY | The x-api-key header is missing, malformed, or the key was revoked. Revocation takes effect on the very next request. | Check that the header name is exactly x-api-key and the value starts with rvk_. If the key was revoked, create a new one in your store dashboard under Settings then API. | No |
| 403 | STORE_NOT_SUPPORTED | The key belongs to an imported store. Imported stores run on their own infrastructure, so their data does not live on Rovela. | Query the store on its own hosting instead. The Store Data API only serves stores generated on Rovela. | No |
| 409 | STORE_NOT_READY | The store exists but its database has not been provisioned yet, which only happens while the initial generation is still running. | Wait for the store to finish generating, then retry. | Yes |
| 400 | INVALID_PARAM | A query parameter failed validation: a limit outside 1 to 250, an unknown status value, a malformed datetime, or a non-UUID resource id. | The error message names the offending parameter and the accepted values. Correct the request. | No |
| 400 | INVALID_CURSOR | The cursor value could not be decoded. Cursors are opaque and only valid when passed back exactly as received. | Pass meta.next_cursor back verbatim. Never parse, edit, or store cursors long term; restart the walk from the first page if one goes stale. | No |
| 404 | NOT_FOUND | The resource id is a valid UUID but no matching row exists in this store. | Confirm the id belongs to this store. Ids are never shared across stores. | No |
| 429 | RATE_LIMITED | The key exceeded 120 requests in the current minute. | Honor the Retry-After header (seconds) before retrying. For bulk exports, request limit=250 pages instead of many small pages. | With backoff |
| 502 | STORE_DB_ERROR | A transient failure while reading the store database. | Retry with exponential backoff. If it persists for more than a few minutes, contact support. | With backoff |

## Versioning

The `/api/v1` prefix is stable. Additive changes (new fields, endpoints, or filters) land in v1 without notice, which is why clients should ignore unknown fields. A breaking change would ship as `/api/v2` with a migration window, never as an in-place change to v1.

## Store

A snapshot of the store the key belongs to: identity, currency, configuration flags, and row counts per resource. Use it as a connection sanity check and to learn the currency your money fields are denominated in.

**The store object**

| Field | Type | Description |
| --- | --- | --- |
| id | uuid | The store id on Rovela. |
| name | string | The store name on the Rovela dashboard. |
| store_name | string or null | The customer-facing store name. |
| store_email | string or null | The store contact email. |
| store_currency | string or null | ISO 4217 currency code all money fields use, for example "USD". |
| store_timezone | string or null | IANA timezone, for example "America/New_York". |
| tax_included_in_prices | boolean or null | Whether displayed prices already include tax. |
| shipping_enabled | boolean or null | Whether the store ships physical goods. |
| guest_checkout | boolean or null | Whether customers can buy without an account. |
| customer_accounts | boolean or null | Whether customer accounts are enabled. |
| counts | object | Row counts: products, categories, customers, orders. |

### Retrieve the store

`GET /api/v1/store`

Returns the store snapshot for the key you authenticate with. If this call works, every other endpoint will too.

**Example request**

```bash
curl -H "x-api-key: rvk_YOUR_KEY" \
  https://rovela.ai/api/v1/store
```

```javascript
const res = await fetch('https://rovela.ai/api/v1/store', {
  headers: { 'x-api-key': process.env.ROVELA_API_KEY },
});
const { data: store } = await res.json();
```

**Example response**

```json
{
  "data": {
    "id": "7b1e9c40-52aa-4f0e-9d2b-8f6c3a1d5e77",
    "name": "Aurora Ceramics",
    "store_name": "Aurora Ceramics",
    "store_email": "hello@auroraceramics.com",
    "store_currency": "USD",
    "store_timezone": "America/New_York",
    "tax_included_in_prices": false,
    "shipping_enabled": true,
    "guest_checkout": true,
    "customer_accounts": true,
    "counts": { "products": 142, "categories": 9, "customers": 830, "orders": 1204 }
  }
}
```

Endpoint-specific errors: `STORE_NOT_SUPPORTED`, `STORE_NOT_READY` (plus the universal `INVALID_API_KEY` and `RATE_LIMITED`). See the Errors section.

## Orders

Orders placed on the storefront, with their line items embedded. Money fields are strings in the store currency. Guest orders have a null customer_id but always carry the buyer's email.

**The orders object**

| Field | Type | Description |
| --- | --- | --- |
| id | uuid | Order id. |
| customer_id | uuid or null | The buyer's customer id, or null for guest checkout. |
| email | string | The buyer's email. Always present. |
| status | string | One of pending, paid, shipped, delivered, cancelled, refunded, return_requested. |
| subtotal | decimal string | Item total before tax, shipping, and discounts. |
| tax | decimal string | Tax charged. |
| shipping | decimal string | Shipping charged. |
| total | decimal string | Amount the customer paid. |
| refund_amount | decimal string or null | Total refunded so far. |
| return_reason | string or null | The customer's reason when a return was requested. |
| discount_code | string or null | The promotion code the customer redeemed, if any. |
| discount_amount | decimal string or null | Amount the discount removed. |
| shipping_address | object or null | firstName, lastName, line1, line2, city, state, postalCode, country, phone. |
| billing_address | object or null | Same shape as shipping_address. |
| stripe_payment_intent_id | string or null | Stripe PaymentIntent id, for reconciliation. |
| stripe_charge_id | string or null | Stripe Charge id. |
| receipt_url | string or null | Stripe-hosted receipt for the customer. |
| tracking_number | string or null | Shipment tracking number once fulfilled. |
| tracking_url | string or null | Carrier tracking link. |
| carrier | string or null | Carrier code, for example "ups". |
| shipping_method | string or null | The shipping option the customer chose. |
| created_at | timestamp | When the order was placed. |
| updated_at | timestamp | Last change to the order. Drives updated_since. |
| items | array | The order's line items, embedded. See Order item below. |

**Order item**

One line of an order. Name and price are snapshots taken at purchase time, so they stay correct even after the product changes.

| Field | Type | Description |
| --- | --- | --- |
| id | uuid | Line item id. |
| order_id | uuid | The parent order. |
| product_id | uuid | The product purchased. |
| variant_id | uuid or null | The variant purchased, if the product has variants. |
| name | string | Product name at purchase time. |
| price | decimal string | Unit price at purchase time. |
| quantity | integer | Units purchased. |
| attributes | object or null | Variant attributes at purchase time, for example {"color": "Sand"}. |
| created_at | timestamp | When the line was written. |

### List orders

`GET /api/v1/orders`

Returns orders oldest first, line items embedded. Filter by status or by updated_since for incremental syncs.

**Query parameters**

| Parameter | Type | Description |
| --- | --- | --- |
| limit | integer | Page size, 1 to 250. Defaults to 50. |
| cursor | string | Opaque cursor from the previous page's meta.next_cursor. |
| updated_since | ISO 8601 datetime | Only orders updated at or after this instant. |
| status | string | One of pending, paid, shipped, delivered, cancelled, refunded, return_requested. |

**Example request**

```bash
curl -H "x-api-key: rvk_YOUR_KEY" \
  "https://rovela.ai/api/v1/orders?status=paid&limit=100"
```

```javascript
const url = new URL('https://rovela.ai/api/v1/orders');
url.searchParams.set('status', 'paid');
url.searchParams.set('limit', '100');

const res = await fetch(url, {
  headers: { 'x-api-key': process.env.ROVELA_API_KEY },
});
const { data: orders, meta } = await res.json();
```

**Example response**

```json
{
  "data": [
    {
      "id": "9f4e2c1a-7d3b-4e8f-a51c-2b9d6e0f8a34",
      "customer_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
      "email": "claire@example.com",
      "status": "paid",
      "subtotal": "84.00",
      "tax": "7.56",
      "shipping": "9.90",
      "total": "93.06",
      "refund_amount": "0.00",
      "return_reason": null,
      "discount_code": "WELCOME10",
      "discount_amount": "8.40",
      "shipping_address": {
        "firstName": "Claire",
        "lastName": "Fontaine",
        "line1": "428 Willow Court",
        "city": "Portland",
        "state": "OR",
        "postalCode": "97205",
        "country": "US",
        "phone": "+1 503 555 0114"
      },
      "billing_address": null,
      "stripe_payment_intent_id": "pi_3RfKw2Ab4cD5eF6g",
      "stripe_charge_id": "ch_3RfKw2Ab4cD5eF6g",
      "receipt_url": "https://pay.stripe.com/receipts/…",
      "tracking_number": null,
      "tracking_url": null,
      "carrier": null,
      "shipping_method": "Standard (3 to 5 days)",
      "created_at": "2026-06-14T09:21:33.000Z",
      "updated_at": "2026-06-14T09:21:35.000Z",
      "items": [
        {
          "id": "e7a8b9c0-d1e2-4f3a-8b4c-5d6e7f8a9b0c",
          "order_id": "9f4e2c1a-7d3b-4e8f-a51c-2b9d6e0f8a34",
          "product_id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
          "variant_id": null,
          "name": "Aurora Mug, Glazed Sand",
          "price": "42.00",
          "quantity": 2,
          "attributes": null,
          "created_at": "2026-06-14T09:21:33.000Z"
        }
      ]
    }
  ],
  "meta": { "next_cursor": "MjAyNi0wNi0xNCAwOToyMTozM3w5ZjRlMmMxYQ", "has_more": true }
}
```

Endpoint-specific errors: `INVALID_PARAM`, `INVALID_CURSOR` (plus the universal `INVALID_API_KEY` and `RATE_LIMITED`). See the Errors section.

### Retrieve an order

`GET /api/v1/orders/{id}`

Returns a single order with its line items.

**Path parameters**

| Parameter | Type | Description |
| --- | --- | --- |
| id (required) | uuid | The order id. |

**Example request**

```bash
curl -H "x-api-key: rvk_YOUR_KEY" \
  https://rovela.ai/api/v1/orders/9f4e2c1a-7d3b-4e8f-a51c-2b9d6e0f8a34
```

```javascript
const res = await fetch(
  `https://rovela.ai/api/v1/orders/${orderId}`,
  { headers: { 'x-api-key': process.env.ROVELA_API_KEY } }
);
const { data: order } = await res.json();
```

**Example response**

```json
{
  "data": {
    "id": "9f4e2c1a-7d3b-4e8f-a51c-2b9d6e0f8a34",
    "status": "paid",
    "total": "93.06",
    "items": [ { "name": "Aurora Mug, Glazed Sand", "quantity": 2 } ]
  }
}
```

Endpoint-specific errors: `INVALID_PARAM`, `NOT_FOUND` (plus the universal `INVALID_API_KEY` and `RATE_LIMITED`). See the Errors section.

## Customers

Customers who created an account or checked out on the storefront. Passwords are never exposed through the API, on any endpoint.

**The customers object**

| Field | Type | Description |
| --- | --- | --- |
| id | uuid | Customer id. |
| email | string | The customer's email. Unique within the store. |
| name | string or null | The customer's name, when provided. |
| stripe_customer_id | string or null | Stripe Customer id, for reconciliation. |
| email_verified | timestamp or null | When the email was verified, or null if it never was. |
| created_at | timestamp | When the customer record was created. Drives created_since. |

### List customers

`GET /api/v1/customers`

Returns customers oldest first. Customer records are effectively append-only, so created_since gives you a clean incremental sync.

**Query parameters**

| Parameter | Type | Description |
| --- | --- | --- |
| limit | integer | Page size, 1 to 250. Defaults to 50. |
| cursor | string | Opaque cursor from the previous page's meta.next_cursor. |
| created_since | ISO 8601 datetime | Only customers created at or after this instant. |

**Example request**

```bash
curl -H "x-api-key: rvk_YOUR_KEY" \
  "https://rovela.ai/api/v1/customers?limit=100"
```

```javascript
const res = await fetch(
  'https://rovela.ai/api/v1/customers?limit=100',
  { headers: { 'x-api-key': process.env.ROVELA_API_KEY } }
);
const { data: customers, meta } = await res.json();
```

**Example response**

```json
{
  "data": [
    {
      "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
      "email": "claire@example.com",
      "name": "Claire Fontaine",
      "stripe_customer_id": "cus_QxAb4cD5eF6gH7",
      "email_verified": "2026-03-02T18:40:12.000Z",
      "created_at": "2026-03-02T18:38:55.000Z"
    }
  ],
  "meta": { "next_cursor": null, "has_more": false }
}
```

Endpoint-specific errors: `INVALID_PARAM`, `INVALID_CURSOR` (plus the universal `INVALID_API_KEY` and `RATE_LIMITED`). See the Errors section.

### Retrieve a customer

`GET /api/v1/customers/{id}`

Returns a single customer.

**Path parameters**

| Parameter | Type | Description |
| --- | --- | --- |
| id (required) | uuid | The customer id. |

**Example request**

```bash
curl -H "x-api-key: rvk_YOUR_KEY" \
  https://rovela.ai/api/v1/customers/c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f
```

**Example response**

```json
{
  "data": {
    "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
    "email": "claire@example.com",
    "name": "Claire Fontaine",
    "stripe_customer_id": "cus_QxAb4cD5eF6gH7",
    "email_verified": "2026-03-02T18:40:12.000Z",
    "created_at": "2026-03-02T18:38:55.000Z"
  }
}
```

Endpoint-specific errors: `INVALID_PARAM`, `NOT_FOUND` (plus the universal `INVALID_API_KEY` and `RATE_LIMITED`). See the Errors section.

## Products

The store catalog, with variants embedded on every product. Products without variants return an empty variants array; their price and inventory live on the product itself.

**The products object**

| Field | Type | Description |
| --- | --- | --- |
| id | uuid | Product id. |
| name | string | Product name. |
| slug | string | URL slug, unique within the store. |
| description | string | Product description. |
| price | decimal string | Base price. Variants can override it. |
| compare_price | decimal string or null | The crossed-out "was" price, when on sale. |
| images | string[] | Image URLs, in display order. |
| videos | string[] | Video URLs. |
| status | string | One of draft, active, archived. |
| category_id | uuid or null | The category the product belongs to. |
| has_variants | boolean | Whether the product sells in variants. |
| sku | string or null | Stock keeping unit, when set. |
| inventory | integer or null | Units in stock (for products without variants). |
| track_inventory | boolean or null | Whether stock is tracked. |
| continue_selling_when_out_of_stock | boolean or null | Whether checkout stays open at zero stock. |
| product_type | string or null | Free-form type label. |
| vendor | string or null | Brand or supplier name. |
| tags | string[] or null | Free-form tags. |
| meta_title | string or null | SEO title override. |
| meta_description | string or null | SEO description override. |
| meta_keywords | string[] or null | SEO keywords. |
| metafields | array | Custom fields as {key, label, value} objects. |
| created_at | timestamp | When the product was created. |
| updated_at | timestamp | Last change. Drives updated_since. |
| variants | array | The product's variants, embedded. See Variant below. |

**Variant**

One sellable variation of a product, for example a size and color combination. A null price means the variant sells at the product's base price.

| Field | Type | Description |
| --- | --- | --- |
| id | uuid | Variant id. |
| product_id | uuid | The parent product. |
| sku | string | Stock keeping unit, unique within the store. |
| name | string | Variant display name, for example "Sand / Large". |
| price | decimal string or null | Price override, or null to use the product price. |
| inventory | integer | Units in stock for this variant. |
| image | string or null | Variant-specific image URL. |
| video | string or null | Variant-specific video URL. |
| attributes | object | The option values, for example {"color": "Sand", "size": "Large"}. |
| created_at | timestamp | When the variant was created. |

### List products

`GET /api/v1/products`

Returns products oldest first, variants embedded. Filter by status to export only the live catalog, or by updated_since for incremental syncs.

**Query parameters**

| Parameter | Type | Description |
| --- | --- | --- |
| limit | integer | Page size, 1 to 250. Defaults to 50. |
| cursor | string | Opaque cursor from the previous page's meta.next_cursor. |
| updated_since | ISO 8601 datetime | Only products updated at or after this instant. |
| status | string | One of draft, active, archived. |

**Example request**

```bash
curl -H "x-api-key: rvk_YOUR_KEY" \
  "https://rovela.ai/api/v1/products?status=active&limit=100"
```

```javascript
const res = await fetch(
  'https://rovela.ai/api/v1/products?status=active&limit=100',
  { headers: { 'x-api-key': process.env.ROVELA_API_KEY } }
);
const { data: products, meta } = await res.json();
```

**Example response**

```json
{
  "data": [
    {
      "id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
      "name": "Aurora Mug",
      "slug": "aurora-mug",
      "description": "A hand-thrown stoneware mug with a glazed interior.",
      "price": "42.00",
      "compare_price": null,
      "images": ["https://media.rovela.app/stores/…/aurora-mug-1.webp"],
      "videos": [],
      "status": "active",
      "category_id": "8d9e0f1a-2b3c-4d5e-8f6a-7b8c9d0e1f2a",
      "has_variants": true,
      "sku": null,
      "inventory": 0,
      "track_inventory": true,
      "continue_selling_when_out_of_stock": false,
      "product_type": "Drinkware",
      "vendor": "Aurora Ceramics",
      "tags": ["mug", "stoneware"],
      "meta_title": null,
      "meta_description": null,
      "meta_keywords": [],
      "metafields": [],
      "created_at": "2026-02-11T10:05:00.000Z",
      "updated_at": "2026-06-01T15:12:44.000Z",
      "variants": [
        {
          "id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
          "product_id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
          "sku": "MUG-SAND",
          "name": "Glazed Sand",
          "price": null,
          "inventory": 18,
          "image": null,
          "video": null,
          "attributes": { "color": "Sand" },
          "created_at": "2026-02-11T10:05:01.000Z"
        }
      ]
    }
  ],
  "meta": { "next_cursor": "MjAyNi0wMi0xMSAxMDowNTowMHwzYzRkNWU2Zg", "has_more": true }
}
```

Endpoint-specific errors: `INVALID_PARAM`, `INVALID_CURSOR` (plus the universal `INVALID_API_KEY` and `RATE_LIMITED`). See the Errors section.

### Retrieve a product

`GET /api/v1/products/{id}`

Returns a single product with its variants.

**Path parameters**

| Parameter | Type | Description |
| --- | --- | --- |
| id (required) | uuid | The product id. |

**Example request**

```bash
curl -H "x-api-key: rvk_YOUR_KEY" \
  https://rovela.ai/api/v1/products/3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f
```

**Example response**

```json
{
  "data": {
    "id": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
    "name": "Aurora Mug",
    "slug": "aurora-mug",
    "price": "42.00",
    "status": "active",
    "variants": [ { "sku": "MUG-SAND", "inventory": 18 } ]
  }
}
```

Endpoint-specific errors: `INVALID_PARAM`, `NOT_FOUND` (plus the universal `INVALID_API_KEY` and `RATE_LIMITED`). See the Errors section.

## Categories

The store's category tree. Categories can nest one level via parent_id; a null parent_id marks a top-level category.

**The categories object**

| Field | Type | Description |
| --- | --- | --- |
| id | uuid | Category id. |
| name | string | Category name. |
| slug | string | URL slug, unique within the store. |
| description | string or null | Category description. |
| image | string or null | Cover image URL. |
| parent_id | uuid or null | The parent category, or null for top level. |
| order | integer | Display position among siblings. |
| created_at | timestamp | When the category was created. |

### List categories

`GET /api/v1/categories`

Returns categories oldest first.

**Query parameters**

| Parameter | Type | Description |
| --- | --- | --- |
| limit | integer | Page size, 1 to 250. Defaults to 50. |
| cursor | string | Opaque cursor from the previous page's meta.next_cursor. |

**Example request**

```bash
curl -H "x-api-key: rvk_YOUR_KEY" \
  "https://rovela.ai/api/v1/categories?limit=100"
```

```javascript
const res = await fetch(
  'https://rovela.ai/api/v1/categories?limit=100',
  { headers: { 'x-api-key': process.env.ROVELA_API_KEY } }
);
const { data: categories, meta } = await res.json();
```

**Example response**

```json
{
  "data": [
    {
      "id": "8d9e0f1a-2b3c-4d5e-8f6a-7b8c9d0e1f2a",
      "name": "Drinkware",
      "slug": "drinkware",
      "description": "Mugs, cups, and carafes.",
      "image": null,
      "parent_id": null,
      "order": 1,
      "created_at": "2026-02-11T10:00:00.000Z"
    }
  ],
  "meta": { "next_cursor": null, "has_more": false }
}
```

Endpoint-specific errors: `INVALID_PARAM`, `INVALID_CURSOR` (plus the universal `INVALID_API_KEY` and `RATE_LIMITED`). See the Errors section.

## Changelog

### 2026-07-08: Store Data API v1 launch

First public release: read-only access to the store snapshot, orders (with line items), customers, products (with variants), and categories. Cursor pagination, incremental sync filters, per-store API keys with instant revocation, and a 120 requests per minute rate limit.
