Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 42 additions & 3 deletions apps/backend/src/routes/webhook.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,48 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { env } from "../config/env.js";
import { verifyWebhookSignature } from "../lib/signature.js";
import { parseCommand } from "../lib/commands.js";
import { sendText } from "../services/whatsapp.js";
import { handleCommand } from "../services/router.js";
import type { WhatsAppWebhookPayload } from "../types/index.js";

const whatsappInboundMessageSchema = z.object({
from: z.string(),
id: z.string(),
timestamp: z.string(),
type: z.string(),
text: z
.object({
body: z.string(),
})
.optional(),
});

export const whatsappWebhookPayloadSchema = z.object({
object: z.string(),
entry: z
.array(
z.object({
id: z.string(),
changes: z
.array(
z.object({
field: z.string(),
value: z.object({
messaging_product: z.string(),
metadata: z.object({
display_phone_number: z.string(),
phone_number_id: z.string(),
}),
messages: z.array(whatsappInboundMessageSchema).optional(),
}),
}),
)
.optional(),
}),
)
.optional(),
});

/**
* WhatsApp Cloud API webhook.
Expand Down Expand Up @@ -37,7 +75,8 @@ export async function webhookRoutes(app: FastifyInstance) {
return reply.code(401).send({ error: "bad signature" });
}

const payload = request.body as WhatsAppWebhookPayload;
const payload = whatsappWebhookPayloadSchema.parse(request.body);

const messages =
payload.entry?.flatMap(
(entry) => entry.changes?.flatMap((change) => change.value.messages ?? []) ?? [],
Expand All @@ -55,4 +94,4 @@ export async function webhookRoutes(app: FastifyInstance) {
// Always 200 quickly — Meta retries and eventually disables slow webhooks.
return reply.code(200).send({ received: true });
});
}
}
100 changes: 100 additions & 0 deletions apps/backend/test/webhook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app.js";

describe("POST /webhooks/whatsapp payload validation", () => {
it("accepts a valid WhatsAppWebhookPayload", async () => {
const app = buildApp();
try {
const response = await app.inject({
method: "POST",
url: "/webhooks/whatsapp",
payload: {
object: "whatsapp_business_account",
entry: [
{
id: "123",
changes: [
{
field: "messages",
value: {
messaging_product: "whatsapp",
metadata: {
display_phone_number: "123456789",
phone_number_id: "987654321",
},
messages: [
{
from: "15555555555",
id: "wamid.HBgLMTU1NTU1NTU1NTU=",
timestamp: "1672531199",
type: "text",
text: {
body: "balance",
},
},
],
},
},
],
},
],
},
});

expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ received: true });
} finally {
await app.close();
}
});

it("returns 400 when object field is missing", async () => {
const app = buildApp();
try {
const response = await app.inject({
method: "POST",
url: "/webhooks/whatsapp",
payload: {
entry: [],
},
});

expect(response.statusCode).toBe(400);
expect(response.json().error).toBeDefined();
} finally {
await app.close();
}
});

it("returns 400 when metadata fields are missing in changes", async () => {
const app = buildApp();
try {
const response = await app.inject({
method: "POST",
url: "/webhooks/whatsapp",
payload: {
object: "whatsapp_business_account",
entry: [
{
id: "123",
changes: [
{
field: "messages",
value: {
messaging_product: "whatsapp",
// metadata is missing
},
},
],
},
],
},
});

expect(response.statusCode).toBe(400);
expect(response.json().error).toBeDefined();
} finally {
await app.close();
}
});
});
Loading