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

# Build a Rewards Experience

> A field guide to building a recognition or loyalty rewards storefront on Snappy - one that people actually love spending their points in.

At Snappy, we believe a reward should feel like a moment, not a transaction. We've watched millions of gifts get chosen, and the pattern is clear: the experiences people treasure are the ones that feel personal and tangible - a beautifully shot product they'd never have bought themselves, a "this is so me" recommendation, a weekend away to remember.

The best-in-class rewards programs we see share two ingredients: a rich mix of **tangible rewards and experiences** alongside the usual options, and **personalization** that makes each person feel seen. Get those two right and a rewards storefront stops feeling like a points-cashout screen and starts feeling like a treat.

This recipe walks you through building exactly that - end to end, with the Snappy API doing the heavy lifting. We'll cover fetching the catalog, personalizing a "For You" page, browsing and searching, product detail pages with variants, checkout with address validation, placing orders, and tracking fulfillment. Along the way we'll share the UX opinions we've formed from all those gift choices.

<Note>
  Snappy's catalog spans physical products, experiences, gift cards, and donations - the full range, so there's something for everyone. This guide leans into the tangible and experiential, because that's where the delight compounds. Think of it as where to point the spotlight, with everything else close at hand.
</Note>

## What you're building

A storefront with four surfaces, each backed by Snappy endpoints:

<CardGroup cols={2}>
  <Card title="For You" icon="sparkles">
    A personalized landing page of curated picks.
  </Card>

  <Card title="Catalog" icon="grid">
    The full browsable store - categories, search, filters, sort.
  </Card>

  <Card title="Product detail" icon="box">
    Rich pages with galleries and up to three levels of variants.
  </Card>

  <Card title="Checkout" icon="truck">
    Recipient details and a shipping address, validated as they type.
  </Card>
</CardGroup>

And underneath it all: **one call to place the order**, plus webhooks to track it home.

***

## Before you start

The only real setup task on your side is **billing**. Snappy fulfills real products to real doorsteps, so before you can place an order you need a Billing Method configured (see [Billing Methods](/billing-methods-overview)).

<Steps>
  <Step title="Configure a billing method">
    In your Snappy account, set up a **billing method** under the account you'll be ordering against. This is a one-time setup in the dashboard - no code required.
  </Step>

  <Step title="Grab your IDs">
    You'll need three identifiers as you build:

    * **`accountId`** - the account orders are billed to. At checkout you pass it as the `Snappy-Account-Id` header (not in the body).
    * **`billingMethodId`** - the billing method within that account. Discover available methods with `GET /v3/billing-methods`.
    * **`collectionId`** - the product collection you're merchandising from. Most partners get a collection scoped to their program; if you're not sure which one is yours, ask your Snappy contact.
  </Step>

  <Step title="Get your API key">
    All requests authenticate with an `X-Api-Key` header. Your key carries scopes - for this guide you'll want `products:read`, `collections:read`, `billingMethods:read`, and `orders:create`. See [Authentication & Security](/authentication-and-security).
  </Step>
</Steps>

<Warning>
  Treat your API key like a password. Keep it server-side, never ship it in your frontend bundle or mobile app, and rotate it if it's ever exposed. Every call in this guide should originate from your backend.
</Warning>

All requests share the same base URL and auth header:

<CodeGroup>
  ```javascript JavaScript theme={null}
    const SNAPPY = "https://api.snappy.com/public-api";
    const headers = {
      "X-Api-Key": process.env.SNAPPY_API_KEY,
      "Content-Type": "application/json",
      // Optional but appreciated - tells us how the call was made.
      "Request-Source": "api_native",
    };
  ```

  ```python Python theme={null}
    import os
    SNAPPY = "https://api.snappy.com/public-api"
    headers = {
        "X-Api-Key": os.environ["SNAPPY_API_KEY"],
        "Content-Type": "application/json",
        # Optional but appreciated - tells us how the call was made.
        "Request-Source": "api_native",
    }
  ```

  ```bash cURL theme={null}
    export SNAPPY="https://api.snappy.com/public-api"
    export SNAPPY_API_KEY="sk_live_..."
    # Pass on every request:
    #   -H "X-Api-Key: $SNAPPY_API_KEY"
    #   -H "Request-Source: api_native"
  ```
</CodeGroup>

***

## Step 1 - Fetch the catalog

Your storefront needs products. Snappy gives you a collection of them through the v3 products endpoint:

```text theme={null}
GET /v3/collections/{collectionId}/products
```

There are two ways to get products onto your pages, and the right one depends on your platform:

* **Fetch live** - call Snappy when you render a page (with a short cache). Simplest to build, and you never have to think about whether prices or availability are current - they always are, straight from the source.
* **Bulk import** - pull the whole catalog into your own database and serve from there. A natural fit if you already have catalog infrastructure (your own search index, merchandising tools, an existing product schema). The tradeoff: your copy can drift from ours, so you'll subscribe to **webhooks** to keep availability and pricing in sync (more on that below).

