[GH-ISSUE #2501] Stripe webhooks on cloudflare (opennextjs) - No webhook payload was provided #9230

Closed
opened 2026-04-13 04:38:46 -05:00 by GiteaMirror · 12 comments
Owner

Originally created by @zpg6 on GitHub (May 1, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/2501

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Deploy to a opennextjs-cloudflare project with stripe plugin configured and send a webhook request.

Current vs. Expected behavior

I expect that it works out of the box when I configure the webhook in Stripe dashboard with mysite.com/api/auth/stripe/webhook and put STRIPE_WEBHOOK_SECRET and other env vars in.

In my case (opennextjs-cloudflare) Cloudflare worker logs the request body isn't there

[Better Auth]: No webhook payload was provided.

And in Stripe logs I get 400 with:

{
  "code": "WEBHOOK_ERROR_NO_WEBHOOK_PAYLOAD_WAS_PROVIDED",
  "message": "Webhook Error: No webhook payload was provided."
}

When I go to print the ctx I get

{
  headers: {},
  params: {},
  request: {},
  body: {
    data: {
      object: {...}, // The actual object payload
    },
    livemode: false,
    id: "evt_1RJmXVETs...............",
    object: "event",
    api_version: "2025-03-31.basil",
    type: "checkout.session.completed",
    ...
  }
}

What version of Better Auth are you using?

v1.2.8-beta.3

Provide environment information

N/A

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

// stripe.ts
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test", {
    apiVersion: "2025-03-31.basil",
    httpClient: Stripe.createFetchHttpClient(),
});

// in the config
stripePlugin({
    stripeClient: stripe,
    stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
    createCustomerOnSignUp: true,
    onCustomerCreate: async ({ customer, user }) => {
        console.log(`Customer ${customer.id} created for user ${user.id}`);
    },
    subscription: {
        enabled: true,
        plans: plans,
        ...
    },
}),
...

Additional context

No response

