Francismiko
Francismiko2y ago

What are the best practices for webhooks in actions?

Can I encapsulate the Stripe webhook into my convex Action when I'm using the Stripe integration project? I tried using httpAction before, but encountered verification errors that prevented it from working. Here is the API code in my Next.js application:
27 Replies
Francismiko
FrancismikoOP2y ago
export const config = { api: { bodyParser: false } }

const buffer = async (req: NextApiRequest): Promise<Buffer> => {
const chunks: Buffer[] = []

for await (const chunk of req) {
chunks.push(Buffer.from(chunk))
}

return Buffer.concat(chunks)
}

export default async function handler(
req: NextApiRequest,
res: NextApiResponse<{ invoiceId?: string; error?: string }>,
) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST')
res.status(405).json({ error: 'Method Not Allowed' })
return
}

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string
const sig = req.headers['stripe-signature'] as string

try {
const event = stripe.webhooks.constructEvent(
await buffer(req),
sig,
webhookSecret,
)
if (event.type === 'invoice.payment_succeeded') {
const invId = (event.data.object as { id: string }).id
fetch(`${process.env.CONVEX_SITE}/invoice?invId=${invId}`)
res.status(200).json({ invoiceId: invId })
}
res.status(200).end()
} catch (err) {
res.status(400).json({ error: 'Webhook Error' })
}
}
export const config = { api: { bodyParser: false } }

const buffer = async (req: NextApiRequest): Promise<Buffer> => {
const chunks: Buffer[] = []

for await (const chunk of req) {
chunks.push(Buffer.from(chunk))
}

return Buffer.concat(chunks)
}

export default async function handler(
req: NextApiRequest,
res: NextApiResponse<{ invoiceId?: string; error?: string }>,
) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST')
res.status(405).json({ error: 'Method Not Allowed' })
return
}

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string
const sig = req.headers['stripe-signature'] as string

try {
const event = stripe.webhooks.constructEvent(
await buffer(req),
sig,
webhookSecret,
)
if (event.type === 'invoice.payment_succeeded') {
const invId = (event.data.object as { id: string }).id
fetch(`${process.env.CONVEX_SITE}/invoice?invId=${invId}`)
res.status(200).json({ invoiceId: invId })
}
res.status(200).end()
} catch (err) {
res.status(400).json({ error: 'Webhook Error' })
}
}
Here is my http api code:
http.route({
path: '/invoice',
method: 'GET',
handler: httpAction(async ({ runMutation }, request) => {
const invId = new URL(request.url).searchParams.get('invId')
if (invId) {
await runMutation('xxx:create', {
...
})

return new Response(null, {
status: 200,
})
} else {
return new Response('invId is required', {
status: 400,
})
}
}),
})
http.route({
path: '/invoice',
method: 'GET',
handler: httpAction(async ({ runMutation }, request) => {
const invId = new URL(request.url).searchParams.get('invId')
if (invId) {
await runMutation('xxx:create', {
...
})

return new Response(null, {
status: 200,
})
} else {
return new Response('invId is required', {
status: 400,
})
}
}),
})
My development environment consists of Next.js v12 + Node v20 + Convex v0.16. Invoking the API request returns a 500 status code, along with the following error message:
"error_message": "Uncaught Error: Uncaught Error: Unauthenticated call to mutation\n at handler (../convex/usage.ts:54:4)\n at async invokeMutation (../../node_modules/convex/src/server/impl/registration_impl.ts:52:2)\n"
"error_message": "Uncaught Error: Uncaught Error: Unauthenticated call to mutation\n at handler (../convex/usage.ts:54:4)\n at async invokeMutation (../../node_modules/convex/src/server/impl/registration_impl.ts:52:2)\n"
Additionally, I have implemented relevant validations within the create function.
const identity = await auth.getUserIdentity()
if (!identity) {
throw new Error('Unauthenticated call to mutation')
}
const identity = await auth.getUserIdentity()
if (!identity) {
throw new Error('Unauthenticated call to mutation')
}
Is there a way for me to migrate this webhook API from Next.js to a convex Action, thereby avoiding the use of httpAction?
ari
ari2y ago
Hey @Francismiko, ingesting stripe webhooks with httpAction is totally possible! In this case, it looks like your xxx:create function is expecting auth to be setup, but since this request is coming from stripe, there is no user to authenticate. So, I'd recommend using an internalMutation here instead so it can only be run by other convex functions You can certainly migrate this next function to Convex! I've done this in one of my apps before, I can send you an example in a bit, once I make it into the office
Francismiko
FrancismikoOP2y ago
Thank you very much @ari , and I noticed that the official documentation has an integration usage of ConvexHttpClient with Next.js. I'm not sure if this would also work for my webhook or if the authorization issue would still arise.
ari
ari2y ago
Oh hm I see! Would you like to keep using nextjs for receiving the stripe webhook? You can actually setup convex to replace that nextjs function altogether! Stripe can send the webhook events directly to your convex httpAction, and that action could do the stripe webhook secret validation, and perform all your mutations (Going afk to drive in to work, but will be back in about an hour)
jamwt
jamwt2y ago
@Francismiko essentially, what arnold is suggesting is the http action can be the webhook and authenticate the call from stripe. and then you can call subsequent "internal" mutations without worrying about authentication information tunneling into that layer because convex ensures internal mutations can only be called by your own code, by authorized contexts
jamwt
jamwt2y ago
Francismiko
FrancismikoOP2y ago
Cool! I get it. I can use the convex http action as the endpoint for the webhook, without the need for an additional Next/api. This way, I can avoid the hassle of passing authentication around.
jamwt
jamwt2y ago
you got it
Francismiko
FrancismikoOP2y ago
This is the code after I migrated to http action:
http.route({
path: '/invoice',
method: 'POST',
handler: httpAction(async ({ runMutation }, request) => {
if (request.method !== 'POST') {
return new Response('Method Not Allowed', {
status: 405,
headers: { Allow: 'POST' },
})
}

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string
const sig = request.headers.get('stripe-signature') as string

try {
const event = stripe.webhooks.constructEvent(
await request.text(),
sig,
webhookSecret,
)
if (event.type === 'invoice.payment_succeeded') {
const invId = (event.data.object as { id: string }).id
await runMutation('xxx:create', {
...
})
}
return new Response(null, {
status: 200,
})
} catch (err) {
return new Response('Webhook Error', {
status: 400,
})
}
}),
})
http.route({
path: '/invoice',
method: 'POST',
handler: httpAction(async ({ runMutation }, request) => {
if (request.method !== 'POST') {
return new Response('Method Not Allowed', {
status: 405,
headers: { Allow: 'POST' },
})
}

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string
const sig = request.headers.get('stripe-signature') as string

try {
const event = stripe.webhooks.constructEvent(
await request.text(),
sig,
webhookSecret,
)
if (event.type === 'invoice.payment_succeeded') {
const invId = (event.data.object as { id: string }).id
await runMutation('xxx:create', {
...
})
}
return new Response(null, {
status: 200,
})
} catch (err) {
return new Response('Webhook Error', {
status: 400,
})
}
}),
})
but errors occurred:
✘ [ERROR] Could not resolve "stream"

