Skip to main content
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.
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.

What you’re building

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

For You

A personalized landing page of curated picks.

Catalog

The full browsable store — categories, search, filters, sort.

Product detail

Rich pages with galleries and up to three levels of variants.

Checkout

Recipient details and a shipping address, validated as they type.
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 funding source configured.
1

Configure a funding method

In your Snappy account, set up a funding source (your billing method) under the account you’ll be ordering against. This is a one-time setup in the dashboard — no code required.
2

Grab your IDs

You’ll need three identifiers as you build:
  • accountId — the account orders are billed to.
  • fundingSourceId — the funding method within that account.
  • 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.
3

Get your API key

All requests authenticate with an X-Api-Key header. Your key carries scopes — for this guide you’ll want product read access plus gifts:create and orders:create for checkout.
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.
All requests share the same base URL and auth header:
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",
};

Step 1 — Fetch the catalog

Your storefront needs products. Snappy gives you a collection of them through the v3 products endpoint:
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 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.
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–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;
}
Each product comes back shaped like this:
{
  "id": "prod_8fawE03MlR",
  "title": "Ember Travel Mug²",
  "createdAt": "2026-04-14T19:12:33Z",
  "catalog": "marketplace",
  "types": ["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).
  • types tells you whether this is physical, digital, giftCards, or donations — 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.
stock-availability-updates
Every webhook arrives in the same envelope — a webhookData block describing the event, and an eventData block with the payload:
{
  "webhookData": {
    "id": "evt_9KdP2mLx",
    "eventType": "stock-availability-updates",
    "triggeredAt": "2026-06-02T15:04:00Z"
  },
  "eventData": {
    "id": "prod_8fawE03MlR",
    "title": "Ember Travel Mug²",
    "category": "Home & Kitchen / Drinkware",
    "brand": { "id": "brnd_01", "name": "Ember" },
    "types": ["physicalGift"],
    "status": "out_of_stock"
  }
}
The status field tells you exactly how to treat the product locally:
statusMeaningWhat to do
in_stockAvailable to orderShow it, make it redeemable.
stocked_on_demandAvailable, may need extra lead timeShow it; consider a “ships in X days” note.
out_of_stockTemporarily unavailableKeep the page, disable redemption gracefully.
discontinuedGone for good, won’t restockRemove it from your catalog.
Pricing is coming to this event. We’re adding price to the stock availability payload, so the same webhook that keeps your stock fresh will keep your pricing fresh too — no separate sync to maintain. Confirm the exact field shape against the webhook reference before you build against it.
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.

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:
// 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);
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:
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");
Showing an “almost there” rail of items slightly above someone’s balance is a lovely nudge — it gives points a sense of momentum.

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:
GET /v2/products/tags
async function fetchTags(search) {
  const url = new URL(`${SNAPPY}/v2/products/tags`);
  if (search) url.searchParams.set("title", search); // min 3 chars
  url.searchParams.set("limit", "100");
  const res = await fetch(url, { headers });
  const { results } = await res.json();
  return results; // [{ id, name }, ...]
}
Tags live in the v2 API (/v2/products/tags) — that’s expected. As you build, you’ll mix v3 product calls with a few v2 endpoints like this one. We’ll flag the version on each call.
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 filtering the collection (here we use a title/category filter; once tag-filtering is available on v3 you’ll pass tag IDs directly):
// 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)));
}
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.
1

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

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

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”).
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.
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.

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:
ControlHow
CategoryGroup by category.fullName, or filter by tag.
Searchfilter[title] — case-insensitive substring match.
Typefilter[types]physical, digital, giftCards, donations.
Sortsort=minPrice (ascending) or sort=-minPrice (descending), also createdAt for newest.
async function searchCatalog(collectionId, { query, type, 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[title]", query);
  if (type)   url.searchParams.set("filter[types]", type);   // e.g. "physical"
  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,
  };
}
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.

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 summary; for the complete picture — every image and the full variant matrix — request the product directly.
Preview — confirm against the final spec. The single-product and full-variant shapes below reflect the v3 model as we expect it to ship, but these specific endpoints aren’t finalized in the public reference yet. Treat the field names as a strong draft and confirm before you build against them.
GET /v3/products/{productId}
Request the expanded fields to get media and variants inline:
async function fetchProduct(productId) {
  const url = new URL(`${SNAPPY}/v3/products/${productId}`);
  url.searchParams.set("include", "brand,tags");
  url.searchParams.set("fields", "full"); // expand media + variants
  const res = await fetch(url, { headers });
  return res.json();
}

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 as a list of variants, each carrying the option values that define it across up to three option levels (for example: Color, Size, and a third axis like Sports Team or Material). The clean way to render this: read the option levels off the product, render one picker per level, and resolve the user’s selections back to a single concrete variantId — because the order is placed against a variant, not a product.
{
  "id": "prod_hoodie",
  "title": "Cloud Fleece Hoodie",
  "media": [{ "type": "image", "src": "https://cdn.snappy.com/..." }],
  "options": [
    { "name": "Color", "values": ["Heather Grey", "Black", "Forest"] },
    { "name": "Size",  "values": ["S", "M", "L", "XL"] }
  ],
  "variants": [
    {
      "id": "var_hoodie_grey_m",
      "options": { "Color": "Heather Grey", "Size": "M" },
      "price": { "amount": 58.0, "currency": "USD" },
      "availability": "available"
    }
  ]
}
// Given the user's picks ({ Color: "Black", Size: "L" }), find the variant.
function resolveVariant(product, selection) {
  return product.variants.find((v) =>
    Object.entries(selection).every(([level, value]) => v.options[level] === value)
  );
}

