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

# Migrating from V2 to V3

> Why and how to move your Snappy integration from the V2 API to the V3 API - authentication, JSON:API conventions, the new first-class Orders model, the refreshed Marketplace catalog, and a full endpoint mapping.

This guide explains **why** you'd move an existing integration from V2 to V3, and **how** to do it endpoint by endpoint. V3 is the recommended standard for all new work.

***

## Why migrate

V3 is a ground-up refresh that standardizes conventions across every endpoint and unlocks surfaces that V2 never exposed.

| Area                    | What V3 gives you                                                                                                                                                                   |
| :---------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Catalog performance** | \~85% lower catalog latency and **static product/variant IDs** that no longer change with real-time availability - so you can cache and store IDs safely.                           |
| **Orders**              | Orders are **first-class resources**. Place an order in a **single idempotent call** instead of the V2 Campaign → Gift → claim chain, then retrieve, list, and cancel by `orderId`. |
| **Consistency**         | One **JSON:API** convention set across all endpoints: `filter[...]`, `include`, `sort`, `page[...]`, and `camelCase` fields everywhere.                                             |
| **Discovery**           | Default sorting by popularity, enhanced **semantic search**, and a `marketplace` vs `swag` catalog split.                                                                           |
| **New surfaces**        | Billing Methods, asynchronous bulk **Export**, Swag base products, and Accounts - all introduced in V3.                                                                             |

<Tip>
  You don't have to migrate everything at once. A common path is: keep V2 gifting running, adopt **V3 Marketplace** for catalog reads first (biggest performance win, lowest risk), then move order placement to **V3 Orders**.
</Tip>

***

## Before you begin

### Your existing API key already works

The **same `X-Api-Key`** authenticates both V2 and V3. The version lives in the URL path - there is nothing new to provision to start calling V3.

```bash theme={null}
# V2 - existing call
curl --request GET \
  --url https://api.snappy.com/public-api/v2/accounts \
  --header 'X-Api-Key: YOUR_API_KEY'

# V3 - same key, just the version in the path changes
curl --request GET \
  --url https://api.snappy.com/public-api/v3/accounts \
  --header 'X-Api-Key: YOUR_API_KEY'
```

Scopes are shared too: a key with `products:read` already reaches both `/v2/products` and `/v3/products`. See [Authentication & Security](/pages/authentication-and-security) for the full scope table.

### Generating an API key (recap)

If you're starting fresh or want a dedicated key for your V3 rollout:

