Ready to maximize your revenue?Book a Demo
TOPCODE
Webhooks

Building a verified webhook handler in Next.js for Shopify

Last updated on June 2, 2026

Building a verified webhook handler in Next.js for Shopify

TOPCODE

Every Shopify app that reacts to store events — new orders, product updates, customer data requests — needs to handle webhooks securely. Shopify signs every webhook delivery with an HMAC-SHA256 signature, and your handler must verify that signature before trusting or acting on the payload. Skip this step and you expose your app to forged events that could drain a database or spam an email queue. This tutorial builds a production-ready webhook handler in Next.js App Router that verifies signatures correctly, handles the raw body requirement, and implements proper error responses.

What You'll Build

A Next.js App Router route handler at app/api/webhooks/shopify/route.ts that: (1) reads the raw request body as a Buffer before JSON parsing, (2) computes HMAC-SHA256 of the raw body using your webhook secret, (3) compares the computed digest against the X-Shopify-Hmac-Sha256 header using a timing-safe comparison, and (4) dispatches to per-topic handlers for orders/create and customers/data_request (a GDPR mandatory topic).

Before You Start

You need a Next.js 14+ project (App Router), a Shopify Partner account with an app, and the app's client secret (found in the Partner Dashboard under App setup). Store the secret in .env.local as SHOPIFY_WEBHOOK_SECRET. Node.js's built-in crypto module handles the HMAC — no additional packages required.

1

Create the verification utility

The verification logic lives in a small utility so it can be tested independently. The algorithm is: (1) compute HMAC-SHA256(key=clientSecret, message=rawBody), (2) base64-encode the resulting digest, (3) compare it against the X-Shopify-Hmac-Sha256 header value using crypto.timingSafeEqual. The timing-safe comparison prevents timing oracle attacks where an attacker could guess the correct HMAC one byte at a time by measuring response latency.

lib/shopify/verifyWebhook.tsts
// lib/shopify/verifyWebhook.ts
import crypto from 'crypto';

export function verifyShopifyWebhook(
  rawBody: Buffer,
  hmacHeader: string,
  secret: string,
): boolean {
  const digest = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('base64');

  const digestBuf = Buffer.from(digest);
  const headerBuf = Buffer.from(hmacHeader);

  if (digestBuf.length !== headerBuf.length) {
    return false;
  }

  return crypto.timingSafeEqual(digestBuf, headerBuf);
}

Expected outcome: A pure function that takes raw bytes, the header value, and the secret, and returns a boolean. Unit-testable without an HTTP server.

2

Build the App Router route handler

In Next.js App Router, route handlers receive a Request object from the Fetch API. Call request.arrayBuffer() to get the raw bytes, then wrap in Buffer.from() for the Node.js crypto API. Extract the x-shopify-hmac-sha256 and x-shopify-topic headers. After verification passes, parse the body and dispatch to the appropriate handler function.

Return 200 for successful handling, 401 when the HMAC check fails, and 422 for topics your handler doesn't recognise. Shopify will retry deliveries that receive 5xx responses, so use 5xx only for genuine infrastructure errors — not for unknown topics.

app/api/webhooks/shopify/route.tsts
// app/api/webhooks/shopify/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyShopifyWebhook } from '@/lib/shopify/verifyWebhook';
import { handleOrderCreate } from './handlers/orderCreate';
import { handleCustomerDataRequest } from './handlers/customerDataRequest';

export const runtime = 'nodejs'; // required for crypto

