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

# Next.js Quickstart

> Build a custom storefront in Next.js 16 with Shoppex as the commerce engine — products, checkout, and webhook fulfillment in under 15 minutes.

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_*`).

<Info>
  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).
</Info>

***

## 1. Set Up the Project

```bash theme={"system"}
npx create-next-app@latest my-shop --typescript --app --tailwind
cd my-shop
npm install @shoppexio/sdk
```

Add your API key to `.env.local`:

```bash theme={"system"}
SHOPPEX_API_KEY=shx_your_api_key
SHOPPEX_WEBHOOK_SECRET=whsec_your_webhook_secret
```

<Warning>
  `SHOPPEX_API_KEY` must only exist on the server. Never prefix it with `NEXT_PUBLIC_`.
</Warning>

Create a tiny server-only client wrapper at `lib/shoppex.ts`:

```typescript theme={"system"}
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.

```tsx theme={"system"}
import Link from 'next/link';
import { shoppex } from '@/lib/shoppex';

type ProductListItem = {
  id: string;
  uniqid: 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`:

```tsx theme={"system"}
import { redirect } from 'next/navigation';
import { shoppex } from '@/lib/shoppex';

type ProductDetail = {
  id: string;
  uniqid: 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="you@example.com"
          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>
  );
}
```

<Info>
  `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.
</Info>

<Warning>
  This quickstart uses `POST /dev/v1/payments` because it is the smallest ad-hoc checkout example (title + amount). That path does **not** link checkout to the catalog product you fetched above.

  If the product is a **subscription**, or you need serial/file/DYNAMIC delivery, use **`POST /dev/v1/orders`** with `items: [{ product_id: product.uniqid, quantity: 1 }]` instead. See [Subscriptions](/guides/subscriptions#headless-developer-api-checkout) and [SaaS Paywall](/headless/use-cases/saas-paywall).
</Warning>

***

## 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`:

```typescript theme={"system"}
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-v2');
  const deliveryId = request.headers.get('x-shoppex-delivery');
  const timestamp = request.headers.get('x-shoppex-timestamp');
  if (!signature || !deliveryId || !timestamp) {
    return NextResponse.json({ error: 'missing signature' }, { status: 401 });
  }

  const raw = await request.text();
  const segments = signature.split(',').map((part) => part.trim());
  const parts = Object.fromEntries(segments.filter((part) => part.includes('=')).map((part) => {
    const [key, value] = part.trim().split('=');
    return [key, value ?? ''];
  }));
  if (
    !segments.includes('v1')
    || parts.t !== timestamp
    || Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300
    || !/^[0-9a-f]{64}$/i.test(parts.h ?? '')
  ) {
    return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
  }

  const expected = createHmac('sha256', process.env.SHOPPEX_WEBHOOK_SECRET!)
    .update(`${deliveryId}.${timestamp}.${raw}`)
    .digest('hex');

  const sigBuf = Buffer.from(parts.h, '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 });
}
```

<Warning>
  **Always verify the signature with constant-time comparison.** The `timingSafeEqual` call above is what prevents timing-side-channel attacks. Never compare signatures with `===`.
</Warning>

<Info>
  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.
</Info>

***

## 5. Local Testing

Run the app and point a tunnel at it so the webhook is reachable:

```bash theme={"system"}
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

| Layer                               | What it does                                                     |
| ----------------------------------- | ---------------------------------------------------------------- |
| `app/products/page.tsx`             | Server-side catalog read, no API key in the browser              |
| `app/products/[id]/page.tsx`        | Server Action creates a payment and redirects to hosted checkout |
| `app/api/webhooks/shoppex/route.ts` | Signed webhook receiver with constant-time HMAC verification     |
| `lib/shoppex.ts`                    | `server-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

<CardGroup cols={2}>
  <Card title="Webhook Event Catalog" icon="bolt" href="/api-reference/webhooks/overview">
    Every event Shoppex emits, with sample payloads.
  </Card>

  <Card title="Architecture Reference" icon="sitemap" href="/headless/architecture">
    Three reference setups including mobile + backend-for-frontend.
  </Card>

  <Card title="Checkout Embed SDK" icon="window-maximize" href="/guides/embeds">
    Prefer a modal over a redirect? Swap the Server Action for a buy button.
  </Card>

  <Card title="Dev API Reference" icon="code" href="/api-reference/introduction">
    Subscriptions, licenses, coupons, customers, and more.
  </Card>
</CardGroup>