<Steps>
  <Step title="Open Company Settings">
    Log in at [login.snappy.com](https://login.snappy.com/login) and go to **Sharing & Access** under **Company Settings**.
  </Step>

  <Step title="Generate a key">
    Under **API Access**, click **Generate Key**, name it, and set an expiration (up to one year).
  </Step>

  <Step title="Assign scopes">
    Grant the minimum scopes your integration needs (e.g. `products:read`, `orders:create`, `orders:read:masked`). The same scopes apply to V2 and V3.
  </Step>

  <Step title="Configure PII access">
    Toggle **Expose Sensitive Information** to decide whether reads resolve to `:read:masked` or `:read:unmasked`.
  </Step>

  <Step title="Copy the secret immediately">
    The secret is shown **once**. Store it securely - if you lose it, rotate the key.
  </Step>
</Steps>

<Note>
  **Two ways to manage API keys.** Create and manage keys in the Snappy dashboard on the **Sharing & Access** page, or manage them programmatically with an existing `X-Api-Key` via the key-management endpoints (`/v2/authentication/apiKeys` or `/v3/authentication/api-keys`). See [API Keys (V3)](/modules/api/v3/api-keys/overview).
</Note>

### New optional scoping header (V3)

V3 endpoints accept an optional header that narrows a request to a sub-entity. Omit it to run against the full org reachable by the key.

| Header              | Purpose                                  |
| :------------------ | :--------------------------------------- |
| `Snappy-Account-Id` | Scope the request to a specific Account. |

<Note>
  A few endpoints - currently the V3 Collections list and by-ID endpoints - **require** `Snappy-Account-Id`. Each reference page notes when it applies.
</Note>

***

## What changes at a glance

Most of the migration work is mechanical: swap the version in the path and adopt V3's JSON:API query conventions. This table is your cheat sheet.

| Convention       | V2                                               | V3                                                                                         |
| :--------------- | :----------------------------------------------- | :----------------------------------------------------------------------------------------- |
| Base path        | `/public-api/v2/...`                             | `/public-api/v3/...`                                                                       |
| Field casing     | Mixed (`firstname`, `orderRecipient`)            | Consistent `camelCase`                                                                     |
| Pagination       | `skip` / `limit` (offset)                        | `page[number]` / `page[size]` - or `page[cursor]` / `page[size]` on product list endpoints |
| Related entities | `expand[]=product` (array)                       | `include=brand,tags` (comma-separated)                                                     |
| Filtering        | Bespoke per endpoint (`minBudget`, `brandName`…) | `filter[field]=...`, ranges via `filter[field][gte]` / `[lte]`                             |
| Sorting          | Bespoke per endpoint                             | `sort=field` / `sort=-field` (single field)                                                |
| Field selection  | `fields=id,name`                                 | `fields=id,name` (unchanged)                                                               |
| Dates            | ISO 8601 UTC                                     | ISO 8601 UTC (unchanged)                                                                   |
| Metadata         | Up to 50 pairs                                   | Up to 50 pairs (unchanged)                                                                 |

### Pagination: offset → page/cursor

```bash theme={null}
# V2 - offset pagination
GET /v2/orders?skip=200&limit=100

# V3 - page-number pagination (most list endpoints)
GET /v3/orders?page[number]=3&page[size]=100
```

V3 responses include a top-level `links` object - use `links.next` **verbatim** to fetch the next page rather than building URLs yourself:

```json theme={null}
{
  "data": [ /* items */ ],
  "links": {
    "first": "/v3/orders?page[number]=1&page[size]=100",
    "next":  "/v3/orders?page[number]=4&page[size]=100",
    "prev":  "/v3/orders?page[number]=2&page[size]=100"
  },
  "meta": { "total": 247 }
}
```

<Warning>
  High-volume product list endpoints (`GET /v3/products`, `GET /v3/collections/{id}/products`, `GET /v3/products/{id}/variants`) use **cursor** pagination instead: `page[cursor]` + `page[size]`. `links.prev` is always `null` (no backward navigation). Never parse or construct cursors manually - follow `links.next`.
</Warning>

### Filtering & sorting: bespoke → JSON:API

```bash theme={null}
# V2 - bespoke params
GET /v2/products?minBudget=25&maxBudget=200&brandName=acme

# V3 - JSON:API filter[] with range brackets, plus sort
GET /v3/products?filter[price][gte]=25&filter[price][lte]=200&filter[brandName]=acme&sort=minPrice
```

Multiple `filter[...]` expressions are ANDed. Unknown filter keys return `400`.

### Hydration: `expand[]` → `include`

```bash theme={null}
# V2
GET /v2/gifts/gft_abcd123?expand[]=product&expand[]=recipient

# V3
GET /v3/products/prd_98765?include=brand,tags
```

***

## Deep dive - Orders

This is the biggest behavioral change in V3. In V2, an Order is a **sub-entity of a Gift**: even Direct Fulfillment goes through a two-step gift-and-claim flow, and you retrieve order state by reading the parent Gift. In V3, **Orders are first-class** - created, retrieved, listed, and cancelled directly.

### Placing an order

**V2 - two steps (create gift, then claim):**

```bash theme={null}
# 1. Create a gift under a campaign
curl --request POST \
  --url https://api.snappy.com/public-api/v2/gifts \
  --header 'X-Api-Key: YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "campaignId": "cmp_12345",
    "recipients": [
      { "firstname": "Jordan", "lastname": "Lee", "email": "jordan@example.com", "key": "order-9982" }
    ]
  }'

# 2. Claim the gift to place the order
curl --request POST \
  --url https://api.snappy.com/public-api/v2/gifts/gft_abcd123/claim \
  --header 'X-Api-Key: YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "variantId": "var_55555",
    "recipient": {
      "firstName": "Jordan",
      "lastName": "Lee",
      "address1": "123 Main St",
      "city": "Austin",
      "provinceCode": "TX",
      "postalCode": "78701",
      "countryCode": "US"
    }
  }'
```

**V3 - one idempotent call:**

```bash theme={null}
curl --request POST \
  --url https://api.snappy.com/public-api/v3/orders \
  --header 'X-Api-Key: YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "idempotencyKey": "order-9982",
    "billingMethodId": "bm_abc123",
    "variantId": "var_55555",
    "recipient": {
      "firstName": "Jordan",
      "lastName": "Lee",
      "email": "jordan@example.com",
      "phone": "+15125550123"
    },
    "shippingAddress": {
      "address1": "123 Main St",
      "city": "Austin",
      "provinceCode": "TX",
      "postalCode": "78701",
      "countryCode": "US"
    },
    "tags": ["q4-campaign"],
    "metadata": { "externalRecipientId": "crm-user-987" }
  }'
```

<Note>
  In V3 the recipient's contact details and the **shipping address are separate objects** (`recipient` + `shippingAddress`), whereas V2's claim payload merged them. The `idempotencyKey` (1–120 chars) is **required** in V3 and replaces the V2 recipient `key` for double-billing protection.
</Note>

### Retrieving, listing, and cancelling

| Operation            | V2                                                 | V3                                                                   |
| :------------------- | :------------------------------------------------- | :------------------------------------------------------------------- |
| Get order            | `GET /v2/gifts/{giftId}` (read order off the Gift) | `GET /v3/orders/{orderId}`                                           |
| List orders          | - *(no direct list; iterate Gifts)*                | `GET /v3/orders` (filter by `status`, `idempotencyKey`, `createdAt`) |
| Cancel order         | `POST /v2/gifts/{giftId}/cancel`                   | `POST /v3/orders/{orderId}/cancel`                                   |
| Validate address     | `POST /v2/orders/addresses/validate`               | `POST /v3/orders/addresses/validate`                                 |
| Autocomplete address | `GET /v2/orders/addresses/autocomplete`            | `GET /v3/orders/addresses/autocomplete`                              |

### Order field mapping

| V2 (on the Gift / Order)                      | V3 (on the Order)                                             | Notes                                                                                                                                                          |
| :-------------------------------------------- | :------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `orders[].id`                                 | `id`                                                          | Order is now top-level, not nested under a Gift.                                                                                                               |
| `orders[].status` (`active`, `cancelled`)     | `status` (`active`, `completed`, `cancelled`, `refunded`)     | V3 adds commercial lifecycle states.                                                                                                                           |
| -                                             | `fulfillmentStatus` (`unfulfilled`, `fulfilled`, `cancelled`) | New: physical status, separate from commercial `status`.                                                                                                       |
| `orderRecipient`                              | `recipient`                                                   | `{ firstName, lastName, email, phone }`.                                                                                                                       |
| -                                             | `shippingAddress`                                             | Address is now its own object.                                                                                                                                 |
| `orderedProducts[].selectedProduct.variantId` | `lineItems[].variantId`                                       |                                                                                                                                                                |
| `orderedProducts[].selectedProduct.title`     | `lineItems[].title`                                           |                                                                                                                                                                |
| `orderedProducts[].deliveryDetails`           | `fulfillments[]`                                              | Many-to-many between line items and shipments; carrier name is in `fulfillments[].trackingCompany`; tracking number/URL in `fulfillments[].trackingInfo`.      |
| `deliveryDetails.status`                      | `fulfillments[].status`                                       | Delivery statuses renamed and switched to snake\_case (e.g. `orderReceived` → `confirmed`, `inTransit` → `in_transit`, `outForDelivery` → `out_for_delivery`). |

<Tip>
  Order-level webhooks fire alongside gift-level webhooks for V3 orders, so you can track delivery state without polling. See [Webhook Event Types](/pages/webhook-event-types).
</Tip>

***

## Deep dive - Marketplace (Products, Variants & Collections)

V2 modeled the catalog as a single Product object with an embedded `variants` array. V3 splits **Products** (display-level) and **Variants** (orderable units) into separate resources with their own endpoints, and adds the `marketplace` vs `swag` catalog split.

### Listing products

**V2 - budget is required, `types` is an array, offset pagination:**

```bash theme={null}
curl --request GET \
  --url 'https://api.snappy.com/public-api/v2/products?minBudget=25&maxBudget=200&brandName=acme&limit=100&skip=0' \
  --header 'X-Api-Key: YOUR_API_KEY'
```

**V3 - JSON:API filters, cursor pagination:**

```bash theme={null}
curl --request GET \
  --url 'https://api.snappy.com/public-api/v3/products?filter[catalog]=marketplace&filter[price][gte]=25&filter[price][lte]=200&filter[brandName]=acme&include=brand,tags&sort=minPrice&page[size]=100' \
  --header 'X-Api-Key: YOUR_API_KEY'
```

### Getting variants

In V2, both `GET /v2/products/{id}` and `GET /v2/variants/{id}` returned the **full Product object with a `variants` array**. In V3 the entities are cleanly separated:

```bash theme={null}
# V2 - product carries its variants inline
GET /v2/products/prd_98765            # returns product + variants[]

# V3 - product and variants are separate resources
GET /v3/products/prd_98765            # product-level fields only
GET /v3/products/prd_98765/variants   # paginated list of variants
GET /v3/variants/var_55555            # a single variant on its own
```

### Key catalog differences

| V2                                   | V3                                                                    | What to change                                                                            |
| :----------------------------------- | :-------------------------------------------------------------------- | :---------------------------------------------------------------------------------------- |
| `minBudget` / `maxBudget` (required) | `filter[price][gte]` / `filter[price][lte]` (optional)                | Budget is no longer mandatory; use price range filters when needed.                       |
| `types: ["physical", ...]` (array)   | No direct equivalent                                                  | Product type filtering is no longer supported. Use `filter[catalog]` to scope by catalog. |
| Variants embedded in product         | `GET /v3/products/{id}/variants`                                      | Fetch variants from the dedicated endpoint.                                               |
| `expand[]`                           | `include=brand,tags`                                                  | Switch hydration syntax.                                                                  |
| `GET /v2/products/tags`              | `GET /v3/product-tags`                                                | Path renamed.                                                                             |
| IDs could change with availability   | **Static IDs**                                                        | Safe to cache/store product and variant IDs.                                              |
| No catalog split                     | `filter[catalog]=marketplace` \| `swag` \| `giftCards` \| `donations` | Required on list endpoints (defaults to `marketplace`).                                   |

<Tip>
  Maintaining a local mirror of the catalog? V3 adds an asynchronous, NDJSON-based **[Export API](/modules/api/v3/exports/overview)** - pair it with `stock-availability-updates` webhooks for incremental refresh instead of repeatedly paging the list endpoints.
</Tip>

***

## Everything else: endpoint mapping

The remaining domains keep **identical functionality** in V3 - the same operations and data, relocated to `/v3/...` and re-skinned in V3 conventions (`camelCase`, `filter[...]`, `page[...]`, `include`). Migrating these is the mechanical swap described in [What changes at a glance](#what-changes-at-a-glance): change the path, update pagination/filter params, and adjust field casing.

| Domain                   | V2                                             | V3                                      | Migration notes                                                                                                                                          |
| :----------------------- | :--------------------------------------------- | :-------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Campaigns**            | `/v2/campaigns`                                | `/v3/campaigns`                         | Same create/read/update/estimate operations. Swap `skip`/`limit` → `page[...]` and bespoke filters → `filter[...]`.                                      |
| **Gifts**                | `/v2/gifts`                                    | `/v3/gifts`                             | Same lifecycle (create, retrieve, update, expire). Recipient fields become `camelCase` (`firstName`/`lastName`).                                         |
| **Recipients**           | `/v2/recipients`                               | `/v3/recipients`                        | Same roster CRUD. JSON:API pagination and filtering.                                                                                                     |
| **Collections**          | `/v2/collections` (+ budgets, products, count) | `/v3/collections` (+ products)          | List and by-ID Collection resources; products within a Collection use cursor pagination and `products:read`. List/by-ID **require** `Snappy-Account-Id`. |
| **Accounts**             | `/v2/accounts`                                 | `/v3/accounts`                          | Same list/retrieve/create.                                                                                                                               |
| **Billing Methods**      | -                                              | `/v3/billing-methods`                   | New in V3: check remaining balance/expiration. Use before high-value orders.                                                                             |
| **Address validate**     | `POST /v2/orders/addresses/validate`           | `POST /v3/orders/addresses/validate`    | Identical behavior.                                                                                                                                      |
| **Address autocomplete** | `GET /v2/orders/addresses/autocomplete`        | `GET /v3/orders/addresses/autocomplete` | Identical behavior.                                                                                                                                      |

<Note>
  Scopes are unchanged across versions - e.g. `campaigns:create`, `recipients:read:masked`, `accounts:read` work for both `/v2` and `/v3`. You do **not** need to regenerate keys to call the V3 equivalents.
</Note>

***

## Error handling

The error object stays a **flat shape** in V3, with the same split between validation (`400`) and everything else:

```json theme={null}
// Validation error (400)
{ "path": "campaignId", "errorCode": "INVALID_REQUEST", "message": "The campaignId provided does not exist." }

// All other errors (401, 403, 404, 409, 422, 5xx)
{ "status": 404, "errorCode": "NOT_FOUND", "message": "The resource you requested could not be found." }
```

The main change is `errorCode`: V3 uses the **structured format** `{STATUS}_{DOMAIN}_{SEQUENCE}` (e.g. `404_PROD_001`, `403_PBLC_001`), while V2 uses symbolic codes (e.g. `NOT_FOUND`). **Switch on `errorCode`, never on `message`.** See [Request & Response Standards](/pages/request-response-standards#error-responses) and [Error Handling](/pages/error-handling).

***

## Migration checklist

<Steps>
  <Step title="Confirm your key reaches V3">
    Repeat one existing V2 read against the `/v3/...` path with the same `X-Api-Key`. If it returns data, you're ready - no new credentials needed.
  </Step>

  <Step title="Adopt V3 query conventions">
    Update your client's pagination (`page[...]`), filtering (`filter[...]`), sorting (`sort=`), and hydration (`include=`) helpers. Follow `links.next` verbatim.
  </Step>

  <Step title="Migrate catalog reads first">
    Move Products/Variants/Collections to V3 - lowest risk, biggest performance win. Split your product-and-variants reads into the separate V3 endpoints and start caching the now-static IDs.
  </Step>

  <Step title="Move order placement to V3 Orders">
    Replace the gift-create → claim flow with a single `POST /v3/orders`. Generate a stable `idempotencyKey` per order and split recipient vs `shippingAddress`. Read order state from `GET /v3/orders/{orderId}` instead of the parent Gift.
  </Step>

  <Step title="Update error handling">
    Switch your error branching to the structured `errorCode` format.
  </Step>

  <Step title="Migrate remaining domains">
    Apply the mechanical path/convention swap to Campaigns, Gifts, Recipients, Accounts, and address endpoints. Adopt Billing Methods to pre-check funds before high-value orders.
  </Step>

  <Step title="Run both versions in parallel, then cut over">
    Because the same key serves both, you can ship V3 per domain behind a flag, verify, and retire the V2 calls when you're confident.
  </Step>
</Steps>

<Tip>
  Need a hand planning your migration? [Reach out](https://www.snappy.com/book-meeting) to your Snappy contact.
</Tip>
