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

# Building & syncing a product catalog

> Pick products from the Rye catalog, populate your own database, and keep it in sync with real-time webhooks plus a daily reconciliation job.

If your app shows a curated set of products — a storefront, a gift guide, a chat assistant that recommends items — you'll want your own copy of the product data rather than hitting Rye's API on every page render. This guide walks through building that catalog and keeping it fresh.

The recommended approach is a **server-side sync**:

1. **Browse** the Catalog tab in the production console to pick products you want to sell.
2. **Backfill** initial product data with the [product lookup endpoint](/api-v2/api-reference/products/lookup-product) to populate your database.
3. **Subscribe** to `product.updated` / `product.removed` webhooks so your copy stays current in real time.
4. **Reconcile** once every 24 hours with the lookup endpoint, to catch anything a missed webhook left stale.

This gives you full control over the data and lets you query it in whatever shape is most efficient for your use case. The sections below follow that order — set up the webhook handler before backfilling so you don't miss updates that land mid-backfill.

## Step 1: Find products in the Catalog tab

Open the **Catalog** tab in the [production console](https://console.rye.com) and browse the merchants and products available through Rye. When you find a product you want to sell, copy its **product URL** by right clicking on the product and clicking "Copy product URL" — that's the only identifier you need to look it up.

<Frame>
  <img src="https://mintlify.s3.us-west-1.amazonaws.com/rye-35/images/rewards/catalog-copy-product-url.png" alt="Right-clicking a product in the Catalog tab reveals a menu with View product, Copy product ID, and Copy product URL." />
</Frame>

Note the **commission** percentage shown on each product tile — that's the cut you earn on a sale, and it's what makes room for the discounting strategy described in [Payment flows](/rewards/payment-flows#discounting-to-incentivize-purchases).

<Note>
  Browse in **production** (`https://console.rye.com`), not staging. The catalog is not yet available in the staging environment.
</Note>

Collect the URLs of the products you want in your catalog. A product URL looks like any normal merchant link, e.g.:

```
https://flybyjing.com/products/saucetrio
```

## Step 2: Set up a `product.updated` webhook handler

Product events keep your catalog current without polling. There are two:

| Event             | Meaning                                                                                     | Payload                                                                                                    |
| ----------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `product.updated` | A product was created or modified (attributes, variants, pricing, or availability changed). | Includes a `data` field with the full [`Product`](/api-v2/api-reference/products/lookup-product) snapshot. |
| `product.removed` | A product was deleted or unpublished by the merchant.                                       | No `data` field — the product no longer exists.                                                            |

Product webhooks are **snapshot** events, which mean they carry the full product state in their `data` field, so you can write updated product data to your database without any follow-up API call. See the [webhooks guide](/api-v2/webhooks) for endpoint setup, signature verification, and the verification handshake.

<Warning>
  **Product event subscriptions aren't yet self-serve.** We don't currently support subscribing to product events programmatically or from the console. [Reach out to us](/api-v2/support) and we'll enable `product.updated` / `product.removed` delivery for the products and merchants you care about, on a case-by-case basis.
</Warning>

### Handling the events

Verify the signature, then branch on the event type. Acknowledge quickly (within 5 seconds) and do the database write in the background.

<CodeGroup>
  ```typescript webhook.ts theme={null}
  import { CheckoutIntents } from "checkout-intents";
  import express from "express";
  import { upsertProduct, deleteProduct } from "./catalog";

  const app = express();
  const client = new CheckoutIntents();
  const WEBHOOK_SECRET = process.env.RYE_HMAC_SECRET_KEY!;

  app.use(express.raw({ type: "application/json" }));

  app.post("/webhook", (req, res) => {
    const signatureHeader = req.headers["x-rye-signature"] as string;
    if (!signatureHeader) return res.status(401).send("Unauthorized");

    const event = client.events.unwrap(
      req.body.toString(),
      signatureHeader,
      WEBHOOK_SECRET,
    );

    // Handle the verification challenge during endpoint setup
    if (event.type === "webhook_endpoint.verification_challenge") {
      return res.json({ challenge: event.source.id });
    }

    // Acknowledge immediately, then process in the background
    res.status(200).send("OK");

    switch (event.type) {
      case "product.updated":
        // `data` is a full Product snapshot — write it straight to your DB
        void upsertProduct(event.data);
        break;
      case "product.removed":
        // No `data` — the product is gone. Remove it by id.
        void deleteProduct(event.source.id);
        break;
    }
  });

  app.listen(3000, () => console.log("Listening on port 3000"));
  ```

  ```python webhook.py theme={null}
  import os
  from flask import Flask, request, jsonify
  from checkout_intents import CheckoutIntents
  from catalog import upsert_product, delete_product

  app = Flask(__name__)
  client = CheckoutIntents()
  WEBHOOK_SECRET = os.environ["RYE_HMAC_SECRET_KEY"]


  @app.route("/webhook", methods=["POST"])
  def webhook():
      signature_header = request.headers.get("x-rye-signature")
      if not signature_header:
          return "Unauthorized", 401

      event = client.events.unwrap(
          request.get_data(), signature_header, WEBHOOK_SECRET
      )

      # Handle the verification challenge during endpoint setup
      if event.type == "webhook_endpoint.verification_challenge":
          return jsonify({"challenge": event.source.id})

      if event.type == "product.updated":
          # `data` is a full Product snapshot — write it straight to your DB
          upsert_product(event.data)
      elif event.type == "product.removed":
          # No `data` — the product is gone. Remove it by id.
          delete_product(event.source.id)

      return "OK", 200
  ```
</CodeGroup>

Persist the `id` (the stable Rye product identifier, e.g. `fly-by-jing.myshopify.com:17880536678469`) and the `url` on every product — you'll key your reconciliation job off the `url` in Step 4.

<Info>
  **Snapshots can arrive out of order.** Each event carries a `createdAt` timestamp; if you receive an update older than what you've already stored, ignore it. For strict consistency you can ignore the snapshot entirely and re-fetch authoritative state with the lookup endpoint (Step 3) using the product's `url`.
</Info>

## Step 3: Backfill your catalog with the lookup endpoint

To populate your catalog initially, call the [product lookup endpoint](/api-v2/api-reference/products/lookup-product) once for each product URL you collected in Step 1. It's a simple `GET` keyed by URL:

```bash curl theme={null}
curl "https://api.rye.com/api/v1/products/lookup?url=https://flybyjing.com/products/saucetrio" \
  -H "Authorization: Bearer $RYE_API_KEY"
```

The response is a `Product` object:

```jsonc theme={null}
{
  "id": "fly-by-jing.myshopify.com:17880536678469",
  "url": "https://flybyjing.com/products/saucetrio",
  "name": "Chili Crisp Trio",
  "brand": "Fly By Jing",
  "retailer": "FLY BY JING",
  "sku": "Set_Triple_Threat_2",
  "description": "Our best-selling trio of addictive Sichuan Sauces, now in a gift-ready box…",
  "isPurchasable": true,
  "availability": "in_stock",
  "price": { "amountSubunits": 4500, "currencyCode": "USD" },
  "images": [
    {
      "url": "https://images.rye.com/kHP2gdcGQjAS7tXRHRoKyq4PtQdEJ_Y0QkRvpad7qM4/aHR0cHM6...",
      "isFeatured": true
    }
    // ...4 more images
  ],
  "variantDimensions": [{ "label": "Title", "values": ["Default Title"] }],
  "variants": [
    {
      "id": "17880536678469",
      "name": "Default Title",
      "sku": "Set_Triple_Threat_2",
      "availability": "in_stock",
      "price": { "amountSubunits": 4500, "currencyCode": "USD" },
      "dimensions": [{ "label": "Title", "value": "Default Title" }],
      "images": [{ "url": "https://images.rye.com/kHP2gdcG...", "isFeatured": true }]
    }
  ]
}
```

Loop over your URLs and upsert each result with the same `upsertProduct` you wrote for the webhook handler — the shape is identical to the `product.updated` snapshot:

<CodeGroup>
  ```typescript backfill.ts theme={null}
  import { CheckoutIntents } from "checkout-intents";
  import { upsertProduct } from "./catalog";

  const client = new CheckoutIntents({ apiKey: process.env.RYE_API_KEY });

  const PRODUCT_URLS = [
    "https://flybyjing.com/products/saucetrio",
    // ...the URLs you collected from the Catalog tab
  ];

  for (const url of PRODUCT_URLS) {
    const product = await client.products.lookup({ url });
    await upsertProduct(product);
    console.log(`Backfilled ${product.name}`);
  }
  ```

  ```python backfill.py theme={null}
  import os
  from checkout_intents import CheckoutIntents
  from catalog import upsert_product

  client = CheckoutIntents(api_key=os.environ["RYE_API_KEY"])

  PRODUCT_URLS = [
      "https://flybyjing.com/products/saucetrio",
      # ...the URLs you collected from the Catalog tab
  ]

  for url in PRODUCT_URLS:
      product = client.products.lookup(url=url)
      upsert_product(product)
      print(f"Backfilled {product.name}")
  ```
</CodeGroup>

<Note>
  Product lookup has its own rate-limit bucket of **10 requests/sec** (see [Rate Limits](/api-v2/rate-limits)). For a large backfill, throttle to stay under it, or [request an increase](/api-v2/support).
</Note>

## Step 4: Reconcile daily with a cron job

Webhooks can occasionally be missed — a delivery times out, your endpoint has a blip, an event is dropped. A periodic reconciliation pass keeps your catalog from drifting. **Run a cron job once every 24 hours** that re-looks-up every product in your catalog and writes back the current state.

The job re-uses the lookup endpoint from Step 3. For each stored product:

* **Lookup succeeds** → upsert the fresh data (this corrects anything a missed `product.updated` left stale).
* **Lookup returns `404`** → the product is gone; delete it (this corrects anything a missed `product.removed` left behind).

<CodeGroup>
  ```typescript reconcile.ts theme={null}
  import { CheckoutIntents } from "checkout-intents";
  import { allProducts, upsertProduct, deleteProduct } from "./catalog";

  const client = new CheckoutIntents({ apiKey: process.env.RYE_API_KEY });

  // Schedule this to run once per 24h (e.g. node-cron "0 3 * * *").
  export async function reconcileCatalog() {
    for (const { id, url } of await allProducts()) {
      try {
        const product = await client.products.lookup({ url });
        await upsertProduct(product);
      } catch (err) {
        if (err instanceof CheckoutIntents.NotFoundError) {
          // Product no longer exists in Rye's catalog — drop it.
          await deleteProduct(id);
        } else {
          throw err; // transient error — let it retry next run
        }
      }
      await new Promise((r) => setTimeout(r, 100)); // ~10 req/s rate limit
    }
  }
  ```

  ```python reconcile.py theme={null}
  import os
  import time
  from checkout_intents import CheckoutIntents
  from checkout_intents import NotFoundError
  from catalog import all_products, upsert_product, delete_product

  client = CheckoutIntents(api_key=os.environ["RYE_API_KEY"])


  # Schedule this to run once per 24h (e.g. a cron entry "0 3 * * *").
  def reconcile_catalog():
      for product in all_products():
          try:
              fresh = client.products.lookup(url=product["url"])
              upsert_product(fresh)
          except NotFoundError:
              # Product no longer exists in Rye's catalog — drop it.
              delete_product(product["id"])
          time.sleep(0.1)  # ~10 req/s rate limit
  ```
</CodeGroup>

A daily cadence is plenty as a safety net — real-time updates still flow through the webhook handler from Step 2. If your catalog is small or you need tighter freshness guarantees, you can run it more often, but mind the [product lookup rate limit](/api-v2/rate-limits).

## Recap

| Mechanism                                      | Purpose                               | Cadence    |
| ---------------------------------------------- | ------------------------------------- | ---------- |
| Catalog tab                                    | Discover products to sell             | One-off    |
| Lookup endpoint (backfill)                     | Populate the catalog initially        | One-off    |
| `product.updated` / `product.removed` webhooks | Keep the catalog current in real time | Continuous |
| Lookup endpoint (reconcile)                    | Catch missed webhooks                 | Every 24h  |

With all four in place you have an authoritative, query-optimized copy of your product data that stays in sync with Rye. From here you can wire products straight into a [checkout intent](/api-v2/example-flows/simple-checkout) to let users buy them.