export async function POST(request: NextRequest): Promise<NextResponse> {
  const rawBuffer = Buffer.from(await request.arrayBuffer());
  const hmacHeader = request.headers.get('x-shopify-hmac-sha256') ?? '';
  const topic = request.headers.get('x-shopify-topic') ?? '';
  const shop = request.headers.get('x-shopify-shop-domain') ?? '';

  const isValid = verifyShopifyWebhook(
    rawBuffer,
    hmacHeader,
    process.env.SHOPIFY_WEBHOOK_SECRET!,
  );

  if (!isValid) {
    console.warn(`[webhook] HMAC verification failed for shop ${shop}, topic ${topic}`);
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const payload = JSON.parse(rawBuffer.toString('utf-8'));

  try {
    switch (topic) {
      case 'orders/create':
        await handleOrderCreate({ shop, payload });
        break;
      case 'customers/data_request':
        await handleCustomerDataRequest({ shop, payload });
        break;
      default:
        console.log(`[webhook] Unhandled topic: ${topic}`);
        return NextResponse.json({ error: 'Unprocessable topic' }, { status: 422 });
    }

    return NextResponse.json({ ok: true });
  } catch (err) {
    console.error('[webhook] Handler error:', err);
    return NextResponse.json({ error: 'Internal error' }, { status: 500 });
  }
}

Expected outcome: Requests with a valid HMAC are dispatched to the correct handler. Invalid signatures return 401 immediately without touching the payload.

3

Implement the orders/create handler

Each topic handler receives the shop domain and the parsed payload. For orders/create the payload is an Order object — the same shape as the REST Admin API's GET /orders/{id}.json response. It includes line_items, customer, shipping_address, and financial status fields. Shopify's @shopify/shopify-api package exports TypeScript types for the REST resources — install it to get the Order type.

app/api/webhooks/shopify/handlers/orderCreate.tsts
// app/api/webhooks/shopify/handlers/orderCreate.ts
import type { Order } from '@shopify/shopify-api';
import { db } from '@/lib/db';

export async function handleOrderCreate({
  shop,
  payload,
}: {
  shop: string;
  payload: Order;
}): Promise<void> {
  // Upsert into your database — use the Shopify order ID as the idempotency key
  // Shopify may deliver the same webhook more than once.
  await db.order.upsert({
    where: { shopifyId: String(payload.id) },
    create: {
      shopifyId: String(payload.id),
      shop,
      customerEmail: payload.email ?? null,
      totalPrice: payload.total_price,
      lineItemCount: payload.line_items?.length ?? 0,
      createdAt: new Date(payload.created_at!),
    },
    update: {
      totalPrice: payload.total_price,
    },
  });
}

Expected outcome: Order data is upserted idempotently. Duplicate deliveries (Shopify retries on 5xx) will not create duplicate rows.

4

Register the webhook via the API

Register webhooks during your OAuth callback using the webhookSubscriptionCreate Admin GraphQL mutation (preferred) or via the REST POST /admin/api/2024-01/webhooks.json endpoint. Your webhook URL must be the publicly reachable HTTPS endpoint from the previous step. During development with shopify app dev the tunnel URL changes each session, so you'll need to re-register on every dev restart unless you configure a stable tunnel domain.

mutation WebhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $webhookSubscription: WebhookSubscriptionInput!) {
  webhookSubscriptionCreate(topic: $topic, webhookSubscription: $webhookSubscription) {
    webhookSubscription {
      id
      topic
      endpoint {
        __typename
        ... on WebhookHttpEndpoint {
          callbackUrl
        }
      }
    }
    userErrors { field message }
  }
}

# Variables:
# {
#   "topic": "ORDERS_CREATE",
#   "webhookSubscription": {
#     "callbackUrl": "https://your-app.vercel.app/api/webhooks/shopify",
#     "format": "JSON"
#   }
# }

Expected outcome: The webhook subscription is created. Send a test order to the store and check your handler logs.

5

Test with the Shopify CLI deliveries command

Shopify CLI 3.x ships a shopify webhook trigger command that synthesises a realistic webhook payload and delivers it to your handler, optionally signing it with a test secret. This lets you test your verification and handler logic without actually creating an order on Shopify.

If the HMAC verification fails with the synthesised payload, double-check that SHOPIFY_WEBHOOK_SECRET in .env.local matches the client secret from the Partner Dashboard. A common mistake is using the API key (public) instead of the client secret (private).

# Trigger a test orders/create webhook to your local dev server
shopify webhook trigger \
  --topic=ORDERS_CREATE \
  --address=https://<your-tunnel>.trycloudflare.com/api/webhooks/shopify \
  --api-version=2024-01

Expected outcome: You see a 200 OK from your handler and a new row in the database (or a log line confirming receipt). The HMAC verification passes.

What's Next

You should also handle the three mandatory GDPR webhooks that Shopify requires from all apps before they can be listed in the App Store: customers/data_request, customers/redact, and shop/redact. For high-volume production apps, consider queuing webhook work onto a background job system rather than processing synchronously in the route handler — Shopify expects a fast 200 response and will retry if your handler takes more than 5 seconds.

Senior Shopify Engineer

Frances Chen

Keep reading