Receiving webhook in a worker and sending it to the frontend?

Hey dear Cloudflare dev's, I receive a webhook from Mollie and want to forward its data to my frontend. As I understand, since workers are stateless, I can't share variables between functions eg. a global let webhook-data variable. My current approach stores the data in Cloudflare KV and then polls it to send via websockets or server-sent events, which feels overly complex and has it's own pitfalls. While Durable Objects offer in-memory state, I’m trying to keep costs low and it seems a large solution for what feels to be a small problem. Are there any suggestions how to best solve this? Thanks in advance! See the snippit below for an example of the above case:
/// server
app.post('/mollie-webhook', async (context) => {
const payload = await context.req.text()
console.log('Received webhook data:', payload)

await context.env.recurify.put('latest-payment', payload)
return context.text('Webhook received')
})

app.get('/mollie-payment-sse', async (c) => {
c.header('Content-Encoding', 'Identity');

return streamSSE(c, async (stream: SSEStreamingApi) => {
let lastEventId: string | null = null;

while (true) {
await stream.sleep(1000);

const storedEvent = await c.env.recurify.get('latest-payment');
if (storedEvent) {
const eventObj = JSON.parse(storedEvent);
if (eventObj.id !== lastEventId) {
lastEventId = eventObj.id;
await stream.writeSSE({
event: 'new-payment-update',
data: eventObj.data,
id: eventObj.id,
});
}
}
}
});
});
/// server
app.post('/mollie-webhook', async (context) => {
const payload = await context.req.text()
console.log('Received webhook data:', payload)

await context.env.recurify.put('latest-payment', payload)
return context.text('Webhook received')
})

app.get('/mollie-payment-sse', async (c) => {
c.header('Content-Encoding', 'Identity');

return streamSSE(c, async (stream: SSEStreamingApi) => {
let lastEventId: string | null = null;

while (true) {
await stream.sleep(1000);

const storedEvent = await c.env.recurify.get('latest-payment');
if (storedEvent) {
const eventObj = JSON.parse(storedEvent);
if (eventObj.id !== lastEventId) {
lastEventId = eventObj.id;
await stream.writeSSE({
event: 'new-payment-update',
data: eventObj.data,
id: eventObj.id,
});
}
}
}
});
});
3 Replies
DwarfNinja
DwarfNinjaOP3d ago
@Leo As mentioned I'd rather use an alternative if possible as it seems that purchasing Workers Pro seems overkill for this problem? Do you have any other suggestions? For the current scope of the project yes, while it seems the're should be an alternative or is Durable Objects the only solution?
Hard@Work
Hard@Work2d ago
I mean, theoretically, this could work, assuming that Mollie never sends more than one webhook per minute. And assuming that you aren't unlucky and accidentally get a cached read two times in a row. And assuming that the Worker doesn't get evicted. As Leo pointed out, Durable Objects are a much better option for this kind of thing
DwarfNinja
DwarfNinjaOP2d ago
@Leo & @Hard@Work thank you for your responses. You have one me over and I started with Durable Objects today. Im trying to learn how they work and currently got this implementation. The problem im now facing is is that the webhookdata is null when retrieved in the stream. Stub WebhookData: null What am I missing here? Is the Durable Object disposed in between /mollie-webhook and /mollie-payment-sse? Thanks in advance!
//index.ts
app.post('/mollie-webhook', async (context, env) => {
const payload = await context.req.text()
console.log('Received webhook data:', payload)

const id = context.env.DURABLE_WEBHOOK.idFromName("durable_webhook");
const stub = context.env.DURABLE_WEBHOOK.get(id);

const rpcResponse = await stub.storeWebhookData(payload);
console.log(rpcResponse);

return context.text('Webhook received')
})

app.get('/mollie-payment-sse', async (c) => {

return streamSSE(c, async (stream: SSEStreamingApi) => {
const id = c.env.DURABLE_WEBHOOK.idFromName("durable_webhook");
const stub = c.env.DURABLE_WEBHOOK.get(id);

while (true) {
await stream.sleep(1000);

const webhookData = await stub.getWebhookData();
console.log(`Stub WebhookData: ${webhookData}`);

if (webhookData) {
await stream.writeSSE({
event: "new-payment-update",
data: webhookData
});

await stub.clearWebhookData();
}
}
});
});
//index.ts
app.post('/mollie-webhook', async (context, env) => {
const payload = await context.req.text()
console.log('Received webhook data:', payload)

const id = context.env.DURABLE_WEBHOOK.idFromName("durable_webhook");
const stub = context.env.DURABLE_WEBHOOK.get(id);

const rpcResponse = await stub.storeWebhookData(payload);
console.log(rpcResponse);

return context.text('Webhook received')
})

app.get('/mollie-payment-sse', async (c) => {

return streamSSE(c, async (stream: SSEStreamingApi) => {
const id = c.env.DURABLE_WEBHOOK.idFromName("durable_webhook");
const stub = c.env.DURABLE_WEBHOOK.get(id);

while (true) {
await stream.sleep(1000);

const webhookData = await stub.getWebhookData();
console.log(`Stub WebhookData: ${webhookData}`);

if (webhookData) {
await stream.writeSSE({
event: "new-payment-update",
data: webhookData
});

await stub.clearWebhookData();
}
}
});
});
//durable-webhook.ts
import { DurableObject } from "cloudflare:workers";
import {Env} from "hono";

export class DurableWebhook extends DurableObject {
webhookData: string | null = null;

constructor(state: DurableObjectState, env: Env) {
super(state, env);
}

// Stores incoming webhook data
async storeWebhookData(webhookData: string): Promise<string> {
this.webhookData = webhookData;
return "Webhook data stored";
}

// Returns the stored webhook data (if any)
async getWebhookData(): Promise<string | null> {
return this.webhookData;
}

// Clears the stored webhook data
async clearWebhookData(): Promise<void> {
this.webhookData = null;
}
}
//durable-webhook.ts
import { DurableObject } from "cloudflare:workers";
import {Env} from "hono";

export class DurableWebhook extends DurableObject {
webhookData: string | null = null;

constructor(state: DurableObjectState, env: Env) {
super(state, env);
}

// Stores incoming webhook data
async storeWebhookData(webhookData: string): Promise<string> {
this.webhookData = webhookData;
return "Webhook data stored";
}

// Returns the stored webhook data (if any)
async getWebhookData(): Promise<string | null> {
return this.webhookData;
}

// Clears the stored webhook data
async clearWebhookData(): Promise<void> {
this.webhookData = null;
}
}
EDIT SOLVED ✅: Got it to work by using Durable Object Storage to store the received webhook data during the /mollie-webhook call and later retrieve it in /mollie-payment-sse. I assume it's due to that the Durable Object is destroyed after it is used in /mollie-webhook which means it loses it's state, but I'll need someone to confirm that. May I ask why it is better for the DO to handle the connection? How would I migratie the functionality into the DO? Can I just pretty much copy paste the app.post and app.get code? (ps. im using hono) Thank you in advance!

Did you find this page helpful?