Skip to main content
Build a custom storefront on Next.js 16 (App Router) that:
  1. Lists products from your Shoppex catalog on the server.
  2. Starts a checkout session from a Server Action.
  3. Redirects the customer to Shoppex hosted checkout.
  4. Receives a signed webhook when the order is paid and runs your own fulfillment.
Time budget: 15 minutes. You need a Shoppex shop and an API key (shx_*).
If you do not have an API key yet, open Dashboard → Settings → Developer API, click Generate New API Key, pick scopes products.read payments.write webhooks.read, and copy the key (it is shown once).

1. Set Up the Project

npx create-next-app@latest my-shop --typescript --app --tailwind
cd my-shop
npm install @shoppexio/sdk
Add your API key to .env.local:
SHOPPEX_API_KEY=shx_your_api_key
SHOPPEX_WEBHOOK_SECRET=whsec_your_webhook_secret
SHOPPEX_API_KEY must only exist on the server. Never prefix it with NEXT_PUBLIC_.
Create a tiny server-only client wrapper at lib/shoppex.ts:
import { ShoppexClient } from '@shoppexio/sdk';
import 'server-only';

export const shoppex = new ShoppexClient({
  apiKey: process.env.SHOPPEX_API_KEY!,
});

2. List Products on the Server

app/products/page.tsx reads the catalog on every request. Because this is a Server Component, your API key never leaves the server.
import Link from 'next/link';
import { shoppex } from '@/lib/shoppex';

type ProductListItem = {
  id: string;
  title: string;
  price: number;
  currency: string;
};

export default async function ProductsPage() {
  const { data: products } = await shoppex.products.list<ProductListItem>({ limit: 25 });

  return (
    <main className="mx-auto max-w-4xl p-8">
      <h1 className="mb-8 text-3xl font-semibold">Shop</h1>
      <ul className="grid grid-cols-2 gap-6">
        {products.map((product) => (
          <li key={product.id} className="rounded-lg border p-4">
            <h2 className="font-medium">{product.title}</h2>
            <p className="text-sm text-gray-500">{product.price} {product.currency}</p>
            <Link
              href={`/products/${product.id}`}
              className="mt-2 inline-block text-sm text-indigo-600 hover:underline"
            >
              View →
            </Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

3. Start Checkout from a Server Action

A product page with a Server Action that creates a payment and redirects to hosted checkout. The API key stays on the server; the browser only ever sees the resulting checkout_url. app/products/[id]/page.tsx:
import { redirect } from 'next/navigation';
import { shoppex } from '@/lib/shoppex';

type ProductDetail = {
  id: string;
  title: string;
  price: number;
  currency: string;
};

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const { data: product } = await shoppex.products.get<ProductDetail>(id);

  async function startCheckout(formData: FormData) {
    'use server';
    const email = formData.get('email') as string;

    const payment = await shoppex.raw.POST<{ data: { url: string; checkout_url?: string } }>(
      '/dev/v1/payments',
      {
        body: {
          title: product.title,
          email,
          value: product.price,
          currency: product.currency,
          return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/orders/thanks`,
          cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/products/${product.id}`,
        },
      }
    );

    redirect(payment.data.checkout_url ?? payment.data.url);
  }

  return (
    <main className="mx-auto max-w-xl p-8">
      <h1 className="text-3xl font-semibold">{product.title}</h1>
      <p className="mt-2 text-gray-600">{product.price} {product.currency}</p>

      <form action={startCheckout} className="mt-8 space-y-4">
        <input
          type="email"
          name="email"
          required
          placeholder="[email protected]"
          className="w-full rounded border px-3 py-2"
        />
        <button
          type="submit"
          className="rounded bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-700"
        >
          Buy
        </button>
      </form>
    </main>
  );
}
return_url is where Shoppex sends the customer after a successful payment. cancel_url is where they go if they abandon checkout.If you prefer the Stripe-style alias, POST /dev/v1/checkout/sessions is available too. The published JS SDK does not wrap that route yet, so the quickstart uses POST /dev/v1/payments, which already has first-class SDK support.

4. Verify the Signed Webhook

Create a webhook endpoint in the Shoppex dashboard (Settings → Webhooks → Add Endpoint) pointing at https://your-site.com/api/webhooks/shoppex, subscribe to order:paid, and copy the signing secret into SHOPPEX_WEBHOOK_SECRET. app/api/webhooks/shoppex/route.ts:
import { createHmac, timingSafeEqual } from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const signature = request.headers.get('x-shoppex-signature');
  if (!signature) {
    return NextResponse.json({ error: 'missing signature' }, { status: 401 });
  }

  const raw = await request.text();
  const expected = createHmac('sha512', process.env.SHOPPEX_WEBHOOK_SECRET!)
    .update(raw)
    .digest('hex');

  const sigBuf = Buffer.from(signature, 'hex');
  const expBuf = Buffer.from(expected, 'hex');
  if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
    return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
  }

  const event = JSON.parse(raw) as {
    event: string;
    data: { uniqid: string; status: string; customer_email: string; total: number };
    created_at: number;
  };

  if (event.event === 'order:paid') {
    // Your fulfillment: grant access, send license, provision subscription, etc.
    console.log(`Order ${event.data.uniqid} paid for ${event.data.customer_email}`);
  }

  return NextResponse.json({ received: true });
}
Always verify the signature with constant-time comparison. The timingSafeEqual call above is what prevents timing-side-channel attacks. Never compare signatures with ===.
Return 2xx within a few seconds. Shoppex retries failed deliveries with backoff — do your real fulfillment work in a background job and ack the webhook immediately if fulfillment takes longer than a couple of seconds.

5. Local Testing

Run the app and point a tunnel at it so the webhook is reachable:
npm run dev
# in a second terminal:
ngrok http 3000
Set the webhook URL in the Shoppex dashboard to the ngrok URL + /api/webhooks/shoppex. In the dashboard, click Send Test Event on your webhook to verify the signature check passes.

What You Built

LayerWhat it does
app/products/page.tsxServer-side catalog read, no API key in the browser
app/products/[id]/page.tsxServer Action creates a payment and redirects to hosted checkout
app/api/webhooks/shoppex/route.tsSigned webhook receiver with constant-time HMAC verification
lib/shoppex.tsserver-only SDK wrapper
Your frontend, your routing, your brand. Shoppex handled the PSP selection, 3DS, the hosted checkout page, the payment confirmation, the event delivery, and will also handle refunds, disputes, and subscriptions when you add them.

Next Steps

Webhook Event Catalog

Every event Shoppex emits, with sample payloads.

Architecture Reference

Three reference setups including mobile + backend-for-frontend.

Checkout Embed SDK

Prefer a modal over a redirect? Swap the Server Action for a buy button.

Dev API Reference

Subscriptions, licenses, coupons, customers, and more.