**For this guide we'll fetch live**, because it's the simplest path and it sidesteps the whole question of keeping prices and stock up to date. If a bulk import fits your platform better, skip to [Keeping a local catalog in sync](#keeping-a-local-catalog-in-sync) for the webhook side of the story - everything else in this guide applies either way.

Here's a paginated fetch of a collection. The endpoint uses **cursor pagination** - follow `links.next` until it's null.

<CodeGroup>
  ```javascript JavaScript theme={null}
    async function fetchCollection(collectionId, { location = "US" } = {}) {
      const products = [];
      let cursor = null;
      do {
        const url = new URL(`${SNAPPY}/v3/collections/${collectionId}/products`);
        url.searchParams.set("location", location);      // ISO 3166-1 alpha-2
        url.searchParams.set("include", "brand,tags");   // hydrate brand + tags
        url.searchParams.set("fields", "priceRange,variantsCount");
        url.searchParams.set("page[size]", "100");       // 1 to 300, default 100
        if (cursor) url.searchParams.set("page[cursor]", cursor);
        const res = await fetch(url, { headers });
        if (!res.ok) throw new Error(`Snappy ${res.status}`);
        const body = await res.json();
        products.push(...body.data);
        cursor = body.links?.next
          ? new URL(body.links.next).searchParams.get("page[cursor]")
          : null;
      } while (cursor);
      return products;
    }
  ```

  ```python Python theme={null}
    from urllib.parse import urlparse, parse_qs
    import requests
    def fetch_collection(collection_id, location="US"):
        products = []
        cursor = None
        while True:
            params = {
                "location": location,            # ISO 3166-1 alpha-2
                "include": "brand,tags",         # hydrate brand + tags
                "fields": "priceRange,variantsCount",
                "page[size]": 100,               # 1 to 300, default 100
            }
            if cursor:
                params["page[cursor]"] = cursor
            res = requests.get(
                f"{SNAPPY}/v3/collections/{collection_id}/products",
                headers=headers, params=params,
            )
            res.raise_for_status()
            body = res.json()
            products.extend(body["data"])
            next_link = (body.get("links") or {}).get("next")
            if not next_link:
                break
            cursor = parse_qs(urlparse(next_link).query)["page[cursor]"][0]
        return products
  ```

  ```bash cURL theme={null}
    curl -G "$SNAPPY/v3/collections/$COLLECTION_ID/products" \
      -H "X-Api-Key: $SNAPPY_API_KEY" \
      --data-urlencode "location=US" \
      --data-urlencode "include=brand,tags" \
      --data-urlencode "fields=priceRange,variantsCount" \
      --data-urlencode "page[size]=100"
    # Follow the `links.next` URL for the next page.
  ```
</CodeGroup>

Each product comes back shaped like this:

```json theme={null}
{
  "id": "prod_8fawE03MlR",
  "title": "Ember Travel Mug²",
  "createdAt": "2026-04-14T19:12:33Z",
  "catalog": "marketplace",
  "type": "physical",
  "category": { "fullName": "Home & Kitchen / Drinkware" },
  "media": [{ "type": "image", "src": "https://cdn.snappy.com/..." }],
  "brand": { "id": "brnd_01", "name": "Ember" },
  "tags": [{ "id": "tag_travel", "name": "Travel" }],
  "priceRange": { "min": { "amount": 39.95, "currency": "USD" },
                  "max": { "amount": 39.95, "currency": "USD" } },
  "variantsCount": 3
}
```

A few fields earn their keep right away:

* **`media`** drives your product imagery - use the first image as the card thumbnail.
* **`category.fullName`** is a breadcrumb you can split for category filters.
* **`tags`** are your personalization fuel (next step).
* **`type`** tells you whether this is `physical`, `digital`, `giftCard`, or `donation` (a single value, not an array) - useful for steering merchandising.
* **`priceRange`** is what you'll map into points.

### Keeping a local catalog in sync

If you've chosen the bulk-import route - pulling the catalog into your own database with the Export endpoints rather than fetching live - there's one job you take on in return: keeping your copy fresh. A product that's out of stock or repriced on our side should reflect that on yours, ideally within minutes.

Snappy handles this with **webhooks**. Subscribe to the stock availability event and update your local records as changes roll in, instead of re-importing the whole catalog on a timer.

```text theme={null}
stock-availability-updates
```

See [Webhook Event Types](/webhook-event-types#catalog--stock-events) for the full payload shape and the current handling rules.

<Tip>
  Fetching live (the path this guide follows) skips all of this - there's nothing to keep in sync because every render reads the source of truth. Reach for bulk import + webhooks when you have real reasons to own a local copy, not by default.
</Tip>

***

## Step 2 - Model your points economy

Snappy's catalog is priced in real currency. Your users think in points - recognition points, loyalty tier points, anniversary credits, whatever your program calls them. The bridge between the two is a ratio you decide and own.

Pick a ratio that feels generous and legible. A round number is your friend:

<CodeGroup>
  ```javascript JavaScript theme={null}
    // You own this number. 100 points = $1 is easy to reason about.
    const POINTS_PER_DOLLAR = 100;
    const toPoints = (usd) => Math.round(usd * POINTS_PER_DOLLAR);
    const toDollars = (points) => points / POINTS_PER_DOLLAR;
    // Show every product's cost in points:
    const pointsCost = toPoints(product.priceRange.min.amount);
  ```

  ```python Python theme={null}
    # You own this number. 100 points = $1 is easy to reason about.
    POINTS_PER_DOLLAR = 100
    def to_points(usd):   return round(usd * POINTS_PER_DOLLAR)
    def to_dollars(pts):  return pts / POINTS_PER_DOLLAR
    # Show every product's cost in points:
    points_cost = to_points(product["priceRange"]["min"]["amount"])
  ```
</CodeGroup>

The real magic is filtering the catalog by **what a person can actually afford**. Translate their points balance into a max budget and pass it straight to the API, so you can recommend products that they can redeem instantly:

<CodeGroup>
  ```javascript JavaScript theme={null}
    const balancePoints = user.pointsBalance;          // e.g. 12,450
    const maxBudgetUsd = toDollars(balancePoints);     // 124.50
    const url = new URL(`${SNAPPY}/v3/collections/${collectionId}/products`);
    url.searchParams.set("filter[price][lte]", maxBudgetUsd.toFixed(2));
    // Optionally hide the truly tiny stuff so the page feels aspirational:
    url.searchParams.set("filter[price][gte]", "15");
  ```

  ```python Python theme={null}
    balance_points = user["points_balance"]            # e.g. 12,450
    max_budget_usd = to_dollars(balance_points)        # 124.50
    params = {
        "filter[price][lte]": f"{max_budget_usd:.2f}",
        # Optionally hide the truly tiny stuff so the page feels aspirational:
        "filter[price][gte]": "15",
    }
  ```
</CodeGroup>

<Tip>
  Showing an "almost there" rail of items slightly above someone's balance is a lovely nudge - it gives points a sense of momentum.
</Tip>

***

## Step 3 - A personalized "For You" page

The "For You" page is where you earn the user's attention. Instead of dropping them into a 2,000-item grid, you greet them with a handful of curated rails that feel hand-picked.

The raw material is **tags**. Snappy tags span categories ("Home & Kitchen"), occasions ("Birthday"), and values ("Sustainable", "Women-Owned"). Pull the list:

```text theme={null}
GET /v3/product-tags
```

<CodeGroup>
  ```javascript JavaScript theme={null}
    async function fetchTags(search) {
      const url = new URL(`${SNAPPY}/v3/product-tags`);
      if (search) url.searchParams.set("title", search); // min 3 chars
      url.searchParams.set("page[size]", "100");         // 1 to 100, default 100
      const res = await fetch(url, { headers });
      const { data } = await res.json();
      return data; // [{ id, name }, ...]
    }
  ```

  ```python Python theme={null}
    def fetch_tags(search=None):
        params = {"page[size]": 100}        # 1 to 100, default 100
        if search:
            params["title"] = search        # min 3 chars
        res = requests.get(f"{SNAPPY}/v3/product-tags", headers=headers, params=params)
        return res.json()["data"]           # [{ id, name }, ...]
  ```

  ```bash cURL theme={null}
    curl -G "$SNAPPY/v3/product-tags" \
      -H "X-Api-Key: $SNAPPY_API_KEY" \
      --data-urlencode "page[size]=100"
  ```
</CodeGroup>

<Note>
  `GET /v3/product-tags` returns a paginated `{ data, links }` envelope (page-number pagination, `page[size]` max 100). Follow `links.next` if you need more than one page.
</Note>

Now group a few meaningful tags into **interests** - human-friendly buckets like "Wellness & Self-Care," "Travel & Adventure," "Food & Dining," "Home & Living." Each interest maps to one or more tags, and each becomes a rail. Build a rail by fetching the collection with `include=tags` and matching on tag names client-side (the standalone `GET /v3/products` endpoint also supports server-side `filter[tagId]` / `filter[brandId]` if you'd rather filter the whole catalog by ID):

<CodeGroup>
  ```javascript JavaScript theme={null}
    // Your own mapping from a friendly interest to Snappy tag names.
    const INTERESTS = {
      "Wellness & Self-Care": ["Wellness", "Beauty", "Fitness"],
      "Travel & Adventure":   ["Travel", "Outdoors"],
      "Food & Dining":        ["Food", "Drinkware", "Kitchen"],
      "Home & Living":        ["Home & Kitchen", "Decor"],
    };
    async function buildRail(collectionId, interest, maxBudgetUsd) {
      const url = new URL(`${SNAPPY}/v3/collections/${collectionId}/products`);
      url.searchParams.set("include", "brand,tags");
      url.searchParams.set("filter[price][lte]", maxBudgetUsd.toFixed(2));
      url.searchParams.set("page[size]", "12");
      const res = await fetch(url, { headers });
      const { data } = await res.json();
      // Keep products whose tags intersect the interest's tag names.
      const wanted = new Set(INTERESTS[interest]);
      return data.filter((p) => (p.tags ?? []).some((t) => wanted.has(t.name)));
    }
  ```

  ```python Python theme={null}
    INTERESTS = {
        "Wellness & Self-Care": {"Wellness", "Beauty", "Fitness"},
        "Travel & Adventure":   {"Travel", "Outdoors"},
        "Food & Dining":        {"Food", "Drinkware", "Kitchen"},
        "Home & Living":        {"Home & Kitchen", "Decor"},
    }
    def build_rail(collection_id, interest, max_budget_usd):
        params = {
            "include": "brand,tags",
            "filter[price][lte]": f"{max_budget_usd:.2f}",
            "page[size]": 12,
        }
        res = requests.get(
            f"{SNAPPY}/v3/collections/{collection_id}/products",
            headers=headers, params=params,
        )
        data = res.json()["data"]
        wanted = INTERESTS[interest]
        # Keep products whose tags intersect the interest's tag names.
        return [p for p in data if any(t["name"] in wanted for t in p.get("tags", []))]
  ```
</CodeGroup>

For the **featured rail** at the very top, this is your moment to set the tone: lead with a beautiful tangible product or a standout experience. You can highlight key products directly by fetching them by ID, or do a broad fetch from Snappy. Our products are sorted by relevance by default, meaning that the most trending products will be fetched first.

***

## Step 4 - The personalization flow

A "For You" page is only as good as what it knows. The fastest way to learn is to ask - once, gently, up front.

Show a short interest picker the first time someone visits: a grid of friendly, image-led cards ("Wellness," "Travel," "Food & Dining," ...). Ask them to pick **at least two**, then assemble their rails from those choices.

<Steps>
  <Step title="Present the interests">
    Render your interest buckets as tappable cards. Lead with imagery - people pick with their eyes. Require a minimum of two so you have enough signal to personalize, but keep it optional to *finish*: never trap someone behind this screen.
  </Step>

  <Step title="Store the selection">
    Save the chosen interest labels against the user (your database, or a cookie for a logged-out demo). That's all the state you need.
  </Step>

  <Step title="Assemble their page">
    On the next render, build one rail per selected interest using `buildRail` from Step 3, plus a featured hero and a couple of evergreen rails ("Popular this month," "Worth saving for").
  </Step>
</Steps>

<Tip>
  **Personalization UX rules we live by:**

  * **Default smartly.** Even before someone picks anything, show a strong generic page - never a blank one.
  * **Always let them change it.** Put an "Update picks" affordance somewhere visible. Tastes change; so should the page.
  * **Never gate the catalog.** Personalization is a shortcut, not a tollbooth. The full store is always one tap away.
</Tip>

<Note>
  Keep this flow simple and structured - a fixed set of interests, not an open-ended "tell us in your own words" text box. Structured input is faster for users, easier to map to tags, and produces more predictable rails.
</Note>

***

## Step 5 - The catalog page

Some people know exactly what they want. The catalog page is for them: the full store, with the controls to slice it down fast.

Four controls cover the vast majority of needs, and the v3 endpoint backs all of them:

| Control      | How                                                                                            |
| :----------- | :--------------------------------------------------------------------------------------------- |
| **Category** | Group by `category.fullName` client-side, or narrow with free-text `filter[search]`.           |
| **Search**   | `filter[search]` - free-text across product title, category, and brand.                        |
| **Sort**     | `sort=minPrice` (ascending) or `sort=-minPrice` (descending); also `createdAt` / `-createdAt`. |

<CodeGroup>
  ```javascript JavaScript theme={null}
    async function searchCatalog(collectionId, { query, sort, cursor } = {}) {
      const url = new URL(`${SNAPPY}/v3/collections/${collectionId}/products`);
      url.searchParams.set("include", "brand,tags");
      url.searchParams.set("page[size]", "48");
      if (query)  url.searchParams.set("filter[search]", query);
      if (sort)   url.searchParams.set("sort", sort);           // e.g. "minPrice"
      if (cursor) url.searchParams.set("page[cursor]", cursor);
      const res = await fetch(url, { headers });
      const body = await res.json();
      return {
        items: body.data,
        nextCursor: body.links?.next
          ? new URL(body.links.next).searchParams.get("page[cursor]")
          : null,
      };
    }
  ```

  ```python Python theme={null}
    from urllib.parse import urlparse, parse_qs
    def search_catalog(collection_id, query=None, sort=None, cursor=None):
        params = {"include": "brand,tags", "page[size]": 48}
        if query:  params["filter[search]"] = query
        if sort:   params["sort"] = sort              # e.g. "minPrice"
        if cursor: params["page[cursor]"] = cursor
        res = requests.get(
            f"{SNAPPY}/v3/collections/{collection_id}/products",
            headers=headers, params=params,
        )
        body = res.json()
        next_link = (body.get("links") or {}).get("next")
        next_cursor = parse_qs(urlparse(next_link).query)["page[cursor]"][0] if next_link else None
        return {"items": body["data"], "next_cursor": next_cursor}
  ```
</CodeGroup>

<Tip>
  Your **default sort and category order** are a merchandising choice, not just a technical one. Leading with physical products and experiences sets the tone and surfaces the rewards people remember most. The full range stays one filter away, so anyone with something specific in mind can get there in a tap - you're simply putting the most delightful stuff front and center.
</Tip>

***

## Step 6 - Product detail pages

When someone taps a product, give them a page worth the tap: a real gallery, a clear description, and confident variant selection.

Pull the full detail for a single product. The list response you've been using is a lightweight summary; for the detail page, request the product directly and ask for its aggregated **options** matrix.

```text theme={null}
GET /v3/products/{productId}
```

This endpoint returns **product-level data only** - `media` is always present, and `fields=options` adds the aggregated option matrix. The full list of orderable variants comes from a separate endpoint (below).

<CodeGroup>
  ```javascript JavaScript theme={null}
    async function fetchProduct(productId) {
      const url = new URL(`${SNAPPY}/v3/products/${productId}`);
      url.searchParams.set("include", "brand,tags");
      url.searchParams.set("fields", "options,priceRange,variantsCount");
      const res = await fetch(url, { headers });
      return res.json(); // the product object is returned directly (no `data` wrapper)
    }
  ```

  ```python Python theme={null}
    def fetch_product(product_id):
        params = {"include": "brand,tags", "fields": "options,priceRange,variantsCount"}
        res = requests.get(f"{SNAPPY}/v3/products/{product_id}", headers=headers, params=params)
        return res.json()  # the product object is returned directly (no `data` wrapper)
  ```
</CodeGroup>

### Showing variants (up to three levels)

Many products come in variations - a hoodie in three colors and five sizes, a laptop in two configurations etc. Snappy models this across **up to three option levels** (for example: Color, Size, and a third axis like Sports Team or Material). Rendering it cleanly takes two pieces:

1. The product's aggregated **`options`** (from `GET /v3/products/{productId}` with `fields=options`) - what you render pickers from. Each option value carries a `firstSelectableVariant` you can default to.
2. The product's **variants** (a separate, paginated endpoint) - each concrete, orderable variant with its `selectedOptions`, price, and media. **The order is placed against a variant, not a product.**

The aggregated `options` come back on the product like this:

```json theme={null}
{
  "id": "655277e68e0719000d6c3fd5",
  "title": "Cloud Fleece Hoodie",
  "type": "physical",
  "media": [{ "type": "image", "src": "https://media.snappy.com/..." }],
  "options": [
    {
      "name": "color",
      "displayName": "Color",
      "displayType": "swatch",
      "values": [
        { "displayName": "Heather Grey", "value": "heather_grey",
          "firstSelectableVariant": { "id": "FB6bgFV4lf", "title": "Cloud Fleece Hoodie",
            "selectedOptions": { "color": "heather_grey", "size": "m" } } }
      ]
    },
    {
      "name": "size",
      "displayName": "Size",
      "displayType": "button",
      "values": [
        { "displayName": "Medium", "value": "m",
          "firstSelectableVariant": { "id": "FB6bgFV4lf", "title": "Cloud Fleece Hoodie",
            "selectedOptions": { "color": "heather_grey", "size": "m" } } }
      ]
    }
  ]
}
```

Fetch the orderable variants from their own endpoint (page-number pagination). Pass `fields=price` (or `price,priceBreakdown`) for pricing and `include=brand` for the variant brand:

```text theme={null}
GET /v3/products/{productId}/variants
```

Each variant looks like this:

```json theme={null}
{
  "data": [
    {
      "id": "FB6bgFV4lf",
      "productId": "655277e68e0719000d6c3fd5",
      "title": "Cloud Fleece Hoodie",
      "selectedOptions": { "color": "heather_grey", "size": "m" },
      "taxable": true,
      "media": [{ "type": "image", "src": "https://media.snappy.com/..." }],
      "personalization": { "isPersonalized": false, "personalizationTemplateFields": [] },
      "price": { "amount": 58.0, "currency": "USD" }
    }
  ],
  "links": { "first": "/v3/products/655277e68e0719000d6c3fd5/variants?page[size]=100&page[number]=1",
             "next": null, "prev": null }
}
```

<CodeGroup>
  ```javascript JavaScript theme={null}
    // `variants` is the `data` array from GET /v3/products/{productId}/variants.
    // `selection` maps option name -> chosen value, e.g. { color: "black", size: "l" }.
    function resolveVariant(variants, selection) {
      return variants.find((v) =>
        Object.entries(selection).every(([level, value]) => v.selectedOptions[level] === value)
      );
    }
    const variant = resolveVariant(variants, { color: "black", size: "l" });
    // variant.id is what you'll send to checkout.
    // Default a picker to an option value's `firstSelectableVariant` to avoid dead-end combinations.
  ```

  ```python Python theme={null}
    def resolve_variant(variants, selection):
        for v in variants:
            if all(v["selectedOptions"].get(level) == value for level, value in selection.items()):
                return v
        return None
    variant = resolve_variant(variants, {"color": "black", "size": "l"})
    # variant["id"] is what you'll send to checkout.
    # Default a picker to an option value's `firstSelectableVariant` to avoid dead-end combinations.
  ```
</CodeGroup>

<Note>
  The variant object carries no inline stock flag. To confirm a specific variant can ship to a country, call `GET /v3/variants/{variantId}/availability`.
</Note>

<Tip>
  **Variant UX that respects people:**

  * Pre-select each option's `firstSelectableVariant` so a single-variant product needs zero taps and pickers never start on a dead-end combination.
  * Reflect the selected variant's image and price immediately. Surprise at checkout erodes trust.
  * Confirm shippability for the recipient's country with the availability endpoint, and show the state plainly - a graceful "Not available in your region" beats a failed checkout.
</Tip>

***

## Step 7 - Search and related products

Two features make a store feel alive: a search box that finds things, and "you might also like" rows that keep people exploring. Both are first-class in v3.

**Product search** - there's no separate search endpoint. Use **`filter[search]`** on the catalog endpoint (Step 5) - free-text ranked across product title, category, and brand:

```text theme={null}
GET /v3/collections/{collectionId}/products?filter[search]=cold+brew&location=US
```

**Related products** - for "Complete the set" or "More like this" rows on a product page, use the recommendations endpoint keyed off a product ID. It returns products ordered by relevance:

```text theme={null}
GET /v3/products/{productId}/recommendations?location=US&page[limit]=10
```

<CodeGroup>
  ```javascript JavaScript theme={null}
    async function fetchRecommendations(productId, { location = "US", limit = 10, collectionId } = {}) {
      const url = new URL(`${SNAPPY}/v3/products/${productId}/recommendations`);
      url.searchParams.set("location", location);          // required
      url.searchParams.set("page[limit]", String(limit));  // 0 to 20, default 10
      url.searchParams.set("include", "brand,tags");
      if (collectionId) url.searchParams.set("collectionId", collectionId); // scope to your collection
      const res = await fetch(url, { headers });
      const { data } = await res.json();
      return data; // [Product, ...] - empty when none qualify
    }
  ```

  ```python Python theme={null}
    def fetch_recommendations(product_id, location="US", limit=10, collection_id=None):
        params = {"location": location, "page[limit]": limit, "include": "brand,tags"}
        if collection_id:
            params["collectionId"] = collection_id
        res = requests.get(
            f"{SNAPPY}/v3/products/{product_id}/recommendations",
            headers=headers, params=params,
        )
        return res.json()["data"]  # [Product, ...] - empty when none qualify
  ```
</CodeGroup>

<Tip>
  Pass your `collectionId` to keep recommendations inside your merchandised catalog. If you filter by price, send **both** `filter[price][gte]` and `filter[price][lte]` together - the endpoint requires them as a pair.
</Tip>

***

## Step 8 - Checkout

Checkout is where good intentions meet reality: you need a real recipient and a real address. The kindest thing you can do here is make the address easy to get right.

### Validate the address as they type

Snappy's autocomplete endpoint turns a half-typed string into clean, structured, shippable addresses - fewer failed deliveries, fewer support tickets.

```text theme={null}
GET /v3/orders/addresses/autocomplete
```

<CodeGroup>
  ```javascript JavaScript theme={null}
    // Debounce on the client; call this from your backend.
    async function autocompleteAddress(partial, country = "US") {
      const url = new URL(`${SNAPPY}/v3/orders/addresses/autocomplete`);
      url.searchParams.set("filter[address]", partial);  // 4 to 128 chars, required
      url.searchParams.set("filter[country]", country);  // 2 to 3 chars, required
      const res = await fetch(url, { headers });
      const { data } = await res.json();
      return data; // [{ addressLine1, addressLine2?, city, state, zipcode }, ...]
    }
  ```

  ```python Python theme={null}
    def autocomplete_address(partial, country="US"):
        params = {"filter[address]": partial, "filter[country]": country}  # both required
        res = requests.get(
            f"{SNAPPY}/v3/orders/addresses/autocomplete",
            headers=headers, params=params,
        )
        return res.json()["data"]
  ```

  ```bash cURL theme={null}
    curl -G "$SNAPPY/v3/orders/addresses/autocomplete" \
      -H "X-Api-Key: $SNAPPY_API_KEY" \
      --data-urlencode "filter[address]=123 Main" \
      --data-urlencode "filter[country]=US"
  ```
</CodeGroup>

A response looks like:

```json theme={null}
{
  "data": [
    { "addressLine1": "123 Main St", "city": "San Francisco",
      "state": "CA", "zipcode": "94105" }
  ]
}
```

<Tip>
  Debounce the input (\~300ms) and only call once the user has typed a few characters. When they pick a suggestion, snap the structured fields into your form - then let them eyeball it. Autocomplete plus a quick human glance is the sweet spot for delivery accuracy.
</Tip>

The fields you ultimately need for an order: recipient **first name, last name, email, and phone** (E.164 format - required by v3 fulfillment), and a **shipping address**. Note the autocomplete/validate address shape (`addressLine1`, `addressLine2`, `city`, `state`, `zipcode`) maps onto the place-order shape (`address1`, `address2`, `city`, `provinceCode`, `postalCode`, plus `countryCode`). For verified-deliverable addresses, run a pre-flight `POST /v3/orders/addresses/validate` before placing the order.

***

## Step 9 - Place the order

This is the payoff, and it's refreshingly simple. **One call** creates the order:

```text theme={null}
POST /v3/orders
```

<Note>
  If you've integrated with Snappy before, you may remember a two-step dance: create a gift, then claim it. This endpoint collapses that into a single atomic call. You hand over the variant and the recipient; you get back an order. Less code, fewer round-trips, no half-finished states.
</Note>

<CodeGroup>
  ```javascript JavaScript theme={null}
    async function placeOrder({ variantId, recipient, idempotencyKey }) {
      const res = await fetch(`${SNAPPY}/v3/orders`, {
        method: "POST",
        headers: {
          ...headers,
          "Snappy-Account-Id": process.env.SNAPPY_ACCOUNT_ID, // account is scoped via header
        },
        body: JSON.stringify({
          billingMethodId: process.env.SNAPPY_BILLING_METHOD_ID,
          variantId,
          recipient: {
            firstName: recipient.firstName,
            lastName: recipient.lastName,
            email: recipient.email,
            phone: recipient.phone,             // E.164, required
          },
          shippingAddress: {
            address1: recipient.address1,
            address2: recipient.address2,       // optional
            city: recipient.city,
            provinceCode: recipient.provinceCode, // e.g. "CA"
            postalCode: recipient.postalCode,
            countryCode: recipient.countryCode,   // ISO 3166-1 alpha-2
          },
          idempotencyKey,                        // top-level, stable per redemption - see below
          tags: ["rewards-store"],               // optional: for your reporting
        }),
      });
      if (!res.ok) return handleOrderError(res);   // see error handling below
      const { data } = await res.json();
      return data; // { id, status: "active", trackingLink }
    }
  ```

  ```python Python theme={null}
    import os, requests
    def place_order(variant_id, recipient, idempotency_key):
        payload = {
            "billingMethodId": os.environ["SNAPPY_BILLING_METHOD_ID"],
            "variantId": variant_id,
            "recipient": {
                "firstName": recipient["first_name"],
                "lastName": recipient["last_name"],
                "email": recipient["email"],
                "phone": recipient["phone"],        # E.164, required
            },
            "shippingAddress": {
                "address1": recipient["address1"],
                "address2": recipient.get("address2"),   # optional
                "city": recipient["city"],
                "provinceCode": recipient["province_code"],  # e.g. "CA"
                "postalCode": recipient["postal_code"],
                "countryCode": recipient["country_code"],    # ISO 3166-1 alpha-2
            },
            "idempotencyKey": idempotency_key,      # top-level, stable per redemption - see below
            "tags": ["rewards-store"],              # optional: for your reporting
        }
        order_headers = {**headers, "Snappy-Account-Id": os.environ["SNAPPY_ACCOUNT_ID"]}
        res = requests.post(f"{SNAPPY}/v3/orders", headers=order_headers, json=payload)
        if not res.ok:
            return handle_order_error(res)       # see error handling below
        return res.json()["data"]                # { id, status, trackingLink }
  ```

  ```bash cURL theme={null}
    curl -X POST "$SNAPPY/v3/orders" \
      -H "X-Api-Key: $SNAPPY_API_KEY" \
      -H "Snappy-Account-Id: acct_123" \
      -H "Content-Type: application/json" \
      -d '{
        "billingMethodId": "87654321",
        "variantId": "FB6bgFV4lf",
        "recipient": {
          "firstName": "Jane",
          "lastName": "Doe",
          "email": "jane@example.com",
          "phone": "+12133734253"
        },
        "shippingAddress": {
          "address1": "123 Main St", "city": "San Francisco",
          "provinceCode": "CA", "postalCode": "94105", "countryCode": "US"
        },
        "idempotencyKey": "user-7251-order-2026-06",
        "tags": ["rewards-store"]
      }'
  ```
</CodeGroup>

A successful response:

```json theme={null}
{
  "data": {
    "id": "G7nR4bD9mK",
    "status": "active",
    "trackingLink": "https://gift.snappy.com/choose/G7nR4bD9mK?utm_source=l&utm_medium=i&utm_campaign=l2fX39ZvaM"
  }
}
```

The response is intentionally minimal. To retrieve the full order later - line items, fulfillments, and tracking detail - call `GET /v3/orders/{orderId}`.

### The idempotency key matters

The top-level **`idempotencyKey`** (1 to 120 characters) makes the call safe to retry. Send the same key twice and you get the *same order* back - no duplicate, no double-charge. This is your safety net against the classic failure modes: a flaky network, a double-clicked "Redeem" button, a retried request.

<Warning>
  Generate one stable key per redemption and reuse it on retries - something like `user-{userId}-order-{cartId}`. Don't generate a fresh random key on each attempt, or you'll defeat the very protection it provides.
</Warning>

### Handle the errors people will actually hit

A few failures are worth handling gracefully, because they map to real human moments:

| HTTP  | Meaning                                                   | What to show the user                                                                              |
| :---- | :-------------------------------------------------------- | :------------------------------------------------------------------------------------------------- |
| `422` | Insufficient funds in the billing method                  | A calm "Something went wrong on our end" - this is your billing, not their fault. Alert your team. |
| `422` | Variant not available in the recipient's country          | "Not available in your region - here's something similar." Offer the recommendations rail.         |
| `404` | Variant, billing method, or account not found             | A config/data bug on your side - log it and fail safe; never surface raw errors.                   |
| `400` | Invalid request body (bad phone, malformed address, etc.) | Catch at the form step with inline field validation, before they're emotionally committed.         |

Every failure uses the standard v3 error envelope - `{ message, errorCode, errors[] }`. Branch on `errorCode`, and surface `message` only when it's safe to show an end user.

<Tip>
  The best error is the one nobody sees. Validate the address (Step 8) and re-confirm availability and affordability *before* the order call, so checkout itself almost always succeeds. Reserve the error UI for genuine surprises.
</Tip>

***

## Step 10 - Track fulfillment with webhooks

The order is placed - now keep the recipient (and your support team) in the loop without polling. Subscribe to Snappy's order webhooks and let updates come to you.

<Warning>
  **Preview - confirm against the final spec.** The `order-*` webhook event names below are the pattern we expect V3 to ship with; confirm exact event names against the current [Webhook Event Types](/webhook-event-types) reference before you build against them.
</Warning>

Orders move through a predictable fulfillment lifecycle, and each transition fires an event. The fulfillment status values on V3 Orders are:

| Status           | Meaning                                    |
| :--------------- | :----------------------------------------- |
| `confirmed`      | Order received by the vendor.              |
| `processing`     | Being prepared for shipment.               |
| `inTransit`      | Shipped and in transit - tracking is live. |
| `outForDelivery` | Out for delivery today.                    |
| `delivered`      | Arrived at the recipient's address.        |

Every V3 webhook arrives in the same envelope - a `webhookData` block describing the event and an `eventData` block with the payload. A delivery-status webhook looks roughly like:

```json theme={null}
{
  "webhookData": {
    "id": "wh_9KdP2mLx",
    "eventType": "order-delivery-status-changed",
    "target": "https://your-domain.com/webhooks",
    "triggeredAt": "2026-06-02T15:04:00Z"
  },
  "eventData": {
    "orderId": "G7nR4bD9mK",
    "status": "inTransit",
    "trackingLink": "https://gift.snappy.com/choose/G7nR4bD9mK?utm_source=l&utm_medium=i&utm_campaign=l2fX39ZvaM"
  }
}
```

Handle it on your backend:

<CodeGroup>
  ```javascript JavaScript theme={null}
    // Express handler for Snappy order webhooks.
    app.post("/webhooks/snappy", (req, res) => {
      const { webhookData, eventData } = req.body;
      if (webhookData.eventType.startsWith("order-")) {
        updateOrderStatus(eventData.orderId, eventData.status);   // your DB
        if (eventData.status === "inTransit") {
          notifyUser(eventData.orderId, eventData.trackingLink);  // your email/push
        }
      }
      res.sendStatus(200); // acknowledge fast
    });
  ```

  ```python Python theme={null}
    # Flask handler for Snappy order webhooks.
    @app.post("/webhooks/snappy")
    def snappy_webhook():
        body = request.get_json()
        webhook_data, event_data = body["webhookData"], body["eventData"]
        if webhook_data["eventType"].startswith("order-"):
            update_order_status(event_data["orderId"], event_data["status"])       # your DB
            if event_data["status"] == "inTransit":
                notify_user(event_data["orderId"], event_data["trackingLink"])     # your email/push
        return "", 200  # acknowledge fast
  ```
</CodeGroup>

<Tip>
  Surface the `trackingLink` to your recipient as soon as the order ships - a "Track your reward" button closes the loop and turns a transaction into an experience. Anticipation is part of the gift.
</Tip>

***

## Putting it together

We hope this guide has been a useful introduction to how to build a rewards experience on Snappy. As always, our team is available to help you craft the best experience for your customers. Reach out to your Snappy representative for any questions, needs, or feedback.

<Card title="API Reference" icon="book" href="/products-v3-overview">
  Full endpoint specs for everything used in this guide.
</Card>
