| import { httpAction, internalMutation } from "../_generated/server"; |
| import { v } from "convex/values"; |
| import { internal } from "../_generated/api"; |
| import { requireEnv } from "../lib/env"; |
| import { |
| WebhookPayloadSchema, |
| type WebhookPayload, |
| } from "@dodopayments/core"; |
|
|
| const WEBHOOK_SIGNATURE_TOLERANCE_SECONDS = 5 * 60; |
|
|
| async function timingSafeEqualStrings(a: string, b: string): Promise<boolean> { |
| const enc = new TextEncoder(); |
| const keyMaterial = await crypto.subtle.generateKey( |
| { name: "HMAC", hash: "SHA-256" }, |
| false, |
| ["sign"], |
| ); |
| const [sigA, sigB] = await Promise.all([ |
| crypto.subtle.sign("HMAC", keyMaterial, enc.encode(a)), |
| crypto.subtle.sign("HMAC", keyMaterial, enc.encode(b)), |
| ]); |
| const aArr = new Uint8Array(sigA); |
| const bArr = new Uint8Array(sigB); |
| let diff = 0; |
| for (let i = 0; i < aArr.length; i++) diff |= aArr[i]! ^ bArr[i]!; |
| return diff === 0; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function verifyDodoSignature( |
| webhookKey: string, |
| webhookId: string, |
| webhookTimestamp: string, |
| webhookSignature: string, |
| body: string, |
| ): Promise<void> { |
| const now = Math.floor(Date.now() / 1000); |
| const timestamp = Number.parseInt(webhookTimestamp, 10); |
| if (Number.isNaN(timestamp)) { |
| throw new Error("Invalid Signature Headers"); |
| } |
| if (now - timestamp > WEBHOOK_SIGNATURE_TOLERANCE_SECONDS) { |
| throw new Error("Message timestamp too old"); |
| } |
| if (timestamp > now + WEBHOOK_SIGNATURE_TOLERANCE_SECONDS) { |
| throw new Error("Message timestamp too new"); |
| } |
|
|
| const secretBytes = Uint8Array.from( |
| atob(webhookKey.replace("whsec_", "")), |
| (c) => c.charCodeAt(0), |
| ); |
| const key = await crypto.subtle.importKey( |
| "raw", |
| secretBytes, |
| { name: "HMAC", hash: "SHA-256" }, |
| false, |
| ["sign"], |
| ); |
| const computed = await crypto.subtle.sign( |
| "HMAC", |
| key, |
| new TextEncoder().encode(`${webhookId}.${timestamp}.${body}`), |
| ); |
| const expected = btoa(String.fromCharCode(...new Uint8Array(computed))); |
|
|
| for (const versionedSignature of webhookSignature.split(" ")) { |
| const [version, signature] = versionedSignature.split(","); |
| if (version !== "v1" || !signature) continue; |
| if (await timingSafeEqualStrings(signature, expected)) return; |
| } |
| throw new Error("No matching signature found"); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const reportDodoSignatureFailure = internalMutation({ |
| args: { |
| webhookId: v.optional(v.string()), |
| webhookTimestamp: v.optional(v.string()), |
| errorMessage: v.string(), |
| }, |
| handler: async (_ctx, { webhookId, webhookTimestamp, errorMessage }) => { |
| throw new Error( |
| `[webhook] Dodo signature verification failed (webhookId=${webhookId ?? "<missing>"}, ts=${webhookTimestamp ?? "<missing>"}): ${errorMessage}`, |
| ); |
| }, |
| }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export const webhookHandler = httpAction(async (ctx, request) => { |
| |
| const webhookKey = requireEnv("DODO_PAYMENTS_WEBHOOK_SECRET"); |
|
|
| |
| const webhookId = request.headers.get("webhook-id"); |
| const webhookTimestamp = request.headers.get("webhook-timestamp"); |
| const webhookSignature = request.headers.get("webhook-signature"); |
|
|
| if (!webhookId || !webhookTimestamp || !webhookSignature) { |
| return new Response("Missing required webhook headers", { status: 400 }); |
| } |
|
|
| |
| const body = await request.text(); |
|
|
| |
| |
| |
| |
| const persistFailureAndSignal = async (failure: { |
| eventType: string; |
| rawPayload: unknown; |
| timestamp: number; |
| errorKind: string; |
| errorMessage: string; |
| }): Promise<void> => { |
| try { |
| const signal = await ctx.runMutation( |
| internal.payments.webhookMutations.recordWebhookFailure, |
| { |
| webhookId, |
| eventType: failure.eventType, |
| rawPayload: failure.rawPayload, |
| timestamp: failure.timestamp, |
| receivedAt: Date.now(), |
| errorKind: failure.errorKind, |
| errorMessage: failure.errorMessage, |
| }, |
| ); |
|
|
| |
| |
| |
| |
| |
| |
| if (process.env.NODE_ENV !== "test") { |
| |
| |
| |
| try { |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.payments.webhookMutations.reportDodoWebhookFailure, |
| { |
| webhookId, |
| eventType: failure.eventType, |
| errorKind: signal.errorKind, |
| errorMessage: signal.errorMessage, |
| attemptCount: signal.attemptCount, |
| unresolvedCount: signal.unresolvedCount, |
| eventTypes: signal.eventTypes, |
| }, |
| ); |
| } catch (scheduleErr) { |
| |
| |
| |
| console.error("[webhook] reportDodoWebhookFailure schedule failed:", scheduleErr); |
| } |
| } |
| } catch (recordErr) { |
| |
| |
| |
| |
| console.error("[webhook] Failed to persist Dodo webhook failure:", recordErr); |
| } |
| }; |
|
|
| |
| |
| |
| try { |
| await verifyDodoSignature( |
| webhookKey, |
| webhookId, |
| webhookTimestamp, |
| webhookSignature, |
| body, |
| ); |
| } catch (error) { |
| |
| |
| |
| |
| console.error("Webhook signature verification failed:", error); |
| |
| |
| |
| |
| |
| |
| |
| |
| try { |
| await ctx.scheduler.runAfter( |
| 0, |
| internal.payments.webhookHandlers.reportDodoSignatureFailure, |
| { |
| webhookId: webhookId ?? undefined, |
| webhookTimestamp: webhookTimestamp ?? undefined, |
| errorMessage: error instanceof Error ? error.message : String(error), |
| }, |
| ); |
| } catch (scheduleErr) { |
| |
| |
| console.error( |
| "[webhook] reportDodoSignatureFailure schedule failed:", |
| scheduleErr, |
| ); |
| } |
| return new Response("Invalid webhook signature", { status: 401 }); |
| } |
|
|
| |
| |
| |
| |
| |
| let parsedBody: unknown = null; |
| let payload: WebhookPayload; |
| try { |
| parsedBody = JSON.parse(body); |
| payload = WebhookPayloadSchema.parse(parsedBody); |
| } catch (error) { |
| const errorKind = error instanceof Error && error.name |
| ? error.name |
| : "WebhookPayloadValidationError"; |
| const errorMessage = error instanceof Error ? error.message : String(error); |
| const parsedRecord = |
| parsedBody !== null && typeof parsedBody === "object" && !Array.isArray(parsedBody) |
| ? (parsedBody as Record<string, unknown>) |
| : null; |
| await persistFailureAndSignal({ |
| eventType: typeof parsedRecord?.type === "string" ? parsedRecord.type : "unknown", |
| |
| |
| rawPayload: parsedBody, |
| timestamp: Date.now(), |
| errorKind, |
| errorMessage, |
| }); |
| |
| |
| console.error("Webhook payload validation failed:", error); |
| return new Response("Invalid webhook payload", { status: 500 }); |
| } |
|
|
| |
| |
| |
| |
| |
| const eventTimestamp = payload.timestamp |
| ? payload.timestamp.getTime() |
| : Date.now(); |
|
|
| if (!payload.timestamp) { |
| console.warn("[webhook] Missing payload.timestamp — falling back to Date.now(). Out-of-order detection may be unreliable."); |
| } |
|
|
| |
| |
| |
| const sanitizedPayload = JSON.parse(JSON.stringify(payload)); |
| const eventType = typeof payload.type === "string" ? payload.type : "unknown"; |
|
|
| try { |
| await ctx.runMutation( |
| internal.payments.webhookMutations.processWebhookEvent, |
| { |
| webhookId, |
| eventType, |
| rawPayload: sanitizedPayload, |
| timestamp: eventTimestamp, |
| }, |
| ); |
| } catch (error) { |
| const errorKind = error instanceof Error && error.name |
| ? error.name |
| : "WebhookProcessingError"; |
| const errorMessage = error instanceof Error ? error.message : String(error); |
|
|
| await persistFailureAndSignal({ |
| eventType, |
| rawPayload: sanitizedPayload, |
| timestamp: eventTimestamp, |
| errorKind, |
| errorMessage, |
| }); |
|
|
| |
| |
| console.error("Webhook processing failed:", error); |
| return new Response("Internal processing error", { status: 500 }); |
| } |
|
|
| |
| |
| |
| |
| try { |
| await ctx.runMutation( |
| internal.payments.webhookMutations.markWebhookFailureRecovered, |
| { webhookId }, |
| ); |
| } catch (error) { |
| console.error("[webhook] Failed to mark Dodo webhook failure recovered:", error); |
| return new Response("Internal processing error", { status: 500 }); |
| } |
|
|
| |
| return new Response(null, { status: 200 }); |
| }); |
|
|