fix(webhook): handle Saleor legacy webhook payload format with snake_case fields
Some checks failed
Build and Deploy / build (push) Has been cancelled

Saleor sends webhook payloads as arrays with snake_case fields:
- user_email instead of userEmail
- billing_address instead of billingAddress
- total_gross_amount instead of total.gross.amount
- etc.

Added convertPayloadToOrder() function to transform snake_case payload
to camelCase format expected by our email templates.
This commit is contained in:
Unchained
2026-03-25 15:22:40 +02:00
parent e08e919e83
commit c9aaacc452

View File

@@ -16,12 +16,49 @@ interface SaleorWebhookHeaders {
"saleor-api-url": string;
}
// Saleor sends snake_case in webhook payloads
interface SaleorLineItemPayload {
id: string;
product_name: string;
variant_name?: string;
quantity: number;
total_price_gross_amount: string;
currency: string;
}
interface SaleorAddressPayload {
first_name?: string;
last_name?: string;
street_address_1?: string;
street_address_2?: string;
city?: string;
postal_code?: string;
country?: string;
phone?: string;
}
interface SaleorOrderPayload {
id: string;
number: number;
user_email: string;
first_name?: string;
last_name?: string;
billing_address?: SaleorAddressPayload;
shipping_address?: SaleorAddressPayload;
lines: SaleorLineItemPayload[];
total_gross_amount: string;
shipping_price_gross_amount?: string;
currency: string;
language_code?: string;
metadata?: Record<string, string>;
}
// Internal camelCase interfaces for our code
interface SaleorLineItem {
id: string;
productName: string;
variantName?: string;
quantity: number;
quantityUnit?: string;
totalPrice: {
gross: {
amount: number;
@@ -84,6 +121,66 @@ const LANGUAGE_CODE_MAP: Record<string, string> = {
FR: "fr",
};
// Convert Saleor snake_case payload to camelCase
function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder {
return {
id: payload.id,
number: String(payload.number),
userEmail: payload.user_email,
user: payload.first_name || payload.last_name ? {
firstName: payload.first_name,
lastName: payload.last_name,
email: payload.user_email,
} : undefined,
billingAddress: payload.billing_address ? {
firstName: payload.billing_address.first_name,
lastName: payload.billing_address.last_name,
streetAddress1: payload.billing_address.street_address_1,
streetAddress2: payload.billing_address.street_address_2,
city: payload.billing_address.city,
postalCode: payload.billing_address.postal_code,
country: payload.billing_address.country,
phone: payload.billing_address.phone,
} : undefined,
shippingAddress: payload.shipping_address ? {
firstName: payload.shipping_address.first_name,
lastName: payload.shipping_address.last_name,
streetAddress1: payload.shipping_address.street_address_1,
streetAddress2: payload.shipping_address.street_address_2,
city: payload.shipping_address.city,
postalCode: payload.shipping_address.postal_code,
country: payload.shipping_address.country,
phone: payload.shipping_address.phone,
} : undefined,
lines: payload.lines.map((line) => ({
id: line.id,
productName: line.product_name,
variantName: line.variant_name,
quantity: line.quantity,
totalPrice: {
gross: {
amount: parseInt(line.total_price_gross_amount),
currency: line.currency,
},
},
})),
total: {
gross: {
amount: parseInt(payload.total_gross_amount),
currency: payload.currency,
},
},
shippingPrice: payload.shipping_price_gross_amount ? {
gross: {
amount: parseInt(payload.shipping_price_gross_amount),
currency: payload.currency,
},
} : undefined,
languageCode: payload.language_code?.toUpperCase(),
metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined,
};
}
function getCustomerLanguage(order: SaleorOrder): string {
if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) {
return LANGUAGE_CODE_MAP[order.languageCode];
@@ -402,23 +499,24 @@ export async function POST(request: NextRequest) {
console.log("Headers:", { event, domain, apiUrl, hasSignature: !!signature });
console.log("Payload:", JSON.stringify(body).substring(0, 500));
// Handle Saleor subscription payload format (array of events)
let order = null;
if (Array.isArray(body)) {
// Find the order in the array
const orderEvent = body.find((item: any) => item.order);
if (orderEvent) {
order = orderEvent.order;
}
} else if (body.order) {
order = body.order;
// Handle Saleor legacy webhook payload format (array with snake_case fields)
let orderPayload: SaleorOrderPayload | null = null;
if (Array.isArray(body) && body.length > 0) {
// Legacy format: array with order objects directly
orderPayload = body[0] as SaleorOrderPayload;
} else if (body.data && Array.isArray(body.data)) {
// Subscription format: { data: [...] }
orderPayload = body.data[0] as SaleorOrderPayload;
}
if (order) {
console.log("Order ID:", order.id);
console.log("Order number:", order.number);
console.log("User email:", order.userEmail);
if (!orderPayload) {
console.error("No order found in webhook payload");
return NextResponse.json({ error: "No order in payload" }, { status: 400 });
}
console.log("Order ID:", orderPayload.id);
console.log("Order number:", orderPayload.number);
console.log("User email:", orderPayload.user_email);
if (!event) {
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });
@@ -432,10 +530,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: true, message: "Event not supported" });
}
if (!order) {
console.error("No order found in webhook payload");
return NextResponse.json({ error: "No order in payload" }, { status: 400 });
}
// Convert snake_case payload to camelCase
const order = convertPayloadToOrder(orderPayload);
await handleSaleorWebhook(normalizedEvent, { order });