Skip to content
Cipay
Esc
navigateopen⌘Jpreview
On this page

Hooks

Build custom catalog, checkout, subscription, order, and invoice interfaces with TanStack Query.

Start with Cipay components when their markup fits. Use hooks when your product needs custom state, layout, or orchestration. Both paths call the same buyer handler and use the same contract-derived types; using a hook does not require replacing useful Cipay components with hand-written markup.

Each read hook returns a normal TanStack Query v5 result. The SDK supplies the query key, fetcher, same-origin credentials, no-store policy, and ownership boundary.

Catalog query controls

This example uses useProductsList for explicit cache and refresh behavior, then passes each fetched product to ProductCard. Products are created in Cipay and returned by the hook; they are not defined in UI code.

"use client";

import {
  ProductCard,
  useProductsList,
} from "@cipay/client-sdk/react";

export function Catalog() {
  const products = useProductsList(
    { query: "coffee", pageSize: 12 },
    {
      staleTime: 30_000,
      retry: 2,
      retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 8_000),
      refetchOnWindowFocus: false,
    },
  );

  if (products.isPending) return <p>Loading products…</p>;
  if (products.isError) {
    return <p role="alert">{products.error.message}</p>;
  }

  return (
    <section>
      {/* isFetching also covers a background refresh while data remains visible. */}
      <button
        type="button"
        disabled={products.isFetching}
        onClick={() => void products.refetch()}
      >
        {products.isFetching ? "Refreshing…" : "Refresh catalog"}
      </button>

      {products.data.items.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </section>
  );
}

Changing query, pageSize, or cursor creates a different cache key. Send nextCursor with the same filters to request the next page, and reset the cursor when search filters change.

TanStack Query v5 does not support onSuccess, onError, or onSettled on read-query options. Derive UI directly from the result, as above. Use an effect only when fetched data must synchronize with a genuine external system.

Retrieve one product

useProduct(id) loads one published product and stays disabled while id is null. ProductCard renders the returned contract object directly.

"use client";

import {
  ProductCard,
  useProduct,
} from "@cipay/client-sdk/react";

export function ProductDetail({ productId }: { productId: string | null }) {
  const product = useProduct(productId, {
    staleTime: 60_000,
    retry: 1,
  });

  if (!productId) return <p>Select a product.</p>;
  if (product.isPending) return <p>Loading product…</p>;
  if (product.isError) return <p role="alert">{product.error.message}</p>;

  return <ProductCard product={product.data} />;
}

The product response includes published assets and offers. See the generated product response for every field.

Preview a quote

useQuote returns Cipay-calculated amounts for the chosen offer and discount. It does not create checkout or return a payment URL.

"use client";

import {
  CheckoutButton,
  useQuote,
} from "@cipay/client-sdk/react";

export function Quote({ offerId }: { offerId: string }) {
  const quote = useQuote(
    { offerId, discountCode: "SANDBOX10" },
    { staleTime: 15_000, retry: 1 },
  );

  if (quote.isPending) return <p>Calculating total…</p>;
  if (quote.isError) return <p role="alert">{quote.error.message}</p>;
  if (!quote.data) return <p>This offer is unavailable.</p>;

  return (
    <section>
      {/* Money is returned in integer halalas; format it only for display. */}
      <p>Total: {(quote.data.totalHalalas / 100).toFixed(2)} {quote.data.currency}</p>
      <CheckoutButton
        input={{
          offerId,
          buyer: { email: "buyer@example.test", locale: "en" },
          recurringConsentAccepted: true,
        }}
      />
    </section>
  );
}

Set recurringConsentAccepted only after the buyer has explicitly accepted the recurring terms shown by your application.

Create checkout with a hook

Prefer CheckoutButton for the standard create-and-redirect flow. Use useCheckoutCreate when you need custom validation, analytics, or navigation before redirecting.

"use client";

import { useCheckoutCreate } from "@cipay/client-sdk/react";

export function CustomCheckout({ offerId }: { offerId: string }) {
  const checkout = useCheckoutCreate({
    // This callback applies to every checkout started by this component.
    onError: (error) => reportSafeError(error.status, error.message),
  });

  function startCheckout() {
    checkout.mutate(
      {
        offerId,
        buyer: { email: "buyer@example.test", locale: "en" },
        recurringConsentAccepted: true,
      },
      {
        // This callback belongs only to this button click.
        onSuccess: (created) => {
          saveCapabilityInServerSession(created.capability);
          if (created.checkoutUrl) window.location.assign(created.checkoutUrl);
        },
      },
    );
  }

  return (
    <button type="button" disabled={checkout.isPending} onClick={startCheckout}>
      {checkout.isPending ? "Opening checkout…" : "Continue"}
    </button>
  );
}