node_modules/next/dist/compiled/@vercel/og/index.node.js:17949:25:
17949 │ ...rt { Readable } from "stream";
╵ ~~~~~~~~

The package "stream" wasn't found on the file
system but is built into node. Are you trying
to bundle for node? You can use "platform:
'node'" to do that, which will remove this
error.

✘ [ERROR] Could not resolve "fs"

node_modules/next/dist/compiled/@vercel/og/index.node.js:17950:16:
17950 │ import fs2 from "fs";
╵ ~~~~

The package "fs" wasn't found on the file
system but is built into node. Are you trying
to bundle for node? You can use "platform:
'node'" to do that, which will remove this
error.

✘ [ERROR] No loader is configured for ".wasm" files: node_modules/next/dist/compiled/@vercel/og/yoga.wasm?module

node_modules/next/dist/compiled/@vercel/og/index.edge.js:17950:22:
17950 │ ...asm from "./yoga.wasm?module";
╵ ~~~~~~~~~~~~~~~~~~~~

✘ [ERROR] No loader is configured for ".wasm" files: node_modules/next/dist/compiled/@vercel/og/resvg.wasm?module

node_modules/next/dist/compiled/@vercel/og/index.edge.js:17949:23:
17949 │ ...sm from "./resvg.wasm?module";
╵ ~~~~~~~~~~~~~~~~~~~~~
✘ [ERROR] Could not resolve "stream"

node_modules/next/dist/compiled/@vercel/og/index.node.js:17949:25:
17949 │ ...rt { Readable } from "stream";
╵ ~~~~~~~~

The package "stream" wasn't found on the file
system but is built into node. Are you trying
to bundle for node? You can use "platform:
'node'" to do that, which will remove this
error.

✘ [ERROR] Could not resolve "fs"

node_modules/next/dist/compiled/@vercel/og/index.node.js:17950:16:
17950 │ import fs2 from "fs";
╵ ~~~~

The package "fs" wasn't found on the file
system but is built into node. Are you trying
to bundle for node? You can use "platform:
'node'" to do that, which will remove this
error.

✘ [ERROR] No loader is configured for ".wasm" files: node_modules/next/dist/compiled/@vercel/og/yoga.wasm?module