const variant = resolveVariant(product, { Color: "Black", Size: "L" });
// variant.id is what you'll send to checkout.
// Disable option combinations where availability !== "available".
Variant UX that respects people:
  • Pre-select the most popular or only-available option so a single-variant product needs zero taps.
  • Visibly disable (don’t hide) combinations that are out of stock — people want to know the Forest hoodie exists, just not in their size right now.
  • Reflect the selected variant’s image and price immediately. Surprise at checkout erodes trust.
  • Show the availability state plainly. A graceful “Back in stock soon” beats a dead “Add to cart” button.

Two features make a store feel alive: a search box that finds things, and “you might also like” rows that keep people exploring.
Preview — confirm against the final spec. Dedicated search and related-products endpoints are on the v3 roadmap but aren’t published yet. The shapes below are our best expectation so you can design your UI around them now — confirm before building, and talk to your Snappy contact if you need these capabilities before they ship.
Product search — for a true search box (beyond the catalog’s filter[title] substring match), expect a dedicated endpoint that ranks across titles, brands, and tags:
GET /v3/products/search?q=cold+brew&location=US
In the meantime, filter[title] on the collection endpoint (Step 5) covers most in-store search needs. Related products — for “Complete the set” or “More like this” rows on a product page, expect a related-products endpoint keyed off a product ID:
GET /v3/products/{productId}/related
Until it lands, a solid stand-in is to fetch a small rail filtered to the same primary tag or category as the product being viewed — reusing buildRail from Step 3.

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.
GET /v2/orders/addresses/autocomplete
// Debounce on the client; call this from your backend.
async function autocompleteAddress(partial, country = "US") {
  const url = new URL(`${SNAPPY}/v2/orders/addresses/autocomplete`);
  url.searchParams.set("address", partial);  // 4–128 chars
  url.searchParams.set("country", country);
  const res = await fetch(url, { headers });
  const { results } = await res.json();
  return results; // [{ addressLine1, addressLine2?, city, state, zipcode }, ...]
}
A response looks like:
{
  "results": [
    { "addressLine1": "123 Main St", "city": "San Francisco",
      "state": "CA", "zipcode": "94105" }
  ]
}
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.
The fields you ultimately need for an order: recipient first name, last name, email, and a shipping address (line1, optional line2, city, state, zip, country). Collect them, validate them, and you’re ready to place the order.

Step 9 — Place the order

This is the payoff, and it’s refreshingly simple. One call creates the gift and places the order:
POST /v2/orders
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.
async function placeOrder({ variantId, recipient, idempotencyKey }) {
  const res = await fetch(`${SNAPPY}/v2/orders`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      accountId: process.env.SNAPPY_ACCOUNT_ID,
      fundingSourceId: process.env.SNAPPY_FUNDING_SOURCE_ID,
      variantId,
      tags: ["rewards-store"],          // optional: for your reporting
      recipient: {
        firstname: recipient.firstName,
        lastname: recipient.lastName,
        email: recipient.email,
        key: idempotencyKey,            // permanent idempotency key — see below
        shippingAddress: {
          line1: recipient.line1,
          line2: recipient.line2,       // optional
          city: recipient.city,
          state: recipient.state,
          zip: recipient.zip,
          country: recipient.country,   // ISO 3166-1 alpha-2
        },
      },
    }),
  });

  if (!res.ok) return handleOrderError(res);   // see error handling below
  const { result } = await res.json();
  return result; // { orderId, status: "processing", trackingLink }
}
A successful response:
{
  "result": {
    "orderId": "ord_GxP9mL2K",
    "status": "processing",
    "trackingLink": "https://gift.snappy.com/claim/ord_GxP9mL2K?token=..."
  }
}

The idempotency key matters

The recipient.key field is a permanent idempotency key. 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.
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.

Handle the errors people will actually hit

A few failures are worth handling gracefully, because they map to real human moments:
CodeMeaningWhat to show the user
402_GIFT_001Insufficient budget in the funding sourceA calm “Something went wrong on our end” — this is your billing, not their fault. Alert your team.
422_ORDS_003Variant not available”Just sold out — here’s something similar.” Offer the related rail.
422_ORDS_006Price exceeds the configured budgetRe-check your points→budget math; don’t let them reach this.
422_ORDS_002Region not supported for shippingCatch at the address step, before they’re emotionally committed.
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.

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.
Preview — confirm against the final spec. The order-* webhook event names and payload shape below reflect the model as we expect it to ship. Confirm the exact event types and fields against the final webhook reference before you depend on them.
Orders move through a predictable lifecycle, and each transition fires an event:
StatusMeaning
processingOrder received and queued for fulfillment.
receivedAccepted and scheduled for shipment.
shippedOn its way — tracking is live.
deliveredArrived.
cancelledCouldn’t be fulfilled (e.g. address issue).
A webhook delivery looks roughly like:
{
  "event": "order-shipped",
  "data": {
    "orderId": "ord_GxP9mL2K",
    "status": "shipped",
    "trackingLink": "https://gift.snappy.com/claim/ord_GxP9mL2K?token=...",
    "timestamp": "2026-06-02T15:04:00Z"
  }
}
Handle it on your backend:
// Express handler for Snappy order webhooks.
app.post("/webhooks/snappy", (req, res) => {
  const { event, data } = req.body;
  if (event.startsWith("order-")) {
    updateOrderStatus(data.orderId, data.status);     // your DB
    if (data.status === "shipped") {
      notifyUser(data.orderId, data.trackingLink);    // your email/push
    }
  }
  res.sendStatus(200); // acknowledge fast
});
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.

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.

API Reference

Full endpoint specs for everything used in this guide.
Last modified on June 3, 2026