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.
- 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-Versionheader is answered in 2021-01 (Recharge, API versions). A webhook has aversionof 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 hassubscription_idandtypeis"CHECKOUT"or"RECURRING". In 2021-11, a line item haspurchase_item_idwithpurchase_item_type: "subscription", andtypeis"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. - 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. Itsx-recharge-versionheader said2021-01all along. - 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.
- 04
A serverless function can stop when it replies
On Vercel, a function that sends its response and then carries on with
awaitmay 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’swaitUntil()“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
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
Find out which version your webhook sends
List your webhooks with
GET /webhooksand read each one’sversion, or log thex-recharge-versionheader on a real delivery. When we created ours, sending 2021-11, it came back as 2021-01, so setversionin the request body and check the answer rather than the request.Read the body in that version
Either write your code for the shape the webhook sends, or create the webhook again with
versionset to the version your code reads, and check the first real delivery before you trust it. We kept 2021-01 and read its fields.Compare names without case
Fold
typeto lower case before comparing it, so"CHECKOUT"and"checkout"both count.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(). SetmaxDurationto cover it.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.
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-Signatureheader for new integrations and the olderX-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()fromnext/serverinstead ofwaitUntil().
03Read the subscription back
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 thatvercel logsdidn’t show every line our function wrote after the reply: one order had no log lines at all, and its subscription had our date.Check a recurring order is left alone
The next renewal should arrive as
RECURRING(orrecurring) and be skipped.Check the webhook is still registered
GET /webhooksshould 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
typewith"checkout"; the webhook sent"CHECKOUT". - Every write refused (fixed 27 May). The endpoint in our own plan was wrong:
/charge_datereturned 404. The reference has/set_next_charge_date, with the field nameddate. - 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.
- Recharge: Using webhooks (timeout and retries; updated Jun 2025)
- Recharge: API versions
- Recharge: Webhook examples 2021-01
- Recharge: Webhook examples 2021-11
- Recharge API reference (2021-11): Create a webhook, and webhook validation
- Recharge API reference (2021-11): Change a subscription next charge date
- Vercel: @vercel/functions API reference (waitUntil)
- Vercel Community: Running a background job after an API response
Sources checked on .