node_modules/next/dist/compiled/@vercel/og/index.edge.js:17950:22:
17950 │ ...asm from "./yoga.wasm?module";
╵ ~~~~~~~~~~~~~~~~~~~~

✘ [ERROR] No loader is configured for ".wasm" files: node_modules/next/dist/compiled/@vercel/og/resvg.wasm?module

node_modules/next/dist/compiled/@vercel/og/index.edge.js:17949:23:
17949 │ ...sm from "./resvg.wasm?module";
╵ ~~~~~~~~~~~~~~~~~~~~~
Can you provide me with some more guidance? Thank you very much!
jamwt
jamwt2y ago
looks like your action is bundling vercel open graph image generation stuff. I'm guessing you probably don't need that in your webhook. maybe @presley has an idea here on how to manage this?
Francismiko
FrancismikoOP2y ago
Ah! I found the cause of this bug. It was due to referencing the stripe object defined in Next.js from within Convex, resulting in an incompatible runtime environment error. After redefining a new stripe object, the bug was resolved. Thank you to the developers of Convex @jamwt @ari for their assistance. If there are any issues regarding webhooks in the future, I will continue to follow up here. 🙂
jamwt
jamwt2y ago
sounds great @Francismiko ! glad to hear you're unblocked and moving forward on your project.
Francismiko
FrancismikoOP2y ago
Can someone give me a sample code about stripe webhook? Because it takes the original object of the request (buffer or string) as a parameter. Something err like this: 400 Bad Request: InvalidModules: Loading the pushed modules encountered the following error: Failed to analyze http.js: Uncaught ReferenceError: Event is not defined at <anonymous> (../node_modules/stripe/esm/StripeEmitter.js:6:16)
Francismiko
FrancismikoOP2y ago
When I refer here: https://discord.com/channels/1019350475847499849/1100122735872577596/1100122735872577596. After I migrated the steps of instantiating stripe to action, new error content appeared:
Discord
Discord - A New Way to Chat with Friends & Communities
Discord is the easiest way to communicate over voice, video, and text. Chat, hang out, and stay close with your friends and communities.
Francismiko
FrancismikoOP2y ago
{
"error_message": "Uncaught Error: Converting circular structure to JSON\n --> starting at object with constructor 'Stripe2'\n | property 'account' -> object with constructor 'Constructor'\n --- property '_stripe' closes the circle\n at stringifyValueForError (../node_modules/convex/src/values/value.ts:375:8)\n at convexToJsonInternal (../node_modules/convex/src/values/value.ts:484:10)\n at convexToJsonInternal (../node_modules/convex/src/values/value.ts:501:15)\n at convexToJson (../node_modules/convex/src/values/value.ts:549:0)\n at invokeAction (../node_modules/convex/src/server/impl/registration_impl.ts:250:0)\n"
}
{
"error_message": "Uncaught Error: Converting circular structure to JSON\n --> starting at object with constructor 'Stripe2'\n | property 'account' -> object with constructor 'Constructor'\n --- property '_stripe' closes the circle\n at stringifyValueForError (../node_modules/convex/src/values/value.ts:375:8)\n at convexToJsonInternal (../node_modules/convex/src/values/value.ts:484:10)\n at convexToJsonInternal (../node_modules/convex/src/values/value.ts:501:15)\n at convexToJson (../node_modules/convex/src/values/value.ts:549:0)\n at invokeAction (../node_modules/convex/src/server/impl/registration_impl.ts:250:0)\n"
}
ari
ari2y ago
Hey, this does look like an odd error! Would you mind sharing the code you have so far? Feel free to DM it to me, but be sure to remove any secrets from the code
Francismiko
FrancismikoOP2y ago
@ari Of course I can! Here is my code in the project: action.ts
export const createStripe = internalAction({
handler: async () => {
return new Stripe(process.env.STRIPE_SECRET_KEY as string, {
apiVersion: '2022-11-15',
})
},
})
export const createStripe = internalAction({
handler: async () => {
return new Stripe(process.env.STRIPE_SECRET_KEY as string, {
apiVersion: '2022-11-15',
})
},
})
http.ts
const http = httpRouter()

http.route({
path: '/invoice',
method: 'POST',
handler: httpAction(async ({ runMutation, runAction }, request) => {
const stripe = await runAction('action:createStripe')
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string
const sig = request.headers.get('stripe-signature') as string

try {
const event = stripe.webhooks.constructEvent(
await request.text(),
sig,
webhookSecret,
)
if (event.type === 'invoice.payment_succeeded') {
const invId = (event.data.object as { id: string }).id
await runMutation('xxx:create', { invoice: invId })
}
return new Response(null, {
status: 200,
})
} catch (err) {
return new Response('Webhook Error', {
status: 400,
})
}
}),
})

export default http
const http = httpRouter()

