Fixes · Recharge webhooks

Recharge webhook returns 200 but the subscription never changes

The fix

Check which API version the webhook sends. Its body follows the webhook’s own version, which falls back to the store’s default, often 2021-01, where an order’s line items carry subscription_id and its type is "CHECKOUT". Code written against the 2021-11 API reads neither.

Reply 200 within Recharge’s 5 seconds, then make the change. On Vercel, put that work inside waitUntil(), or it can stop as soon as the reply goes out.

01Four ways a 200 changes nothing

A 200 tells Recharge the webhook was delivered. It won’t send it again, and nothing in Recharge shows the change that didn’t happen. Each of these let our handler answer 200 and change nothing.

  1. 01

    The body is in a different API version from your code

    Recharge has two API versions, 2021-01 and 2021-11, and a request without the X-Recharge-Version header is answered in 2021-01 (Recharge, API versions). A webhook has a version of its own: “The version of the API used to populate the body of the webhook. If not set, this will default to the store’s default API version” (Recharge API reference, webhooks). Recharge’s own examples show the difference in an order. In 2021-01, each line item has subscription_id and type is "CHECKOUT" or "RECURRING". In 2021-11, a line item has purchase_item_id with purchase_item_type: "subscription", and type is "checkout" or "recurring". Read one shape from a body in the other and every order looks like it has no subscription, or isn’t a first order.

  2. 02

    The test webhook can’t show you the shape

    Recharge’s test call reached our endpoint with an empty body, {}. So we took the shape from an order fetched through the API in 2021-11, while the webhook was sending 2021-01. Its x-recharge-version header said 2021-01 all along.

  3. 03

    A reply after 5 seconds is a failure

    “Recharge’s server waits 5 seconds for a response. If we do not receive a response in that period we consider the request failed.” Recharge then tries again, 20 times over 48 hours, and deletes the webhook if every try fails (Recharge, webhooks overview). Waiting before you answer, to let another integration write first, hits this every time.

  4. 04

    A serverless function can stop when it replies

    On Vercel, a function that sends its response and then carries on with await may never finish the rest. This Vercel Community thread describes it: “the moment the response is sent, the process is killed and the job is never completed”. Vercel’s waitUntil() “Extends the lifetime of the request handler for the lifetime of the given Promise”, with the same timeout as the function itself (Vercel, @vercel/functions).

02Read the right fields, reply, then write

For store owners

  1. Check one real order by hand

    After an order that should have changed a subscription, open that customer’s subscription in Recharge and look at the field the integration was meant to set, such as the next charge date. If it’s unchanged, send this page to whoever built the integration.

For developers

  1. Find out which version your webhook sends

    List your webhooks with GET /webhooks and read each one’s version, or log the x-recharge-version header on a real delivery. When we created ours, sending 2021-11, it came back as 2021-01, so set version in the request body and check the answer rather than the request.

  2. Read the body in that version

    Either write your code for the shape the webhook sends, or create the webhook again with version set to the version your code reads, and check the first real delivery before you trust it. We kept 2021-01 and read its fields.

  3. Compare names without case

    Fold type to lower case before comparing it, so "CHECKOUT" and "checkout" both count.

  4. Answer first, then do the work inside waitUntil

    Do the quick checks, reply 200, and pass the slow part (a wait, the API call) to waitUntil(). Set maxDuration to cover it.

  5. Check the answer to your own write

    After the 200, Recharge won’t retry for you. Log the status of your API call, and alert or retry on anything but success.

Reply fast, write after (Vercel, Node, TypeScript)ts
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { waitUntil } from '@vercel/functions';

export const config = { maxDuration: 60 };

