Add provider webhook event store

This commit is contained in:
Theodore Song
2026-07-13 09:16:50 -04:00
parent f89cadbb40
commit 7dc0bb4dfd
12 changed files with 444 additions and 4 deletions
+13
View File
@@ -73,9 +73,22 @@ initialize Sentry. Keep auth tokens server-side only.
- `ADMIN_ALERT_EMAIL=emplusbodyworks@gmail.com` - `ADMIN_ALERT_EMAIL=emplusbodyworks@gmail.com`
- `CUSTOMER_SUPPORT_EMAIL=emplusbodyworks@gmail.com` - `CUSTOMER_SUPPORT_EMAIL=emplusbodyworks@gmail.com`
- `PRODUCTION_APP_URL=https://polymarket-site-eta.vercel.app` - `PRODUCTION_APP_URL=https://polymarket-site-eta.vercel.app`
- `WEBHOOK_BASE_URL=https://polymarket-site-eta.vercel.app/api`
- `RESTRICTED_JURISDICTIONS=US,CA-NY,CA-ON` - `RESTRICTED_JURISDICTIONS=US,CA-NY,CA-ON`
- `LIVE_TRADING_ENABLED=false` - `LIVE_TRADING_ENABLED=false`
## Webhook URLs to paste into provider dashboards
These endpoints store provider events in Neon for audit/reconciliation prep.
They do not move money or place orders.
- Clerk: `https://polymarket-site-eta.vercel.app/api/webhooks/clerk`
- Veriff: `https://polymarket-site-eta.vercel.app/api/webhooks/veriff`
- Circle: `https://polymarket-site-eta.vercel.app/api/webhooks/circle`
Provider event counts are visible at
`https://polymarket-site-eta.vercel.app/api/provider-events`.
## What is still intentionally locked ## What is still intentionally locked
- Real deposits. - Real deposits.
+4 -1
View File
@@ -15,8 +15,11 @@ interfaces visible without moving funds or placing orders.
Sentry browser DSN and Clerk publishable key once configured. Sentry browser DSN and Clerk publishable key once configured.
- `/api/live` now checks the selected provider stack: Clerk, Neon, Veriff, - `/api/live` now checks the selected provider stack: Clerk, Neon, Veriff,
Circle, and Sentry. Circle, and Sentry.
- `/api/webhooks/clerk`, `/api/webhooks/veriff`, and `/api/webhooks/circle`
receive provider events and store them in Neon.
- `/api/provider-events` reports provider event counts for a quick health check.
- The Live Money tab shows launch checks, wallet/deposit settings, agent risk - The Live Money tab shows launch checks, wallet/deposit settings, agent risk
limits, a dry-run order console, and an audit preview. limits, provider webhook URLs, a dry-run order console, and an audit preview.
The app still refuses to place live orders. That is intentional until the legal, The app still refuses to place live orders. That is intentional until the legal,
provider, wallet-signing, reconciliation, and monitoring steps below are done. provider, wallet-signing, reconciliation, and monitoring steps below are done.
+110
View File
@@ -0,0 +1,110 @@
import { neon } from "@neondatabase/serverless";
let sqlClient;
let schemaReady;
export function databaseUrl() {
return process.env.DATABASE_URL || process.env.NEON_DATABASE_URL || "";
}
export function hasDatabase() {
return Boolean(databaseUrl());
}
export function sql() {
if (!sqlClient) {
const url = databaseUrl();
if (!url) throw new Error("Database is not configured");
sqlClient = neon(url);
}
return sqlClient;
}
export async function ensureSchema() {
if (schemaReady) return;
const db = sql();
await db`
create table if not exists provider_events (
id bigserial primary key,
provider text not null,
event_type text not null,
external_id text,
signature_valid boolean not null default false,
payload jsonb not null,
headers jsonb not null default '{}'::jsonb,
received_at timestamptz not null default now()
)
`;
await db`
create unique index if not exists provider_events_provider_external_id_idx
on provider_events (provider, external_id)
where external_id is not null
`;
await db`
create table if not exists readiness_audit (
id bigserial primary key,
event_type text not null,
detail jsonb not null,
created_at timestamptz not null default now()
)
`;
schemaReady = true;
}
export async function recordProviderEvent(event) {
await ensureSchema();
const db = sql();
const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
const headers = event.headers && typeof event.headers === "object" ? event.headers : {};
const rows = await db`
insert into provider_events (
provider,
event_type,
external_id,
signature_valid,
payload,
headers
) values (
${event.provider},
${event.eventType || "unknown"},
${event.externalId || null},
${Boolean(event.signatureValid)},
${JSON.stringify(payload)}::jsonb,
${JSON.stringify(headers)}::jsonb
)
on conflict do nothing
returning id, received_at
`;
return rows[0] || null;
}
export async function recordAuditEvent(eventType, detail) {
await ensureSchema();
const db = sql();
await db`
insert into readiness_audit (event_type, detail)
values (${eventType}, ${JSON.stringify(detail || {})}::jsonb)
`;
}
export async function providerEventSummary() {
await ensureSchema();
const db = sql();
const rows = await db`
select
provider,
count(*)::int as total,
max(received_at) as latest_received_at,
count(*) filter (where signature_valid)::int as signature_valid_count
from provider_events
group by provider
order by provider
`;
return rows;
}
+69
View File
@@ -0,0 +1,69 @@
import crypto from "node:crypto";
export async function rawBody(req) {
if (typeof req.body === "string") return req.body;
if (req.body && typeof req.body === "object") return JSON.stringify(req.body);
const chunks = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks).toString("utf8");
}
export function parseJson(raw) {
if (!raw) return {};
try {
return JSON.parse(raw);
} catch {
return {};
}
}
export function safeHeaders(req, names) {
const allowed = new Set(names.map((name) => name.toLowerCase()));
return Object.fromEntries(
Object.entries(req.headers || {})
.filter(([key]) => allowed.has(key.toLowerCase()))
.map(([key, value]) => [key.toLowerCase(), Array.isArray(value) ? value.join(",") : String(value)])
);
}
function timingSafeEqualText(a, b) {
const left = Buffer.from(String(a || ""));
const right = Buffer.from(String(b || ""));
return left.length === right.length && crypto.timingSafeEqual(left, right);
}
function signatureCandidates(signature) {
return String(signature || "")
.split(/[,\s]+/)
.map((piece) => piece.trim())
.filter(Boolean)
.flatMap((piece) => {
const idx = piece.indexOf("=");
return idx === -1 ? [piece] : [piece.slice(idx + 1), piece];
})
.map((piece) => piece.replace(/^sha256=/i, "").trim())
.filter(Boolean);
}
export function verifyHmacSignature(raw, secret, signature) {
if (!secret || !signature) return false;
const hex = crypto.createHmac("sha256", secret).update(raw).digest("hex");
const base64 = crypto.createHmac("sha256", secret).update(raw).digest("base64");
return signatureCandidates(signature).some((candidate) => (
timingSafeEqualText(candidate, hex) || timingSafeEqualText(candidate, base64)
));
}
export function firstHeader(req, names) {
for (const name of names) {
const value = req.headers?.[name.toLowerCase()];
if (value) return Array.isArray(value) ? value.join(",") : String(value);
}
return "";
}
export function sendMethodNotAllowed(res) {
res.setHeader("Allow", "POST");
return res.status(405).json({ ok: false, error: "Method not allowed" });
}
+13
View File
@@ -107,6 +107,12 @@ const LAUNCH_REQUIREMENTS = [
["testing", "Paper-to-live parity, dry-runs, test wallets, webhook tests, and edge-case QA completed"], ["testing", "Paper-to-live parity, dry-runs, test wallets, webhook tests, and edge-case QA completed"],
]; ];
const WEBHOOK_ROUTES = [
["clerk", "Clerk user and session events", "/api/webhooks/clerk"],
["veriff", "Veriff verification and review events", "/api/webhooks/veriff"],
["circle", "Circle payment, wallet, and transfer events", "/api/webhooks/circle"],
];
function envValue(key) { function envValue(key) {
if (process.env[key]) return process.env[key]; if (process.env[key]) return process.env[key];
return (ENV_ALIASES[key] || []).map((alias) => process.env[alias]).find(Boolean); return (ENV_ALIASES[key] || []).map((alias) => process.env[alias]).find(Boolean);
@@ -143,6 +149,7 @@ function liveTradingReady() {
function baseStatus() { function baseStatus() {
const providers = providerStatus(); const providers = providerStatus();
const origin = (envValue("WEBHOOK_BASE_URL") || envValue("PRODUCTION_APP_URL") || "").replace(/\/api\/?$/, "").replace(/\/$/, "");
const liveFlagEnabled = process.env.LIVE_TRADING_ENABLED === "true"; const liveFlagEnabled = process.env.LIVE_TRADING_ENABLED === "true";
const signatureTypeOk = String(process.env.POLYMARKET_SIGNATURE_TYPE || "") === "3"; const signatureTypeOk = String(process.env.POLYMARKET_SIGNATURE_TYPE || "") === "3";
const chainOk = String(process.env.POLYMARKET_CHAIN_ID || "137") === "137"; const chainOk = String(process.env.POLYMARKET_CHAIN_ID || "137") === "137";
@@ -162,6 +169,12 @@ function baseStatus() {
providers, providers,
provider_stack: stackStatus(), provider_stack: stackStatus(),
launch_requirements: LAUNCH_REQUIREMENTS.map(([key, label]) => ({ key, label })), launch_requirements: LAUNCH_REQUIREMENTS.map(([key, label]) => ({ key, label })),
webhooks: WEBHOOK_ROUTES.map(([provider, label, path]) => ({
provider,
label,
path,
url: origin ? `${origin}${path}` : path,
})),
next_required: missing, next_required: missing,
docs: { docs: {
polymarket_overview: "https://docs.polymarket.com/trading/overview", polymarket_overview: "https://docs.polymarket.com/trading/overview",
+21
View File
@@ -0,0 +1,21 @@
import { hasDatabase, providerEventSummary } from "./_db.js";
export default async function handler(req, res) {
res.setHeader("Cache-Control", "no-store");
if (req.method !== "GET") {
res.setHeader("Allow", "GET");
return res.status(405).json({ ok: false, error: "Method not allowed" });
}
if (!hasDatabase()) {
return res.status(503).json({ ok: false, error: "Database is not configured" });
}
try {
const providers = await providerEventSummary();
return res.status(200).json({ ok: true, providers });
} catch (err) {
return res.status(500).json({ ok: false, error: err?.message || "Provider event summary failed" });
}
}
+51
View File
@@ -0,0 +1,51 @@
import { recordProviderEvent } from "../_db.js";
import { firstHeader, parseJson, rawBody, safeHeaders, sendMethodNotAllowed, verifyHmacSignature } from "../_webhook.js";
const SIGNATURE_HEADERS = [
"circle-signature",
"x-circle-signature",
"x-signature",
"webhook-signature",
];
function eventType(payload) {
return payload.type || payload.eventType || payload.notificationType || "circle.webhook";
}
function externalId(payload) {
return payload.id || payload.notificationId || payload.data?.id || payload.resource?.id || null;
}
export default async function handler(req, res) {
res.setHeader("Cache-Control", "no-store");
if (req.method !== "POST") return sendMethodNotAllowed(res);
try {
const body = await rawBody(req);
const payload = parseJson(body);
const headers = safeHeaders(req, SIGNATURE_HEADERS.concat(["user-agent"]));
const secret = process.env.CIRCLE_WEBHOOK_SECRET;
const signature = firstHeader(req, SIGNATURE_HEADERS);
const signatureValid = verifyHmacSignature(body, secret, signature);
const recorded = await recordProviderEvent({
provider: "circle",
eventType: eventType(payload),
externalId: externalId(payload),
signatureValid,
payload,
headers,
});
return res.status(202).json({
ok: true,
recorded: Boolean(recorded),
provider: "circle",
event_type: eventType(payload),
signature_valid: signatureValid,
warning: signatureValid ? undefined : "Event recorded, but no recognized Circle signature was verified.",
});
} catch (err) {
return res.status(500).json({ ok: false, error: err?.message || "Circle webhook failed" });
}
}
+56
View File
@@ -0,0 +1,56 @@
import { Webhook } from "svix";
import { recordProviderEvent } from "../_db.js";
import { rawBody, parseJson, safeHeaders, sendMethodNotAllowed } from "../_webhook.js";
function eventType(payload) {
return payload.type || "clerk.webhook";
}
function externalId(payload) {
return payload.id || payload.data?.id || payload.data?.user_id || null;
}
export default async function handler(req, res) {
res.setHeader("Cache-Control", "no-store");
if (req.method !== "POST") return sendMethodNotAllowed(res);
try {
const secret = process.env.CLERK_WEBHOOK_SIGNING_SECRET;
if (!secret) return res.status(503).json({ ok: false, error: "Clerk webhook secret is not configured" });
const body = await rawBody(req);
const headers = safeHeaders(req, ["svix-id", "svix-timestamp", "svix-signature"]);
let payload;
try {
payload = new Webhook(secret).verify(body, headers);
} catch {
return res.status(401).json({ ok: false, error: "Invalid Clerk webhook signature" });
}
const recorded = await recordProviderEvent({
provider: "clerk",
eventType: eventType(payload),
externalId: externalId(payload),
signatureValid: true,
payload,
headers,
});
return res.status(200).json({
ok: true,
recorded: Boolean(recorded),
provider: "clerk",
event_type: eventType(payload),
signature_valid: true,
});
} catch (err) {
const payload = parseJson(req.body);
return res.status(500).json({
ok: false,
error: err?.message || "Clerk webhook failed",
provider: "clerk",
event_type: eventType(payload),
});
}
}
+51
View File
@@ -0,0 +1,51 @@
import { recordProviderEvent } from "../_db.js";
import { firstHeader, parseJson, rawBody, safeHeaders, sendMethodNotAllowed, verifyHmacSignature } from "../_webhook.js";
const SIGNATURE_HEADERS = [
"x-hmac-signature",
"x-veriff-signature",
"veriff-signature",
"x-signature",
];
function eventType(payload) {
return payload.action || payload.eventType || payload.type || payload.status || "veriff.webhook";
}
function externalId(payload) {
return payload.id || payload.sessionId || payload.verification?.id || payload.verification?.vendorData || payload.vendorData || null;
}
export default async function handler(req, res) {
res.setHeader("Cache-Control", "no-store");
if (req.method !== "POST") return sendMethodNotAllowed(res);
try {
const body = await rawBody(req);
const payload = parseJson(body);
const headers = safeHeaders(req, SIGNATURE_HEADERS.concat(["user-agent"]));
const secret = process.env.VERIFF_WEBHOOK_SECRET || process.env.VERIFF_SECRET_KEY;
const signature = firstHeader(req, SIGNATURE_HEADERS);
const signatureValid = verifyHmacSignature(body, secret, signature);
const recorded = await recordProviderEvent({
provider: "veriff",
eventType: eventType(payload),
externalId: externalId(payload),
signatureValid,
payload,
headers,
});
return res.status(202).json({
ok: true,
recorded: Boolean(recorded),
provider: "veriff",
event_type: eventType(payload),
signature_valid: signatureValid,
warning: signatureValid ? undefined : "Event recorded, but no recognized Veriff signature was verified.",
});
} catch (err) {
return res.status(500).json({ ok: false, error: err?.message || "Veriff webhook failed" });
}
}
+10 -1
View File
@@ -598,6 +598,10 @@ footer{margin-top:34px;padding-top:22px;border-top:1px solid var(--border);color
<div id="liveAuditTrail"></div> <div id="liveAuditTrail"></div>
</div> </div>
</div> </div>
<div class="card">
<div class="card-h"><h3>Provider webhooks</h3><span class="small muted">events are stored in Neon</span></div>
<div id="liveWebhookStatus" class="small muted">Checking webhook setup...</div>
</div>
</section> </section>
<!-- ============ ABOUT ============ --> <!-- ============ ABOUT ============ -->
@@ -2352,13 +2356,18 @@ async function fetchLiveBackendStatus(){
} }
function renderLiveBackendStatus(){ function renderLiveBackendStatus(){
const root=$("liveBackendStatus");if(!root)return; const root=$("liveBackendStatus");if(!root)return;
const hookRoot=$("liveWebhookStatus");
const s=LIVE_BACKEND_STATUS; const s=LIVE_BACKEND_STATUS;
if(!s){root.innerHTML="Checking backend readiness...";return;} if(!s){root.innerHTML="Checking backend readiness...";if(hookRoot)hookRoot.innerHTML="Checking webhook setup...";return;}
const providers=(s.providers||[]).map(p=>`<div class="leader-row"><span>${esc(p.label)}${p.expected?`<div class="small muted">${esc(p.expected)}</div>`:""}</span><b class="${p.configured?"pos-val":"neg-val"}">${p.configured?"configured":"missing"}</b></div>`).join(""); const providers=(s.providers||[]).map(p=>`<div class="leader-row"><span>${esc(p.label)}${p.expected?`<div class="small muted">${esc(p.expected)}</div>`:""}</span><b class="${p.configured?"pos-val":"neg-val"}">${p.configured?"configured":"missing"}</b></div>`).join("");
const stack=(s.provider_stack||[]).map(p=>`<div class="leader-row"><span><b>${esc(p.provider)}</b><div class="small muted">${esc(p.role)}</div><div class="small muted">${(p.checks||[]).map(c=>`${esc(c.key)}: ${c.configured?"ok":"missing"}`).join(" · ")}</div></span><b class="${p.configured?"pos-val":"neg-val"}">${p.configured?"ready":"needs keys"}</b></div>`).join(""); const stack=(s.provider_stack||[]).map(p=>`<div class="leader-row"><span><b>${esc(p.provider)}</b><div class="small muted">${esc(p.role)}</div><div class="small muted">${(p.checks||[]).map(c=>`${esc(c.key)}: ${c.configured?"ok":"missing"}`).join(" · ")}</div></span><b class="${p.configured?"pos-val":"neg-val"}">${p.configured?"ready":"needs keys"}</b></div>`).join("");
const next=(s.next_required||[]).slice(0,12).map(x=>`<li>${esc(x)}</li>`).join(""); const next=(s.next_required||[]).slice(0,12).map(x=>`<li>${esc(x)}</li>`).join("");
const reqs=(s.launch_requirements||[]).map(x=>`<li>${esc(x.label)}</li>`).join(""); const reqs=(s.launch_requirements||[]).map(x=>`<li>${esc(x.label)}</li>`).join("");
root.innerHTML=`<b>${s.live_trading_enabled?"Live trading unlocked":"Live trading locked"}</b><div class="small muted" style="margin:6px 0">${esc(s.locked_reason||"All required backend providers are configured.")}</div>${stack?`<div class="locked-panel" style="margin-top:10px"><b>Your provider stack</b>${stack}</div>`:""}${providers}${next?`<div class="locked-panel" style="margin-top:10px"><b>Next setup items</b><ul>${next}</ul></div>`:""}${reqs?`<div class="locked-panel" style="margin-top:10px"><b>Full launch requirements</b><ol>${reqs}</ol></div>`:""}<div class="small muted" style="margin-top:8px">Live execution stays disabled until every provider is configured, every launch requirement is complete, and final approval flips LIVE_TRADING_ENABLED=true.</div>`; root.innerHTML=`<b>${s.live_trading_enabled?"Live trading unlocked":"Live trading locked"}</b><div class="small muted" style="margin:6px 0">${esc(s.locked_reason||"All required backend providers are configured.")}</div>${stack?`<div class="locked-panel" style="margin-top:10px"><b>Your provider stack</b>${stack}</div>`:""}${providers}${next?`<div class="locked-panel" style="margin-top:10px"><b>Next setup items</b><ul>${next}</ul></div>`:""}${reqs?`<div class="locked-panel" style="margin-top:10px"><b>Full launch requirements</b><ol>${reqs}</ol></div>`:""}<div class="small muted" style="margin-top:8px">Live execution stays disabled until every provider is configured, every launch requirement is complete, and final approval flips LIVE_TRADING_ENABLED=true.</div>`;
if(hookRoot){
const hooks=(s.webhooks||[]).map(h=>`<div class="leader-row"><span><b>${esc(h.provider)}</b><div class="small muted">${esc(h.label)}</div></span><a class="market-link" href="${esc(h.url)}" target="_blank" rel="noopener">${esc(h.url)}</a></div>`).join("");
hookRoot.innerHTML=hooks||"Webhook URLs will appear after backend readiness loads.";
}
} }
async function dryRunLiveOrderIntent(){ async function dryRunLiveOrderIntent(){
const s=loadLiveState(); const s=loadLiveState();
+43 -1
View File
@@ -5,9 +5,26 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@vercel/blob": "2.5.0" "@neondatabase/serverless": "^1.1.0",
"@vercel/blob": "2.5.0",
"svix": "^1.96.1"
} }
}, },
"node_modules/@neondatabase/serverless": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.1.0.tgz",
"integrity": "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==",
"license": "MIT",
"engines": {
"node": ">=19.0.0"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@vercel/blob": { "node_modules/@vercel/blob": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.5.0.tgz", "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-2.5.0.tgz",
@@ -107,6 +124,12 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1" "url": "https://github.com/sindresorhus/execa?sponsor=1"
} }
}, },
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/get-stream": { "node_modules/get-stream": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@@ -280,6 +303,16 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/strip-final-newline": { "node_modules/strip-final-newline": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
@@ -289,6 +322,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/svix": {
"version": "1.96.1",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.96.1.tgz",
"integrity": "sha512-l+GyPS6gjL0okiXplLazEY2GbBsv1jZ4UIwtjp553YkDDv635xMKbM5sABpIdiWy1BYxgyV8M6tHeTwY0gyXlQ==",
"license": "MIT",
"dependencies": {
"standardwebhooks": "1.0.0"
}
},
"node_modules/throttleit": { "node_modules/throttleit": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
+3 -1
View File
@@ -1,5 +1,7 @@
{ {
"dependencies": { "dependencies": {
"@vercel/blob": "2.5.0" "@neondatabase/serverless": "^1.1.0",
"@vercel/blob": "2.5.0",
"svix": "^1.96.1"
} }
} }