Originally created by @zpg6 on GitHub (May 1, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/2501 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce Deploy to a opennextjs-cloudflare project with stripe plugin configured and send a webhook request. ### Current vs. Expected behavior I expect that it works out of the box when I configure the webhook in Stripe dashboard with `mysite.com/api/auth/stripe/webhook` and put `STRIPE_WEBHOOK_SECRET` and other env vars in. In my case (opennextjs-cloudflare) Cloudflare worker logs the request body isn't there ``` [Better Auth]: No webhook payload was provided. ``` And in Stripe logs I get 400 with: ```json { "code": "WEBHOOK_ERROR_NO_WEBHOOK_PAYLOAD_WAS_PROVIDED", "message": "Webhook Error: No webhook payload was provided." } ``` When I go to print the ctx I get ``` { headers: {}, params: {}, request: {}, body: { data: { object: {...}, // The actual object payload }, livemode: false, id: "evt_1RJmXVETs...............", object: "event", api_version: "2025-03-31.basil", type: "checkout.session.completed", ... } } ``` ### What version of Better Auth are you using? v1.2.8-beta.3 ### Provide environment information ```bash N/A ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript // stripe.ts export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "sk_test", { apiVersion: "2025-03-31.basil", httpClient: Stripe.createFetchHttpClient(), }); // in the config stripePlugin({ stripeClient: stripe, stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET, createCustomerOnSignUp: true, onCustomerCreate: async ({ customer, user }) => { console.log(`Customer ${customer.id} created for user ${user.id}`); }, subscription: { enabled: true, plans: plans, ... }, }), ... ``` ### Additional context _No response_
GiteaMirror added the locked label 2026-04-13 04:38:46 -05:00
Author
Owner

@zpg6 commented on GitHub (May 1, 2025):

I was able to get it working (200 on stripe) by changing to this:

let event: Stripe.Event;

if (ctx.body?.object === "event") {
	event = ctx.body;
	console.log("event", event);
}
else {
         // ... how it is now
         const buf = await ctx.request.text();
         const sig = ctx.request.headers.get("stripe-signature") as string;
}

The current logic is looking for ctx.request.text() but somehow that's already JSON decoded ¯\_(ツ)_/¯

Some Explanations:

  1. Cloudflare autodetects this and decodes for us
  2. This is a bug - its decoded elsewhere before this executes
  3. Mystery...

Update:

I also tried enabling requireRequest based on the comments on ctx.request to no effect

cloneRequest: true,
requireRequest: true,
<!-- gh-comment-id:2844129069 --> @zpg6 commented on GitHub (May 1, 2025): I was able to get it working (200 on stripe) by changing to this: ```typescript let event: Stripe.Event; if (ctx.body?.object === "event") { event = ctx.body; console.log("event", event); } else { // ... how it is now const buf = await ctx.request.text(); const sig = ctx.request.headers.get("stripe-signature") as string; } ``` The current logic is looking for `ctx.request.text()` but somehow that's already JSON decoded `¯\_(ツ)_/¯` Some Explanations: 1. Cloudflare autodetects this and decodes for us 2. This is a bug - its decoded elsewhere before this executes 3. Mystery... --- ### Update: I also tried enabling `requireRequest` based on the comments on `ctx.request` to no effect ``` cloneRequest: true, requireRequest: true, ```
Author
Owner

@deestt commented on GitHub (May 18, 2025):

I fixed this bug with a second worker that changed the Content-Type from application/json to text/plain, which allowed me to get plain text, not JSON.
Second worker code (add service binding)

export default {
	async fetch(request, env, ctx): Promise<Response> {

		if (request.method !== 'POST') {
			return new Response('Method not allowed', { status: 405 });
		}

		if (!request.url.endsWith("/api/auth/stripe/webhook")) {
			return new Response('Not found', { status: 404 });
		}

		const signature = request.headers.get('Stripe-Signature');

		if (!signature) {
			return new Response('Missing Stripe-Signature header', { status: 400 });
		}

		const body = await request.text();


		return await env.WORKER.fetch("https://URL/api/auth/stripe/webhook", {
			headers: {
				'Stripe-Signature': signature,
				'Content-Type': 'text/plain',
			},
			method: 'POST',
			body,
		});
	},
} satisfies ExportedHandler<Env>;

@better-auth/stripe code

stripeWebhook: createAuthEndpoint(
        "/stripe/webhook",
        {
          method: "POST",
          metadata: {
            isAction: false
          },
          cloneRequest: true
        },
        async (ctx) => {
          if (!ctx.request?.body) {
            throw new APIError("INTERNAL_SERVER_ERROR");
          }
          const buf = ctx.body;
          const sig = ctx.headers.get("stripe-signature");
          const webhookSecret = options.stripeWebhookSecret;
          let event;
<!-- gh-comment-id:2888962101 --> @deestt commented on GitHub (May 18, 2025): I fixed this bug with a second worker that changed the Content-Type from application/json to text/plain, which allowed me to get plain text, not JSON. Second worker code (add service binding) ``` export default { async fetch(request, env, ctx): Promise<Response> { if (request.method !== 'POST') { return new Response('Method not allowed', { status: 405 }); } if (!request.url.endsWith("/api/auth/stripe/webhook")) { return new Response('Not found', { status: 404 }); } const signature = request.headers.get('Stripe-Signature'); if (!signature) { return new Response('Missing Stripe-Signature header', { status: 400 }); } const body = await request.text(); return await env.WORKER.fetch("https://URL/api/auth/stripe/webhook", { headers: { 'Stripe-Signature': signature, 'Content-Type': 'text/plain', }, method: 'POST', body, }); }, } satisfies ExportedHandler<Env>; ``` @better-auth/stripe code ``` stripeWebhook: createAuthEndpoint( "/stripe/webhook", { method: "POST", metadata: { isAction: false }, cloneRequest: true }, async (ctx) => { if (!ctx.request?.body) { throw new APIError("INTERNAL_SERVER_ERROR"); } const buf = ctx.body; const sig = ctx.headers.get("stripe-signature"); const webhookSecret = options.stripeWebhookSecret; let event; ```
Author
Owner

@zpg6 commented on GitHub (May 18, 2025):

@deestt - I don't love the idea of encoding/decoding the data back and forth unnecessarily if we can do a simple check.

let event: Stripe.Event;

if (ctx.body?.object === "event") {
	event = ctx.body;
	console.log("event", event);
}
else {
         // ... how it is now
         const buf = await ctx.request.text();
         const sig = ctx.request.headers.get("stripe-signature") as string;
}

Since you're also confirming the behavior, I will open as a PR to make this simpler fix.

<!-- gh-comment-id:2889081104 --> @zpg6 commented on GitHub (May 18, 2025): @deestt - I don't love the idea of encoding/decoding the data back and forth unnecessarily if we can do a simple check. ``` let event: Stripe.Event; if (ctx.body?.object === "event") { event = ctx.body; console.log("event", event); } else { // ... how it is now const buf = await ctx.request.text(); const sig = ctx.request.headers.get("stripe-signature") as string; } ``` Since you're also confirming the behavior, I will open as a PR to make this simpler fix.
Author
Owner

@deestt commented on GitHub (May 18, 2025):

And where do you check data validation? In my opinion, this is code that is not safe for production

<!-- gh-comment-id:2889084240 --> @deestt commented on GitHub (May 18, 2025): And where do you check data validation? In my opinion, this is code that is not safe for production
Author
Owner

@zpg6 commented on GitHub (May 19, 2025):

We could add the header check of signature of course, you're right.

But I don't understand what yours is doing... was it better call that is auto-decoding the text into body json?

<!-- gh-comment-id:2890545672 --> @zpg6 commented on GitHub (May 19, 2025): We could add the header check of signature of course, you're right. But I don't understand what yours is doing... was it better call that is auto-decoding the text into body json?
Author
Owner

@deestt commented on GitHub (May 20, 2025):

Because I want to check if the webhook is sent by Stripe and not by other people. In addition Stripe formats JSON in such a way that it is not 1:1 as after the JSON.stringify function

<!-- gh-comment-id:2895872375 --> @deestt commented on GitHub (May 20, 2025): Because I want to check if the webhook is sent by Stripe and not by other people. In addition Stripe formats JSON in such a way that it is not 1:1 as after the JSON.stringify function
Author
Owner

@nextify2025 commented on GitHub (May 25, 2025):

I fixed this bug with a second worker that changed the Content-Type from application/json to text/plain, which allowed me to get plain text, not JSON. Second worker code (add service binding)

export default {
	async fetch(request, env, ctx): Promise<Response> {

		if (request.method !== 'POST') {
			return new Response('Method not allowed', { status: 405 });
		}

		if (!request.url.endsWith("/api/auth/stripe/webhook")) {
			return new Response('Not found', { status: 404 });
		}

		const signature = request.headers.get('Stripe-Signature');

		if (!signature) {
			return new Response('Missing Stripe-Signature header', { status: 400 });
		}

		const body = await request.text();


		return await env.WORKER.fetch("https://URL/api/auth/stripe/webhook", {
			headers: {
				'Stripe-Signature': signature,
				'Content-Type': 'text/plain',
			},
			method: 'POST',
			body,
		});
	},
} satisfies ExportedHandler<Env>;

@better-auth/stripe code

stripeWebhook: createAuthEndpoint(
        "/stripe/webhook",
        {
          method: "POST",
          metadata: {
            isAction: false
          },
          cloneRequest: true
        },
        async (ctx) => {
          if (!ctx.request?.body) {
            throw new APIError("INTERNAL_SERVER_ERROR");
          }
          const buf = ctx.body;
          const sig = ctx.headers.get("stripe-signature");
          const webhookSecret = options.stripeWebhookSecret;
          let event;

I used the same method to forward the request to opennext, but it still returns a 400 error (No webhook payload was provided). Can you guide me? @deestt

<!-- gh-comment-id:2907854885 --> @nextify2025 commented on GitHub (May 25, 2025): > I fixed this bug with a second worker that changed the Content-Type from application/json to text/plain, which allowed me to get plain text, not JSON. Second worker code (add service binding) > > ``` > export default { > async fetch(request, env, ctx): Promise<Response> { > > if (request.method !== 'POST') { > return new Response('Method not allowed', { status: 405 }); > } > > if (!request.url.endsWith("/api/auth/stripe/webhook")) { > return new Response('Not found', { status: 404 }); > } > > const signature = request.headers.get('Stripe-Signature'); > > if (!signature) { > return new Response('Missing Stripe-Signature header', { status: 400 }); > } > > const body = await request.text(); > > > return await env.WORKER.fetch("https://URL/api/auth/stripe/webhook", { > headers: { > 'Stripe-Signature': signature, > 'Content-Type': 'text/plain', > }, > method: 'POST', > body, > }); > }, > } satisfies ExportedHandler<Env>; > ``` > > @better-auth/stripe code > > ``` > stripeWebhook: createAuthEndpoint( > "/stripe/webhook", > { > method: "POST", > metadata: { > isAction: false > }, > cloneRequest: true > }, > async (ctx) => { > if (!ctx.request?.body) { > throw new APIError("INTERNAL_SERVER_ERROR"); > } > const buf = ctx.body; > const sig = ctx.headers.get("stripe-signature"); > const webhookSecret = options.stripeWebhookSecret; > let event; > ``` I used the same method to forward the request to opennext, but it still returns a 400 error (No webhook payload was provided). Can you guide me? @deestt
Author
Owner

@deestt commented on GitHub (May 25, 2025):

Could you send your package.json and do you change Webhook URL in Stripe Dashboard

<!-- gh-comment-id:2908024151 --> @deestt commented on GitHub (May 25, 2025): Could you send your package.json and do you change Webhook URL in Stripe Dashboard
Author
Owner

@nextify2025 commented on GitHub (May 27, 2025):

Could you send your package.json and do you change Webhook URL in Stripe Dashboard

Thank you very much for your reply, I have rolled back to my own implementation.

<!-- gh-comment-id:2912457181 --> @nextify2025 commented on GitHub (May 27, 2025): > Could you send your package.json and do you change Webhook URL in Stripe Dashboard Thank you very much for your reply, I have rolled back to my own implementation.
Author
Owner

@dagmawibabi commented on GitHub (Jul 15, 2025):

@nextify2025 is the issue resolved for you?

<!-- gh-comment-id:3076018764 --> @dagmawibabi commented on GitHub (Jul 15, 2025): @nextify2025 is the issue resolved for you?
Author
Owner

@nextify2025 commented on GitHub (Jul 16, 2025):

@nextify2025 is the issue resolved for you?

Yes, I re-packed it. You can refer to the implementation of that package after my project is open-sourced.

<!-- gh-comment-id:3076357922 --> @nextify2025 commented on GitHub (Jul 16, 2025): > [@nextify2025](https://github.com/nextify2025) is the issue resolved for you? Yes, I re-packed it. You can refer to the implementation of that package after my project is open-sourced.
Author
Owner

@dosubot[bot] commented on GitHub (Oct 15, 2025):

Hi, @zpg6. I'm Dosu, and I'm helping the better-auth team manage their backlog and am marking this issue as stale.

Issue Summary:

  • You reported that Stripe webhooks fail with a "No webhook payload was provided" error due to Cloudflare JSON-decoding the payload before it reaches the Stripe plugin.
  • You proposed a fix to check if the payload is already parsed in ctx.body to avoid redundant decoding.
  • Another user shared a workaround using a second worker to change the Content-Type to text/plain to preserve the raw payload for signature validation.
  • Some users tried the workaround with mixed initial success but eventually resolved their issues.
  • The issue was resolved by implementing your proposed fix and sharing the second worker workaround.

Next Steps:

  • Please confirm if this issue is still relevant with the latest version of better-auth by commenting here.
  • If no further updates are provided, I will automatically close this issue in 7 days.

Thank you for your understanding and contribution!

<!-- gh-comment-id:3407206278 --> @dosubot[bot] commented on GitHub (Oct 15, 2025): Hi, @zpg6. I'm [Dosu](https://dosu.dev), and I'm helping the better-auth team manage their backlog and am marking this issue as stale. **Issue Summary:** - You reported that Stripe webhooks fail with a "No webhook payload was provided" error due to Cloudflare JSON-decoding the payload before it reaches the Stripe plugin. - You proposed a fix to check if the payload is already parsed in `ctx.body` to avoid redundant decoding. - Another user shared a workaround using a second worker to change the Content-Type to `text/plain` to preserve the raw payload for signature validation. - Some users tried the workaround with mixed initial success but eventually resolved their issues. - The issue was resolved by implementing your proposed fix and sharing the second worker workaround. **Next Steps:** - Please confirm if this issue is still relevant with the latest version of better-auth by commenting here. - If no further updates are provided, I will automatically close this issue in 7 days. Thank you for your understanding and contribution!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#9230