export default async function handler(req: VercelRequest, res: VercelResponse) {
  // Check the signature first (see the note below).
  const order = req.body?.order;

  // A 2021-01 body: 'CHECKOUT' / 'RECURRING', subscription_id on each line.
  if (!order || String(order.type ?? '').toLowerCase() !== 'checkout') {
    res.status(200).json({ ok: true, skipped: 'not_checkout' });
    return;
  }
  // Some orders carry ids on one-off lines too: see "What we did" below.
  const line = (order.line_items ?? []).find((li: { subscription_id?: number | null }) => typeof li.subscription_id === 'number');
  if (!line) {
    res.status(200).json({ ok: true, skipped: 'no_subscription' });
    return;
  }

  const date = chooseDate(order); // your own rule, as 'YYYY-MM-DD'
  res.status(200).json({ ok: true }); // well inside Recharge's 5 seconds

  waitUntil((async () => {
    const response = await fetch(
      'https://api.rechargeapps.com/subscriptions/' + line.subscription_id + '/set_next_charge_date',
      {
        method: 'POST',
        headers: {
          'X-Recharge-Access-Token': process.env.RECHARGE_API_TOKEN ?? '',
          'X-Recharge-Version': '2021-11',
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ date }),
      },
    );
    if (!response.ok) {
      console.error('Recharge refused the change', response.status, await response.text());
    }
  })());
}
  • The endpoint for a new charge date is POST /subscriptions/{id}/set_next_charge_date, with a body of {"date": "YYYY-MM-DD"} (Recharge API reference).
  • Check the signature before you act on the body. Webhooks created through the API are signed with your API client secret, which is not your API token; Recharge’s reference describes a timestamped X-Recharge-Webhook-Signature header for new integrations and the older X-Recharge-Hmac-Sha256.
  • Expect the same webhook more than once. Recharge’s overview says to make your handling idempotent: the same order should lead to the same change, however often it arrives.
  • If you run on Next.js 15.1 or later, Vercel recommends after() from next/server instead of waitUntil().

03Read the subscription back

  1. Check the subscription, not the logs

    After a real order, fetch the subscription with GET /subscriptions/{id} and read the field you set. On Vercel we found that vercel logs didn’t show every line our function wrote after the reply: one order had no log lines at all, and its subscription had our date.

  2. Check a recurring order is left alone

    The next renewal should arrive as RECURRING (or recurring) and be skipped.

  3. Check the webhook is still registered

    GET /webhooks should still list it. One that failed every retry is deleted.

From our work

04Four bugs in one week on Fuzzball

Fuzzball, the cat food subscription whose quiz and subscription services we built, lets a new subscriber choose the day of their first delivery. The date rides on the order as a hidden cart attribute, and a small function on Vercel listens for Recharge’s order/created webhook and sets the subscription’s next charge date from it. We ran it first in a mode that logged what it would do without writing, from 20 May 2026. Every one of these came up in a single week:

  • Every order skipped (fixed 25 May). The code read 2021-11 line item fields from a 2021-01 body, so no order seemed to have a subscription.
  • Every first order skipped (fixed 26 May). It compared type with "checkout"; the webhook sent "CHECKOUT".
  • Every write refused (fixed 27 May). The endpoint in our own plan was wrong: /charge_date returned 404. The reference has /set_next_charge_date, with the field named date.
  • Every write lost (fixed 28 May). An older integration also set the date on new orders, so ours had to write last. Waiting 30 seconds before replying ran past Recharge’s timeout: Recharge dropped the connection and tried again later, and none of our writes landed. Replying first and waiting afterwards didn’t work either, until the wait and the write went inside waitUntil().

One more shape detail: on trial orders, single items carried a subscription_id each that didn’t exist as a subscription in Recharge. A real box shares one id across all its lines, so the handler takes the id that appears on two or more lines.

The older integration was switched off late on 28 May, and the first four new subscriptions after that all landed on the date our handler set: the order day plus 12, the default. The handler’s code has not changed since 28 May (its git history, checked 23 Oct 2026).

  • In our log“The first delivery date a new subscriber picks now reaches their subscription”

The bigger picture

05Webhooks that fail in silence

A webhook that answers 200 and does nothing fails in silence: Recharge thinks it delivered, your logs think it ran, and subscribers find out on the wrong delivery day. Anything that writes to subscriptions needs a check on the subscription itself, not only on the handler.

Subscription changes like this one, built and checked against real orders, are what our subscriptions work covers.

06Sources

Every step and claim above comes from one of these, or from the fix on the store it describes.

  1. Recharge: Using webhooks (timeout and retries; updated Jun 2025)
  2. Recharge: API versions
  3. Recharge: Webhook examples 2021-01
  4. Recharge: Webhook examples 2021-11
  5. Recharge API reference (2021-11): Create a webhook, and webhook validation
  6. Recharge API reference (2021-11): Change a subscription next charge date
  7. Vercel: @vercel/functions API reference (waitUntil)
  8. Vercel Community: Running a background job after an API response

Sources checked on .

Contact

Subscriptions not doing what they should?

Tell us which app runs them, and what should change on each new order.