The capability is a short-lived secret. Send it to your backend session; never put it in a URL, browser storage, analytics, or logs.

Read checkout status

useCheckoutStatus polls only when a capability exists. Use the generated CheckoutState values instead of repeating contract strings.

"use client";

import {
  CheckoutState,
  useCheckoutStatus,
} from "@cipay/client-sdk/react";

export function CheckoutStatus({ capability }: { capability: string | null }) {
  const status = useCheckoutStatus(capability, {
    retry: 2,
    refetchInterval: (query) =>
      query.state.data?.state === CheckoutState.Completed ? false : 2_000,
  });

  if (!capability) return null;
  if (status.isPending) return <p>Checking payment…</p>;
  if (status.isError) return <p role="alert">{status.error.message}</p>;

  return <p>Checkout status: {status.data.state}</p>;
}

A webhook remains the durable payment signal. Status is for the buyer’s return screen, not for irreversible fulfillment.

Subscription account hooks

useSubscriptionsList and subscription mutations run only after CipayProvider receives a non-secret sessionKey. The backend still verifies the application session and resolves the stored customer mapping.

SubscriptionsList handles query states and supplies each fetched subscription to renderSubscription; the mutation hook adds the account-specific cancellation action.

"use client";

import {
  SubscriptionsList,
  useScheduleSubscriptionCancellation,
} from "@cipay/client-sdk/react";

export function Subscriptions() {
  const cancellation = useScheduleSubscriptionCancellation({
    // Shared callback: refresh surrounding UI after any successful cancellation.
    onSuccess: () => showNotice("Cancellation scheduled"),
    onError: (error) => showError(error.message),
  });

  return (
    <SubscriptionsList
      heading="Your subscriptions"
      renderSubscription={(subscription) => (
        <article>
          <p>Status: {subscription.status}</p>
          <button
            type="button"
            disabled={cancellation.isPending}
            onClick={() => cancellation.mutate({
              id: subscription.id,
              revision: subscription.revision,
              reason: "Requested in account settings",
            })}
          >
            Cancel at period end
          </button>
        </article>
      )}
    />
  );
}

revision stops an old screen from overwriting a newer subscription change. useRemoveSubscriptionCancellation uses the same { id, revision } pattern. See the subscription concurrency flow.

Orders and invoice download

Use OrdersList for the owned order query and InvoiceDownloadButton for the binary request and browser download. Both remain scoped to the server-verified customer.

"use client";

import {
  InvoiceDownloadButton,
  OrdersList,
} from "@cipay/client-sdk/react";

export function Orders() {
  return (
    <OrdersList
      heading="Order history"
      renderOrder={(order) => (
        <article>
          <p>{order.invoiceNumber ?? order.id}</p>
          <p>{order.totalHalalas} {order.currency}</p>
          <InvoiceDownloadButton
            orderId={order.id}
            label="Download invoice"
            onError={(error) => showError(error.message)}
          />
        </article>
      )}
    />
  );
}

Use useOrdersList or useOrder(id) when you need a fully custom order UI. Use useInvoiceDownload when your application must handle the returned { bytes, filename } itself instead of using the component’s default browser download.

Mutation callbacks

Mutation callbacks have two useful scopes:

API Hook-level callback Per-call callback
useCheckoutCreate Shared error reporting or analytics for every attempt in the component. Store one returned capability and navigate for that specific click.
useScheduleSubscriptionCancellation Show a shared notice; the SDK also refreshes subscription queries. Close the confirmation dialog opened for this one subscription.
useRemoveSubscriptionCancellation Refresh shared account UI or report errors consistently. Confirm the particular “keep subscription” action.
useInvoiceDownload Apply one download/error policy to every invoice request. Track or rename the file for the selected order only.

Pass shared callbacks to the hook. Pass one-off onSuccess or onError callbacks as the second argument to mutate or mutateAsync. CipayBuyerError provides an HTTP status and safe display message; do not log capabilities, PII, or payment secrets.