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.

I’m able to successfully create a Google PubSub subscription in the after_auth block in shopify.server.js.

However, the issue is that the subscription is lost if I restart my server for any reason.

I would like to be able to register subscriptions when the server starts and not have to refresh a page or re-install the app.

Is there a way to register code somewhere that runs only once when the server starts or does anyone have any recommendations for dealing with this type of thing?

Thanks.

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
        }));
    }
};

Check out this solution:
https://giftcardreminders.com/blog/how-we-simplified-shopify-remix-development-with-cloudflare-tunnels

Setting Up Cloudflare Tunnels

Here’s the process we followed:

  1. Create a tunnel in Cloudflare Zero Trust

We chose the option of using Cloudflared because we both work on MacOS.

  1. Configure the tunnel

We created a public hostname unique to each developer and configured the local address to tunnel traffic to:

Now, with the agent running on our computers, traffic automatically forwards to our local development server with a static URL — and it’s completely free.

Shopify App Configuration

On the Shopify side, we only needed to make a few adjustments to our shopify.app.toml file to reflect the static tunnel URLs. Here’s what it looks like now:

client_id = “”
name = “GiftCardReminders-Conor”
handle = “giftcardreminders-conor”
application_url = “https://conordev.giftcard.wtf
embedded = true

[build]
automatically_update_urls_on_dev = true
dev_store_url = “conor-dev-shop.myshopify.com
include_config_on_deploy = true

[access_scopes]
scopes = “read_customers,read_gift_cards,read_orders,read_products,read_publications,read_themes,write_customers,write_files,write_gift_cards,write_products”

[auth]
redirect_urls = [
https://conordev.giftcard.wtf/auth/callback”,
https://conordev.giftcard.wtf/auth/shopify/callback”,
https://conordev.giftcard.wtf/api/auth/callback
]

[webhooks]
api_version = “2024-04”

[[webhooks.subscriptions]]
topics = [ “app/uninstalled”, “orders/paid” ]
uri = “/webhooks”

[pos]
embedded = false

Once we updated the shopify.app.toml, we did a quick shopify app deploy and started our app using the following command:

shopify app dev --tunnel-url=“https://conordev.giftcard.wtf:3000

A seamless developer workflow

That’s it! Now, whenever Simon or I want to work on GiftCardReminders, all we have to do is run shopify app dev — no more fiddling with URLs or reconfiguring webhooks. Everything just works, and our development flow is smoother than ever.

If you’re facing similar headaches with Shopify app development, we hope this solution saves you some time and sanity.