How do people handle custom webhooks subscription in the Remix template?

Topic summary

Developers are addressing webhook subscription persistence issues in Shopify Remix apps, particularly when servers restart during local development.

Core Problem:

  • Custom webhook subscriptions (like Google PubSub) created in the after_auth block are lost on server restart
  • Subscriptions only trigger during app installation, not on subsequent server starts
  • Domain URLs change with each restart in local development environments

Solutions Proposed:

Manual Re-subscription Script:

  • Create a command script that deletes previous webhooks and re-subscribes all topics after restart
  • Code example provided showing iteration through stored sessions, deleting existing webhooks, and recreating subscriptions for topics like app/uninstalled, orders/updated, products/update, etc.

Cloudflare Tunnels (Recommended):

  • Use Cloudflare Zero Trust tunnels to maintain static URLs during development
  • Configure unique public hostnames per developer (e.g., conordev.giftcard.wtf) that tunnel to localhost:3000
  • Update shopify.app.toml with static tunnel URLs
  • Eliminates URL changes and webhook reconfiguration needs
  • Free solution that provides seamless workflow

The Cloudflare approach appears to be the preferred long-term solution, avoiding manual webhook management entirely.

Summarized with AI on October 30. AI used: claude-sonnet-4-5-20250929.

Your domain url changes on every server restart and your webhook subscription is only triggered upon installation. This problem only occurs on local development. At the moment, my solution that I could come up with was to have a command script to delete the previous webhook and re-subscribe all webhooks again whenever I restart my server.

import { json } from "@remix-run/node";
import { cors } from "remix-utils/cors";
import { authenticate, unauthenticated } from "../shopify.server";
import dbConnect from "@/db";
import { Session } from "@/modules/session/session.model";

export const loader = async ({ request, response }) => {
    await authenticate.admin(request);    

    return cors(request, response);
}

const webhookTopics = [
  'app/uninstalled',
  'orders/updated',
  // 'orders/create',
  'products/update',
  'products/delete',
  'inventory_levels/update',
  'inventory_items/create',
];

export const action = async ({ request }) => {
  await dbConnect()
    try {
      const storeList = await Session.find()
      for (const store of storeList) {
        const { admin } = await unauthenticated.admin(store.shop);
       
        const subscribedWebhook = await admin.rest.get({
          path: `webhooks.json`
        });
        const subscribedWebhookResponse = await subscribedWebhook.json();
        let deleteWebhook = false

          // Delete Existing Webhook, Temporary comment incase we need to resubscribe
          deleteWebhook = true
          for (const currentWebhook of subscribedWebhookResponse.webhooks) {
            const deletedWebhook = await admin.rest.delete({
              path: `webhooks/${currentWebhook.id}.json`
            });
            const deletedWebhookResponse = await deletedWebhook.json();
            console.log(`Deleted webhook ${currentWebhook.topic}`)
          }

          for (const topic of webhookTopics) {
            let exist = false
            subscribedWebhookResponse.webhooks.map(existingWebhook => {
              if (existingWebhook.topic === topic && deleteWebhook == false) {
                exist = true
              }
            })
            if (exist) {
              console.log(`Skip existing webhook ${topic}`)
              continue
            }

            const webhookEndpoint = `${process.env.URL}/webhooks`;
            const webhookData = {
              webhook: {
                topic: topic,
                address: webhookEndpoint,
                format: 'json',
              },
            };

           
            const response = await admin.rest.post({
              path: `webhooks.json`,
              data: webhookData
            });
            const responseData = await response.json();

            if (response.ok) {
              console.log(`Webhook for ${topic} successfully created with ID: ${responseData.webhook.id}`);
            } else {
              console.error(`Failed to create webhook for ${topic}. Error: ${JSON.stringify(responseData)}`);
            }
          }
      }

      return cors(request, json({ status: 'success', data: "" }));
    } catch (err) {
        console.error(err);
        return cors(request, json({
          status: 'error',
          message: 'error'
        }, {
          status: 500
        }));
    }
};