diff --git a/app/dashboard/[[...tab]]/page.tsx b/app/dashboard/[[...tab]]/page.tsx
index 4861b3b..1348d47 100644
--- a/app/dashboard/[[...tab]]/page.tsx
+++ b/app/dashboard/[[...tab]]/page.tsx
@@ -2,6 +2,7 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useParams } from "next/navigation";
import { copyText } from "@/lib/clipboard";
+import { formatStoredWebhookPayload } from "@/lib/webhook-payload";
import {
filterSignupsByQuery,
filterSignupsByStatus,
@@ -968,12 +969,30 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;
Recent inbound events ({data.events?.length || 0})
{(!data.events || data.events.length === 0) && - Nothing received yet.
}
- {data.events?.map((e: any) => (
- -
- {e.event_type || "event"} {e.source ? `· ${String(e.source).slice(0, 40)}` : ""}
- {e.created_at}
-
- ))}
+ {data.events?.map((e: any) => {
+ const payload = formatStoredWebhookPayload(e.payload);
+ return (
+ -
+ {e.event_type || "event"} {e.source ? `· ${String(e.source).slice(0, 40)}` : ""}
+ {e.created_at}
+
+ Inspect payload
+ {payload}
+
+
+
+ );
+ })}
>
)}
diff --git a/lib/webhook-payload.ts b/lib/webhook-payload.ts
new file mode 100644
index 0000000..0831b53
--- /dev/null
+++ b/lib/webhook-payload.ts
@@ -0,0 +1,72 @@
+function previousNonWhitespace(value: string, from: number): string | null {
+ for (let index = from; index >= 0; index--) {
+ if (!/\s/.test(value[index])) return value[index];
+ }
+ return null;
+}
+
+function nextNonWhitespace(value: string, from: number): string | null {
+ for (let index = from; index < value.length; index++) {
+ if (!/\s/.test(value[index])) return value[index];
+ }
+ return null;
+}
+
+const MAX_PRETTY_DEPTH = 32;
+const MAX_FORMATTED_LENGTH = 64 * 1024;
+
+/** Pretty-print valid JSON without reparsing number literals into JavaScript numbers. */
+export function formatStoredWebhookPayload(value: unknown): string {
+ const raw = typeof value === "string" ? value : String(value ?? "");
+ try {
+ JSON.parse(raw);
+ } catch {
+ return raw;
+ }
+
+ let formatted = "";
+ let indent = 0;
+ let inString = false;
+ let escaped = false;
+ const pad = () => " ".repeat(indent);
+ const outputLimit = Math.min(MAX_FORMATTED_LENGTH, Math.max(4096, raw.length * 4));
+ const append = (text: string): boolean => {
+ formatted += text;
+ return formatted.length <= outputLimit;
+ };
+
+ for (let index = 0; index < raw.length; index++) {
+ const char = raw[index];
+ if (inString) {
+ if (!append(char)) return raw;
+ if (escaped) escaped = false;
+ else if (char === "\\") escaped = true;
+ else if (char === '"') inString = false;
+ continue;
+ }
+
+ if (char === '"') {
+ inString = true;
+ if (!append(char)) return raw;
+ } else if (char === "{" || char === "[") {
+ if (!append(char)) return raw;
+ indent++;
+ if (indent > MAX_PRETTY_DEPTH) return raw;
+ const closing = char === "{" ? "}" : "]";
+ if (nextNonWhitespace(raw, index + 1) !== closing && !append(`\n${pad()}`)) return raw;
+ } else if (char === "}" || char === "]") {
+ indent--;
+ const opening = char === "}" ? "{" : "[";
+ if (previousNonWhitespace(raw, index - 1) !== opening && !append(`\n${pad()}`)) return raw;
+ if (!append(char)) return raw;
+ } else if (char === ",") {
+ if (!append(`,\n${pad()}`)) return raw;
+ } else if (char === ":") {
+ if (!append(": ")) return raw;
+ } else if (!/\s/.test(char)) {
+ if (!append(char)) return raw;
+ }
+ }
+
+ return formatted;
+}
diff --git a/tests/webhook-payload.test.mjs b/tests/webhook-payload.test.mjs
new file mode 100644
index 0000000..8c10197
--- /dev/null
+++ b/tests/webhook-payload.test.mjs
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { formatStoredWebhookPayload } from "../lib/webhook-payload.ts";
+
+test("stored JSON webhook payloads are formatted for inspection and copying", () => {
+ assert.equal(
+ formatStoredWebhookPayload('{"event":"payment.succeeded","data":{"amount":100,"paid":true}}'),
+ `{
+ "event": "payment.succeeded",
+ "data": {
+ "amount": 100,
+ "paid": true
+ }
+}`,
+ );
+});
+
+test("non-JSON webhook payloads fall back to the stored body verbatim", () => {
+ const raw = "payment.succeeded\namount=100&paid=true";
+ assert.equal(formatStoredWebhookPayload(raw), raw);
+ assert.equal(formatStoredWebhookPayload(""), "");
+});
+
+test("JSON formatting preserves number literals exactly", () => {
+ const raw = '{"id":9007199254740993,"ratio":0.1234567890123456789,"empty":[]}';
+ const formatted = formatStoredWebhookPayload(raw);
+
+ assert.match(formatted, /9007199254740993/);
+ assert.match(formatted, /0\.1234567890123456789/);
+ assert.equal(formatted, `{
+ "id": 9007199254740993,
+ "ratio": 0.1234567890123456789,
+ "empty": []
+}`);
+});
+
+test("JSON formatting preserves escaped strings and duplicate keys", () => {
+ const raw = '{"text":"comma, colon: braces {} [\\"quoted\\"]","key":1,"key":2}';
+
+ assert.equal(formatStoredWebhookPayload(raw), `{
+ "text": "comma, colon: braces {} [\\"quoted\\"]",
+ "key": 1,
+ "key": 2
+}`);
+});
+
+test("deep JSON falls back to raw text instead of amplifying indentation", () => {
+ const raw = `${"[".repeat(2000)}0${"]".repeat(2000)}`;
+ const formatted = formatStoredWebhookPayload(raw);
+
+ assert.equal(formatted, raw);
+ assert.equal(formatted.length, 4001);
+});