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.
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.
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",};
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;}
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:
The status field tells you exactly how to treat the product locally:
status
Meaning
What to do
in_stock
Available to order
Show it, make it redeemable.
stocked_on_demand
Available, may need extra lead time
Show it; consider a “ships in X days” note.
out_of_stock
Temporarily unavailable
Keep the page, disable redemption gracefully.
discontinued
Gone for good, won’t restock
Remove 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.
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,450const maxBudgetUsd = toDollars(balancePoints); // 124.50const 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.
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.
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.
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:
sort=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.
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();}
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.
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.
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.
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.
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.
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.
A few failures are worth handling gracefully, because they map to real human moments:
Code
Meaning
What to show the user
402_GIFT_001
Insufficient budget in the funding source
A calm “Something went wrong on our end” — this is your billing, not their fault. Alert your team.
422_ORDS_003
Variant not available
”Just sold out — here’s something similar.” Offer the related rail.
422_ORDS_006
Price exceeds the configured budget
Re-check your points→budget math; don’t let them reach this.
422_ORDS_002
Region not supported for shipping
Catch 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.
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:
// 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.
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.