Building a Webhook System for Shopify Order Automation

Webhooks sound simple. Shopify sends a POST request when something happens, you handle it. In practice, building a reliable webhook system that handles payments, upgrades, cancellations, and edge cases without losing data or duplicating actions is a real engineering challenge.

I built the webhook system for RAXXO Studio's Shopify integration, and here's everything that went wrong before it went right.

The Basic Setup

In a Next.js App Router project, your webhook endpoint is a route handler:

// app/api/shopify/webhook/route.ts
export async function POST(request: Request) {
  const body = await request.text();
  const hmac = request.headers.get('x-shopify-hmac-sha256');

  // Verify signature
  if (!verifyWebhook(body, hmac)) {
    return new Response('Unauthorized', { status: 401 });
  }

  const data = JSON.parse(body);
  // Handle the event
  return new Response('OK', { status: 200 });
}

HMAC Verification: Non-Negotiable

Every webhook must be verified. Shopify signs each request with your webhook secret using HMAC-SHA256. If the signature doesn't match, reject it. No exceptions.

import { createHmac } from 'crypto';

function verifyWebhook(body: string, hmac: string | null): boolean {
  if (!hmac || !process.env.SHOPIFY_WEBHOOK_SECRET) return false;
  const hash = createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET)
    .update(body, 'utf8')
    .digest('base64');
  return hash === hmac;
}

Important: you must verify against the raw body string, not a parsed JSON object. Parsing and re-stringifying changes whitespace, which changes the hash. Read the body as text first, verify, then parse.

What Events to Subscribe To

For a SaaS with Shopify-managed subscriptions, these are the events that matter:

  • orders/paid: Customer completed a purchase. Upgrade their plan.
  • orders/cancelled: Order was cancelled. Downgrade or revert.
  • orders/updated: Order details changed. Check if plan needs adjustment.
  • refunds/create: Refund issued. Downgrade the plan.

Register webhooks through the Shopify admin or API. The endpoint URL must be publicly accessible - Shopify can't reach localhost. For development, use a tunneling service or deploy to your dev environment first.

The URL Gotcha

Here's something that cost me hours: Shopify blocks webhook URLs that point to its own domains. If your app is on a .myshopify.com domain, the webhook can't point there. Use your Vercel deployment URL (.vercel.app) or a custom domain.

Idempotency: Handle Duplicates

Shopify may send the same webhook multiple times. Network hiccups, retries after timeouts, or your server returning a 500 all trigger retries. Your handler must be idempotent - processing the same event twice should produce the same result as processing it once.

The simplest approach: check if the action has already been taken before taking it. Before upgrading a user to Flame, check if they're already on Flame. Before creating a stub user, check if one exists for that email.

The User Matching Problem

When a Shopify order comes in, you get the customer's email. But your app users are identified by Clerk IDs. The matching logic:

  1. Look up existing user by email
  2. If found: update their plan
  3. If not found: create a "stub" user with the email and plan. When they sign up for the app later, the auth system claims this stub by email and they automatically get their purchased plan.

This handles the case where someone buys a plan before creating an account. It's a common flow - they see the pricing page, buy on Shopify, then come back to sign up.

Plan Identification from Orders

Each Shopify product maps to a plan. We identify the plan from the product handle in the order line items:

function getPlanFromOrder(order) {
  for (const item of order.line_items) {
    if (item.product_id === FLAME_PRODUCT_ID) return 'flame';
    if (item.product_id === BLAZE_PRODUCT_ID) return 'blaze';
    if (item.product_id === NEON_PRODUCT_ID) return 'neon';
  }
  return null;
}

Subscription Expiry

Monthly subscriptions need expiry tracking. When a plan is activated, set a planExpiresAt date 35 days in the future (5-day grace period beyond the monthly billing cycle). On each login or API call, check if the plan has expired. If so, downgrade to free.

When Shopify processes a recurring payment, the orders/paid webhook fires again, extending the expiry date for another 35 days. This handles the renewal cycle automatically.

Error Handling and Logging

Webhook endpoints should always return 200 quickly, even if processing takes time. If you return 5xx, Shopify retries with increasing delays. If you consistently fail, Shopify disables the webhook.

Log everything. Every webhook received, every action taken, every error encountered. When a customer says "I paid but my account isn't upgraded," you need those logs to debug the issue.

Testing Without Real Orders

Shopify has a test mode for payments, but you can also test webhooks by sending manual POST requests with the correct HMAC signature. Create a test script that simulates order events against your endpoint.

For RAXXO Studio, I tested every scenario: new purchase, repeat purchase, cancellation, refund, purchase by existing user, purchase by new email, and rapid consecutive webhooks. Each one exposed a different edge case.

The RAXXO Studio webhook system handles all plan management automatically. Plans start at free - try it at studio.raxxo.shop.

Dieser Artikel enthält Affiliate-Links. Wenn du dich darüber anmeldest, erhalte ich eine kleine Provision - für dich entstehen keine Mehrkosten. Ich empfehle nur Tools, die ich selbst nutze. (Werbung)