Billing Redirect Not Working On Remix - 401 Error using "billing.request()"

Topic summary

A developer encounters a 401 Unauthorized error when attempting to redirect users to Shopify’s billing approval page using billing.request() in a Remix app. The issue surfaced when an app reviewer couldn’t access upgrade/downgrade functionality, despite it working on test stores.

Key Technical Details:

  • Error occurs when billing.require() is called with test: false
  • Server logs show “401 Unauthorized” response during billing request
  • Paradoxically, subscription webhooks indicate successful plan activation despite the failed redirect

Proposed Solution:
A community member identified the root cause: wrapping billing.require() in a try-catch block prevents redirect responses from being thrown properly. The fix involves rethrowing the error after logging:

catch (error) {
  console.log('error billing', error);
  throw error; // Critical: rethrow to allow redirects
}

Current Status:
The discussion remains open with one other developer reporting the same issue. The suggested workaround addresses redirect handling but doesn’t fully explain the 401 error or the webhook activation paradox.

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

I’m using RemixJS, the latest versions of** @Shopify_77 /shopify-app-remix**

I first noticed this error when the app reviewer declined my app since I didn’t have proper “Upgrade/Downgrade” buttons. I was very confused since this works flawlessly on the test store. I even provided a full video of it working, twice.

After debugging logs, I couldn’t find ANYTHING.

I have two plans declared on my “shopify.server.ts”. under the billing object which are available on “availablePlans”. and “isBillingTest()” just checks my .env variable for TEST_BILLING === ‘true’

“test: false” in this case right now.

When I click “Subscribe”, my code executes here:

export const action = async ({ request }: ActionFunctionArgs) => {
  const adminContext = await authenticate.admin(request);
  const { billing, session } = adminContext;

const { hasActivePayment, appSubscriptions } = await billing.check({
    plans: availablePlans,
    isTest: isBillingTest(),
  });

if (!hasActivePayment) {
try { 
await billing.require({
        plans: availablePlans,
        onFailure: async (error) => {
          console.error('Billing require failed:', error);
          console.log('Attempting to redirect to billing page for plan:', plan);
          return billing.request({
            plan: plan.toString() as PlanName,
            isTest: isBillingTest(),
            returnUrl: createReturnUrl(session.shop),
          });
        },
      });    
} catch ( error )  {
   console.log('error billing', error );
}
}

and this is the error:

Response {
  size: 0,
  [Symbol(Body internals)]: {
    body: null,
    type: null,
    size: 0,
    boundary: null,
    disturbed: false,
    error: null
  },
  [Symbol(Response internals)]: {
    url: undefined,
    status: 401,
    statusText: 'Unauthorized',
    headers: {
      'x-shopify-api-request-failure-reauthorize-url': 'https://xxxxxx.myshopify.com/admin/charges/123345/21345/RecurringApplicationCharge/confirm_recurring_application_charge?signature=BAh7BzoHaWRsKwg'
    },
    counter: 0,
    highWaterMark: undefined
  }
}

It’s very weird. Why am I getting 401 errors? I wonder if this what the reviewer ran into too.

Although, when I change test: true then I can make successful tests.

Any ideas would be greatly appreciated.

Here is another clue for the Shopify team: On this app, I always had it set to billing test: false. I decided to run a “test payment” (because I’m like "why was the reviewer getting an error? Maybe something on production?), so I changed test: true. Subscription worked. No issues. Webhooks ran.

I then changed it BACK to test: false. I go to subscribe, and I’m expecting it to redirect to the charges URL, but no, 401. This error only started happening AFTER I changed it from the original test: false → true → false.

Now, I don’t know what else to do. I’m afraid the reviewer is going to look again and say it failed. This seems to be on the Shopify side I’m assuming. If I manually enter that URL from the response “x-shopify-api-request-failure-reauthorize-url” then it does go to the Approval Page.

Here is the Shopify App Reviewer getting a 401. This is from my server logs. Redacted information.

[shopify-app/INFO] Requesting billing | {shop: 1234.myshopify.com, plan: Starter Plan, isTest: false, returnUrl: https://admin.shopify.com/store/1234/apps/my-app/app/billing}
POST /app/billing?_data=routes%2Fapp.billing 401 - - 735.172 ms

But somehow after this the webhook ran for subscription? How is this possible if they didn’t get redirected to an approval page?

app subscription {
  admin_graphql_api_id: 'gid://shopify/AppSubscription/1234',
  name: 'Pro Plan',
  status: 'ACTIVE',
  admin_graphql_api_shop_id: 'gid://shopify/Shop/1234',
  created_at: '2025-07-03T20:35:51+08:00',
  updated_at: '2025-07-03T20:35:57+08:00',
  currency: 'USD',
  capped_amount: null,
  price: '49.00',
  interval: 'every_30_days',
  plan_handle: null
}
POST /webhooks/app/subscriptions_update 200 - - 290.793 ms

I am also running into this, have you managed to solve it?

I had a similar issue caused by wrapping billing.requirein a try-catch statement. The issue is that sometimes billing.require throws a redirect, which if you catch (and log) won’t get thrown.

The key I think is to rethrow inside your catch statement:

try { 
    await billing.require({
        plans: availablePlans,
        onFailure: async (error) => {
          console.error('Billing require failed:', error);
          console.log('Attempting to redirect to billing page for plan:', plan);
          return billing.request({
            plan: plan.toString() as PlanName,
            isTest: isBillingTest(),
            returnUrl: createReturnUrl(session.shop),
          });
        },
    });    
} catch ( error )  {
    console.log('error billing', error );
    // re-throw error in case it's a redirect
    throw error;
}

Now, I totally acknowledge this is not ideal. What would be better is to work out whether error is (redirect) response, or an error. If it’s the latter, log etc. If it’s the former, rethrow so the user ends up on the right page.

This might be related to the 401 auth error.