http.route({
path: '/invoice',
method: 'POST',
handler: httpAction(async ({ runMutation, runAction }, request) => {
const stripe = await runAction('action:createStripe')
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string
const sig = request.headers.get('stripe-signature') as string

try {
const event = stripe.webhooks.constructEvent(
await request.text(),
sig,
webhookSecret,
)
if (event.type === 'invoice.payment_succeeded') {
const invId = (event.data.object as { id: string }).id
await runMutation('xxx:create', { invoice: invId })
}
return new Response(null, {
status: 200,
})
} catch (err) {
return new Response('Webhook Error', {
status: 400,
})
}
}),
})

export default http
At first I thought that the reason for the error was that I did not pass the original object of the request as a parameter to the stripe webhook (http defaults to the Request type of the fetch api? I am not sure if this has any effect) but after debugging, I found that the same error still occurs , so the possibility of this error should be ruled out? By the way, I am using version 0.16 of convex
ari
ari2y ago
Thanks! OK I see what's going on now. the createStripe action returns the stripe class, but you can't exchange classes between convex actions, only data. My suggestion would be to use your HTTP action only for processing the request an response, and pushing all the logic down into a single internalAction The code would roughly look like this:
// http.ts
const http = httpRouter();

http.route({
path: "/invoice",
method: "POST",
handler: httpAction(async ({ runAction }, request) => {
const signature: string = request.headers.get("stripe-signature") as string;

const result = await runAction("actions/stripe:webhook", {
sig: signature,
payload: await request.text(),
});

if (result.success) {
return new Response(null, {
status: 200,
});
} else {
return new Response("Webhook Error", {
status: 400,
});
}
}),
});
// http.ts
const http = httpRouter();

http.route({
path: "/invoice",
method: "POST",
handler: httpAction(async ({ runAction }, request) => {
const signature: string = request.headers.get("stripe-signature") as string;

const result = await runAction("actions/stripe:webhook", {
sig: signature,
payload: await request.text(),
});

if (result.success) {
return new Response(null, {
status: 200,
});
} else {
return new Response("Webhook Error", {
status: 400,
});
}
}),
});
// actions/stripe.ts
export const webhook = internalAction({
handler: async ({ runMutation }, { sig: string, payload: string }) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
apiVersion: "2022-11-15",
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string;
try {
const event = stripe.webhooks.constructEvent(payload, sig, webhookSecret);
if (event.type === "invoice.payment_succeeded") {
const invId = (event.data.object as { id: string }).id;
await runMutation("xxx:create", { invoice: invId });
}
return { success: true };
} catch (err) {
console.error(err);
return { success: false, error: err.message };
}
},
});
// actions/stripe.ts
export const webhook = internalAction({
handler: async ({ runMutation }, { sig: string, payload: string }) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
apiVersion: "2022-11-15",
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET as string;
try {
const event = stripe.webhooks.constructEvent(payload, sig, webhookSecret);
if (event.type === "invoice.payment_succeeded") {
const invId = (event.data.object as { id: string }).id;
await runMutation("xxx:create", { invoice: invId });
}
return { success: true };
} catch (err) {
console.error(err);
return { success: false, error: err.message };
}
},
});
Francismiko
FrancismikoOP2y ago
Awesome! Now all the issues with webhooks are finally resolved perfectly. If in the future we can "use node" in HTTP, then can we migrate the core logic to it without the need to define it in the action (although that way is also concise)?In any case, I'm extremely grateful for @ari assistance!
ari
ari2y ago
Awesome glad it's working! Time to get paid with Stripe 😄
Michal Srb
Michal Srb2y ago
For anyone finding this thread in the future, we put out a Stack article describing an end-to-end Convex integration with Stripe, with a working code repo and demo. https://stack.convex.dev/stripe-with-convex
Wake up, you need to make money! (ft. Stripe)
If you’re building a full-stack app, chances are you’ll want some of your users to pay you for the service you provide. How to use Stripe with Convex ...
Michael Rea
Michael Rea14mo ago
Thanks for the article, I'm most of the way through but stuck on the webhooks. Is there anything else that needs to be done to allow the webhooks to call? It's a 404 from the stipe end
Michael Rea
Michael Rea14mo ago
No description
Michael Rea
Michael Rea14mo ago
No description
Michael Rea
Michael Rea14mo ago
The endpoint shows up in the convex dashboard but it's never been hit I'm probably missing something simple Okay, I figured it out! It wasn't clear on the article that it was meant to point to ".convex.site" I had it pointing to: "convex.cloud/stripe"
ian
ian14mo ago
Thanks for following up!
Michael Rea
Michael Rea14mo ago
No problem, looks like I'm going to be using convex on my Project so happy to post solutions if I find them.

Did you find this page helpful?