Custom integration

For a developer connecting a store that is not on Shopify. It walks the whole integration in the order you would build it, and everything here is implemented in packages/example-custom-store, a working shop you can run and read.

If you are on Shopify, install the app instead and skip all of this. See Connecting Shopify.

What you are building

Cascade needs to know about your catalog, your customers and your orders. In return it runs reviews, loyalty, referrals and wishlists, renders the customer-facing pieces on your storefront, and tells your systems when something happens.

Four pieces of work, none of them large:

  1. Push products, customers and orders in, and keep pushing as they change.
  2. Put the widgets on your storefront, signing in the shopper who is logged in.
  3. Honor a loyalty reward code at checkout.
  4. Receive webhooks so your systems hear what Cascade did.

Before you start

  • A site in Cascade representing this storefront. Create it under Settings → Sites, or over the API as the example does on first run. Everything else names it by slug.
  • A secret key from Settings → API keys with the permissions you need: products:write, customers:write, orders:write, loyalty:read, loyalty:write, webhooks:write, and read on anything you want to pull back. See Authentication.
  • A publishable key and the widget signing secret, both from the same page, for the storefront half.

The secret key belongs on your server. If it can be read from a browser, anyone can read and change everything in your organization.

1. Getting your records in

Use the import endpoints. They create or update, matching on a natural key, so one code path covers your first backfill and a single record changing later:

EndpointMatches on
POST /products/importslug
POST /customers/importemail
POST /orders/importremoteId, your own order ID

Every column you send overwrites what is stored, so send complete records rather than partial ones. A column you leave out stores nothing under it, except for variants and items: leave one of those out and whatever is stored stays, so an update that changes a title cannot wipe a product's variants. Sending null is the explicit "replace it with nothing". A product carries its variants and the sites it appears on:

await fetch("https://api.cascade.dev/products/import", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CASCADE_SECRET_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    data: [
      {
        slug: "classic-tee",
        title: "Classic Tee",
        description: "The one everyone owns.",
        imageUrl: "https://example.com/img/classic-tee.jpg",
        url: "https://example.com/products/classic-tee",
        tags: ["apparel"],
        variants: [
          {
            slug: "classic-tee-m",
            title: "Medium",
            priceCents: 2400,
            currencyCode: "USD",
            available: true,
          },
        ],
        sites: [{ site: "my-store" }],
      },
    ],
  }),
})

Order matters: products, then customers, then orders. An order references products by slug and a customer by email, and a row naming something Cascade has not seen is skipped and reported rather than guessed at.

Backfilled orders must not earn points

POST /orders/import takes an earnLoyalty flag. Send false for history:

{ "data": [ ... ], "earnLoyalty": false }

Your existing order history is not new business. Import it with the default and every past order runs through your loyalty rules, handing out points and reward emails for purchases made years ago. A live checkout sends the default, which earns.

If members already have balances in another system, import those separately rather than trying to reconstruct them from orders. See Migrating from another loyalty app.

Keeping in sync

Push on write. When a product is saved in your own admin, import that one product; when an order ships, import that one order with its new shippedAt. The same endpoint, one record in the array.

Deletion works the other way around: Cascade deletes by ID and you know your own slugs and emails, so a delete is a lookup then a delete.

const found = await getProducts({ query: { filter: JSON.stringify({ slug }), limit: 1 } })
const product = found.data.data[0]
if (product) await deleteProductsById({ path: { id: product.id } })

2. Widgets on your storefront

One script tag renders the ratings, review list, review form, loyalty page and wishlist manager. It takes your publishable key, which is safe in page source.

For anything belonging to one shopper (their wishlists, their loyalty balance), the page also carries a signed email address, proving on your server that the shopper really is who the page says. The recipe is an HMAC-SHA256 of the email and a Unix timestamp, keyed with your widget signing secret:

export const signCustomer = (email: string) => {
  const timestamp = Math.floor(Date.now() / 1000).toString()
  const signature = new Bun.CryptoHasher("sha256", process.env.CASCADE_SIGNING_SECRET)
    .update(`${email}${timestamp}`)
    .digest("hex")
  return { email, timestamp, signature }
}

The signing secret is not publishable. Anyone holding it can sign as any of your customers, so keep it out of theme files and JavaScript bundles. Full details, the attributes, and examples in other languages are in Storefront widgets.

3. Rewards at checkout

A member claims a reward in the loyalty widget and gets a code. Your checkout has to recognize it, price it, and mark it used. Three steps, in this order:

Look the code up. GET /loyalty-rewards/lookup?code=... says what the code is, what it is worth, whether it is still pending, when it expires, and any minimum order total.

Work out the money yourself. Cascade tracks the reward; the basket is your business. Map the reward type to your own discount:

Reward typeWhat it means
amountCouponA fixed amount off, capped at the subtotal.
percentageCouponA percentage off the subtotal.
freeShippingShipping is zero.
giftCardA fixed amount off, capped at the subtotal.

Check the code is usable before applying it: the status has to be pending, expiresAt has to be in the future, and minimumOrderTotalCents has to be met.

Redeem it once the order exists. POST /loyalty-rewards/{id}/redeem marks the code used so it cannot be spent twice. Do this after you have written the order, not while the shopper is still editing their cart.

Then import the order as usual. That is what earns points on the purchase and starts the clock on the review request.

4. Receiving webhooks

Register an endpoint and verify every delivery against the raw body before parsing it. See Webhooks for the envelope, the signature recipe and the full event list.

Subscribe to what you actually handle. A storefront usually wants review.created and review.updated (to keep its own cache of published reviews), loyaltyReward.issued and loyaltyReward.redeemed, loyaltyMembership.created, and wishlist.updated.

Telling Cascade about things it cannot see

Some of what a loyalty rule should reward never touches an order: a shopper completed a profile, attended an event, scanned a card in a physical store. POST /loyalty-events takes a token that names one of your rules, the customer, and optionally a value the rule does the arithmetic on.

Tokens come from the rule's trigger settings in the dashboard. A store with no such rule sends nothing. See Loyalty events API.

The worked example

packages/example-custom-store is a complete fake shop, running on :3070 alongside the rest of the repo, that integrates through the public API alone. It has a storefront with every widget, an admin that pushes records, a checkout that honors a reward code, and a webhook receiver, and it holds everything in memory so a restart is a factory reset.

bun db:reset   # seeds an organization, a secret key and a signing secret
bun dev        # the API on :3050, the dashboard on :3055, the store on :3070

On boot it connects itself: creates its site, registers its webhook, and pushes its products, customers and orders. Its /admin page shows the connection status and a two-way log of everything sent and received, which is the fastest way to see a broken integration.

server/cascade.ts is the entire integration surface, and reading it top to bottom is the point of the package. Everything else in there is shop. The other three files worth opening are server/session.ts (signing the widget auth trio), server/checkout.ts (the reward math above) and server/webhook-receiver.ts (verifying against the raw body).

It is built on @cascade-commerce/api, the published TypeScript client, rather than hand-rolled fetch. See TypeScript client.