From 3244e61379675d96ff6c21100c9817a5cd20b5f0 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Fri, 1 Aug 2025 13:12:09 -0300 Subject: [PATCH 01/16] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F=20server:=20add=20s?= =?UTF-8?q?ource=20database=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/database/schema.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/database/schema.ts b/server/database/schema.ts index 0e03c9745..e546b2056 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -68,7 +68,15 @@ export const transactions = pgTable( ({ cardId }) => [index("transactions_card_id_index").on(cardId)], ); -export const credentialsRelations = relations(credentials, ({ many }) => ({ cards: many(cards) })); +export const sources = pgTable("sources", { + id: text("id").primaryKey(), + config: jsonb("config").notNull(), +}); + +export const credentialsRelations = relations(credentials, ({ many, one }) => ({ + cards: many(cards), + source: one(sources, { fields: [credentials.source], references: [sources.id] }), +})); export const cardsRelations = relations(cards, ({ many, one }) => ({ credential: one(credentials, { fields: [cards.credentialId], references: [credentials.id] }), From d1ea4b5b004faf3c48ed13edb6abd161c970d408 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Fri, 30 Jan 2026 15:36:20 -0300 Subject: [PATCH 02/16] =?UTF-8?q?=E2=9C=A8=20server:=20forward=20webhooks?= =?UTF-8?q?=20to=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/violet-plums-move.md | 5 + cspell.json | 1 + server/hooks/panda.ts | 283 +++++++++++++++++++- server/test/hooks/panda.test.ts | 439 ++++++++++++++++++++++++++++++-- 4 files changed, 708 insertions(+), 20 deletions(-) create mode 100644 .changeset/violet-plums-move.md diff --git a/.changeset/violet-plums-move.md b/.changeset/violet-plums-move.md new file mode 100644 index 000000000..678c94b8e --- /dev/null +++ b/.changeset/violet-plums-move.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ forward webhooks to source diff --git a/cspell.json b/cspell.json index 6877550ca..ffe95e098 100644 --- a/cspell.json +++ b/cspell.json @@ -77,6 +77,7 @@ "hdpi", "hexlify", "hideable", + "hmac", "hono", "IBMPlexMono-Medm", "IERC", diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 348e55731..e09cd8caf 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -13,6 +13,7 @@ import { E_TIMEOUT } from "async-mutex"; import createDebug from "debug"; import { and, eq } from "drizzle-orm"; import { Hono } from "hono"; +import { createHmac } from "node:crypto"; import * as v from "valibot"; import { BaseError, @@ -28,9 +29,11 @@ import { padHex, RawContractError, toBytes, + withRetry, zeroHash, } from "viem"; +import domain from "@exactly/common/domain"; import { auditorAbi, exaPluginAbi, @@ -66,6 +69,9 @@ import type { UnofficialStatusCode } from "hono/utils/http-status"; const debug = createDebug("exa:panda"); Object.assign(debug, { inspectOpts: { depth: undefined } }); +const debugWebhook = createDebug("exa:webhook"); +Object.assign(debugWebhook, { inspectOpts: { depth: undefined } }); + const BaseTransaction = v.object({ id: v.string(), type: v.literal("spend"), @@ -81,7 +87,7 @@ const BaseTransaction = v.object({ merchantCategory: v.nullish(v.string()), merchantCategoryCode: v.string(), merchantName: v.string(), - merchantId: v.optional(v.string()), + merchantId: v.nullish(v.string()), authorizedAt: v.optional(v.pipe(v.string(), v.isoTimestamp())), authorizedAmount: v.nullish(v.number()), authorizationMethod: v.optional(v.string()), @@ -114,7 +120,10 @@ const Transaction = v.variant("action", [ authorizationUpdateAmount: v.number(), authorizedAt: v.pipe(v.string(), v.isoTimestamp()), status: v.picklist(["declined", "pending", "reversed"]), - declinedReason: v.optional(v.string()), + declinedReason: v.nullish(v.string()), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), }), }), }), @@ -143,6 +152,9 @@ const Transaction = v.variant("action", [ authorizedAt: v.pipe(v.string(), v.isoTimestamp()), postedAt: v.pipe(v.string(), v.isoTimestamp()), status: v.literal("completed"), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), }), }), }), @@ -203,7 +215,16 @@ const Payload = v.variant("resource", [ action: v.literal("updated"), body: v.object({ applicationReason: v.string(), - applicationStatus: v.string(), + applicationStatus: v.picklist([ + "approved", + "pending", + "needsInformation", + "needsVerification", + "manualReview", + "denied", + "locked", + "canceled", + ]), firstName: v.string(), id: v.string(), isActive: v.boolean(), @@ -227,6 +248,10 @@ export default new Hono().post( setContext("panda", jsonBody); // eslint-disable-line @typescript-eslint/no-unsafe-argument getActiveSpan()?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `panda.${payload.resource}.${payload.action}`); + startSpan({ name: "webhook", op: "panda.webhook" }, () => publish(payload)).catch((error: unknown) => + captureException(error), + ); + if (payload.resource !== "transaction") { if (payload.resource === "dispute") return c.json({ code: "ok" }); const pandaId = @@ -269,8 +294,8 @@ export default new Hono().post( type: payload.body.spend.amount < 0 ? "return" : "purchase", merchant: { mcc: payload.body.spend.merchantCategoryCode, - id: payload.body.spend.merchantId, name: payload.body.spend.merchantName, + ...(payload.body.spend.merchantId && { id: payload.body.spend.merchantId }), }, terminal: { type: payload.body.spend.authorizationMethod }, address: { countryCode: payload.body.spend.merchantCountry }, @@ -645,7 +670,7 @@ export default new Hono().post( feedback: { type: "authorization", status: "network_declined", - reason: payload.body.spend.declinedReason, + reason: payload.body.spend.declinedReason ?? "unknown", }, }).catch((error: unknown) => captureException(error, { level: "error" })); return c.json({ code: "ok" }); @@ -1168,3 +1193,251 @@ const TransactionPayload = v.object( { bodies: v.array(v.looseObject({ action: v.string() }), "invalid transaction payload") }, "invalid transaction payload", ); + +async function publish(payload: v.InferOutput) { + if (payload.resource === "transaction" && payload.action === "requested") return; + if (payload.resource === "dispute") return; + if (payload.resource === "card" && payload.action === "notification") return; + + async function sendWebhook(webhookPayload: v.InferOutput, url: string, secret: string) { + try { + const result = await withRetry( + async () => { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Signature: createHmac("sha256", secret).update(JSON.stringify(webhookPayload)).digest("hex"), + }, + body: JSON.stringify(webhookPayload), + signal: AbortSignal.timeout(60_000), + }); + if (!response.ok) + throw new Error("WebhookFailed", { + cause: { + code: response.status, + response: await response.json(), + payload: webhookPayload, + }, + }); + return response; + }, + { + delay: ({ count }) => Math.trunc(1 << count) * 500, + retryCount: domain === "web.exactly.app" ? 20 : 3, + shouldRetry: ({ error }) => { + if (error instanceof Error) { + return error.message === "WebhookFailed" || error.name === "TimeoutError"; + } + return false; + }, + }, + ); + debugWebhook({ + code: result.status, + response: await result.json(), + payload: webhookPayload, + }); + } catch (error) { + if (error instanceof Error) { + if (error instanceof Error && error.message === "WebhookFailed") { + debugWebhook(error.cause); + } else { + debugWebhook({ + error: error.message, + payload: webhookPayload, + }); + } + } + throw error; + } + } + + const timestamp = new Date().toISOString(); + const user = await database.query.credentials.findFirst({ + columns: { id: true, source: true }, + with: { source: { columns: { config: true } } }, + where: eq( + credentials.pandaId, + (() => { + switch (payload.resource) { + case "card": + return payload.body.userId; + case "user": + return payload.body.id; + case "transaction": + return payload.body.spend.userId; + } + })(), + ), + }); + + if (!user?.source) return; + const config = v.parse(webhookConfig, user.source.config); + await Promise.allSettled( + Object.values(config.webhooks).map(async (webhook) => { + const secret = config.secrets[webhook.secretId]?.key; + if (!secret) throw new Error("secret not found"); + + switch (payload.resource) { + case "user": + return sendWebhook( + v.parse(Webhook, { + ...payload, + timestamp, + body: { ...payload.body, credentialId: user.id }, + }), + webhook.card?.[payload.action] ?? webhook.url, + secret, + ); + case "card": + // falls through + case "transaction": + return sendWebhook( + v.parse(Webhook, { + ...payload, + timestamp, + }), + webhook.transaction?.[payload.action] ?? webhook.url, + secret, + ); + } + }), + ).then((results) => { + for (const result of results) { + if (result.status === "rejected") captureException(result.reason, { level: "error" }); + } + }); +} + +const BaseWebhook = v.object({ + id: v.string(), + type: v.literal("spend"), + spend: v.object({ + amount: v.number(), + currency: v.literal("usd"), + cardId: v.string(), + localAmount: v.number(), + localCurrency: v.pipe(v.string(), v.length(3)), + merchantCity: v.nullish(v.pipe(v.string(), v.trim())), + merchantCountry: v.nullish(v.pipe(v.string(), v.trim())), + merchantCategory: v.nullish(v.pipe(v.string(), v.trim())), + merchantName: v.pipe(v.string(), v.trim()), + authorizedAt: v.optional(v.pipe(v.string(), v.isoTimestamp())), + authorizedAmount: v.nullish(v.number()), + merchantId: v.nullish(v.string()), + }), +}); + +const Webhook = v.variant("resource", [ + v.variant("action", [ + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("transaction"), + action: v.literal("created"), + body: v.object({ + ...BaseWebhook.entries, + spend: v.object({ + ...BaseWebhook.entries.spend.entries, + status: v.picklist(["pending", "declined"]), + declinedReason: v.nullish(v.string()), + }), + }), + }), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("transaction"), + action: v.literal("updated"), + body: v.object({ + ...BaseWebhook.entries, + spend: v.object({ + ...BaseWebhook.entries.spend.entries, + authorizationUpdateAmount: v.number(), + authorizedAt: v.pipe(v.string(), v.isoTimestamp()), + status: v.picklist(["declined", "pending", "reversed"]), + declinedReason: v.nullish(v.string()), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), + }), + }), + }), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("transaction"), + action: v.literal("completed"), + body: v.object({ + ...BaseWebhook.entries, + spend: v.object({ + ...BaseWebhook.entries.spend.entries, + authorizedAt: v.pipe(v.string(), v.isoTimestamp()), + status: v.literal("completed"), + enrichedMerchantIcon: v.nullish(v.string()), + enrichedMerchantName: v.nullish(v.string()), + enrichedMerchantCategory: v.nullish(v.string()), + }), + }), + }), + ]), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("card"), + action: v.literal("updated"), + body: v.object({ + id: v.string(), + last4: v.pipe(v.string(), v.length(4)), + limit: v.object({ + amount: v.number(), + frequency: v.picklist(["per24HourPeriod", "per7DayPeriod", "per30DayPeriod", "perYearPeriod"]), + }), + status: v.picklist(["notActivated", "active", "locked", "canceled"]), + tokenWallets: v.union([v.array(v.literal("Apple")), v.array(v.literal("Google Pay"))]), + }), + }), + v.object({ + id: v.string(), + timestamp: v.pipe(v.string(), v.isoTimestamp()), + resource: v.literal("user"), + action: v.literal("updated"), + body: v.object({ + credentialId: v.string(), + applicationReason: v.string(), + applicationStatus: v.picklist([ + "approved", + "pending", + "needsInformation", + "needsVerification", + "manualReview", + "denied", + "locked", + "canceled", + ]), + isActive: v.boolean(), + }), + }), +]); + +const webhookConfig = v.object({ + type: v.picklist(["uphold"]), + secrets: v.record(v.string(), v.object({ key: v.string(), type: v.picklist(["HMAC-SHA256"]) })), + webhooks: v.record( + v.string(), + v.object({ + url: v.string(), + secretId: v.string(), + transaction: v.optional( + v.object({ + created: v.optional(v.string()), + updated: v.optional(v.string()), + completed: v.optional(v.string()), + }), + ), + card: v.optional(v.object({ updated: v.optional(v.string()) })), + user: v.optional(v.object({ updated: v.optional(v.string()) })), + }), + ), +}); diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 9419097c2..5fa3ba173 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -9,7 +9,8 @@ import "../mocks/sentry"; import { captureException, setUser } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { parse } from "valibot"; +import { createHmac } from "node:crypto"; +import { object, parse, string } from "valibot"; import { BaseError, createWalletClient, @@ -38,14 +39,13 @@ import chain, { exaPluginAbi, issuerCheckerAbi, marketAbi, - marketUSDCAddress, upgradeableModularAccountAbi, } from "@exactly/common/generated/chain"; import ProposalType from "@exactly/common/ProposalType"; import { Address, type Hash } from "@exactly/common/validation"; import { proposalManager } from "@exactly/plugin/deploy.json"; -import database, { cards, credentials, transactions } from "../../database"; +import database, { cards, credentials, sources, transactions } from "../../database"; import app from "../../hooks/panda"; import keeper from "../../utils/keeper"; import * as panda from "../../utils/panda"; @@ -1634,7 +1634,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("over capture debit", async () => { + it("over-captures debit", async () => { const hold = 25; const capture = 30; @@ -1686,7 +1686,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("partial capture debit", async () => { + it("partial-captures debit", async () => { const hold = 80; const capture = 40; const cardId = "partial-capture-debit"; @@ -1747,7 +1747,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("force capture debit", async () => { + it("force-captures debit", async () => { const capture = 42; const cardId = "force-capture-debit"; @@ -1783,7 +1783,7 @@ describe("card operations", () => { expect(spendFromPayload(transaction?.payload, "completed")).toMatchObject({ amount: capture }); }); - it("force capture fraud", async () => { + it("force-captures fraud", async () => { const updateUser = vi.spyOn(panda, "updateUser").mockResolvedValue(userResponseTemplate); const currentFunds = await publicClient .readContract({ @@ -1962,7 +1962,12 @@ describe("concurrency", () => { Promise.all([ keeper.exaSend( { name: "mint", op: "tx.mint" }, - { address: inject("USDC"), abi: mockERC20Abi, functionName: "mint", args: [account2, 70_000_000n] }, + { + address: inject("USDC"), + abi: mockERC20Abi, + functionName: "mint", + args: [account2, 70_000_000n], + }, ), keeper.exaSend( { name: "create account", op: "exa.account" }, @@ -1973,12 +1978,25 @@ describe("concurrency", () => { args: [0n, [{ x: hexToBigInt(owner2.account.address), y: 0n }]], }, ), - ]).then(() => - keeper.exaSend( - { name: "poke", op: "exa.poke" }, - { address: account2, abi: exaPluginAbi, functionName: "poke", args: [marketUSDCAddress] }, - ), - ), + ]) + .then(() => + keeper.writeContract({ + address: account2, + abi: exaPluginAbi, + functionName: "poke", + args: [inject("MarketUSDC")], + }), + ) + .then(async (hash) => { + const { status } = await publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }); + if (status !== "success") { + const trace = await traceClient.traceTransaction(hash); + const error = new Error(trace.output); + captureException(error, { contexts: { tx: { trace } } }); + Object.assign(error, { trace }); + throw error; + } + }), ]); }); @@ -2069,7 +2087,7 @@ describe("concurrency", () => { afterEach(() => vi.useRealTimers()); - it("mutex timeout", async () => { + it("times out when mutex is locked", async () => { const getMutex = vi.spyOn(panda, "getMutex"); const cardId = `${account2}-card`; const promises = Promise.all([ @@ -2122,6 +2140,232 @@ describe("concurrency", () => { }); }); +describe("webhooks", () => { + let webhookOwner: WalletClient, typeof chain, ReturnType>; + let webhookAccount: Address; + + beforeAll(async () => { + webhookOwner = createWalletClient({ + chain, + transport: http(), + account: privateKeyToAccount(generatePrivateKey()), + }); + webhookAccount = deriveAddress(inject("ExaAccountFactory"), { + x: padHex(webhookOwner.account.address), + y: zeroHash, + }); + await Promise.all([ + database.insert(sources).values([ + { + id: "test", + config: { + type: "uphold", + secrets: { test: { key: "secret", type: "HMAC-SHA256" } }, + webhooks: { sandbox: { url: "https://exa.test", secretId: "test" } }, + }, + }, + ]), + database + .insert(credentials) + .values([ + { + id: webhookAccount, + publicKey: new Uint8Array(), + account: webhookAccount, + factory: zeroAddress, + source: "test", + pandaId: webhookAccount, + }, + ]) + .then(() => { + return database + .insert(cards) + .values([{ id: `${webhookAccount}-card`, credentialId: webhookAccount, lastFour: "1234", mode: 0 }]); + }), + + anvilClient.setBalance({ address: webhookOwner.account.address, value: 10n ** 24n }), + Promise.all([ + keeper.exaSend( + { name: "mint", op: "tx.mint" }, + { + address: inject("USDC"), + abi: mockERC20Abi, + functionName: "mint", + args: [webhookAccount, 50_000_000n], + }, + ), + keeper.exaSend( + { name: "create account", op: "exa.account" }, + { + address: inject("ExaAccountFactory"), + abi: exaAccountFactoryAbi, + functionName: "createAccount", + args: [0n, [{ x: hexToBigInt(webhookOwner.account.address), y: 0n }]], + }, + ), + ]) + .then(() => + keeper.writeContract({ + address: webhookAccount, + abi: exaPluginAbi, + functionName: "poke", + args: [inject("MarketUSDC")], + }), + ) + .then(async (hash) => { + const { status } = await publicClient.waitForTransactionReceipt({ hash, confirmations: 0 }); + if (status !== "success") { + const trace = await traceClient.traceTransaction(hash); + const error = new Error(trace.output); + captureException(error, { contexts: { tx: { trace } } }); + Object.assign(error, { trace }); + throw error; + } + }), + ]); + }); + + afterEach(() => vi.resetAllMocks()); + + it("forwards transaction created", async () => { + const cardId = `${webhookAccount}-card`; + + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + await appClient.index.$post({ + ...transactionCreated, + json: { + ...transactionCreated.json, + body: { + ...transactionCreated.json.body, + id: cardId, + spend: { ...transactionCreated.json.body.spend, cardId, userId: webhookAccount }, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("forwards transaction updated", async () => { + vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); + const cardId = `${webhookAccount}-card`; + + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + await appClient.index.$post({ + ...transactionUpdated, + json: { + ...transactionUpdated.json, + body: { + ...transactionUpdated.json.body, + id: cardId, + spend: { ...transactionUpdated.json.body.spend, cardId, userId: webhookAccount }, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("forwards transaction completed", async () => { + vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); + const cardId = `${webhookAccount}-card`; + + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + await appClient.index.$post({ + ...transactionCompleted, + json: { + ...transactionCompleted.json, + body: { + ...transactionCompleted.json.body, + id: cardId, + spend: { ...transactionCompleted.json.body.spend, cardId, userId: webhookAccount }, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 1, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("forwards card updated", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json() { + return Promise.resolve({}); + }, + } as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { + ...cardUpdated.json.body, + userId: webhookAccount, + tokenWallets: ["Apple"], + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + + it("forwards user updated", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json() { + return Promise.resolve({}); + }, + } as Response); + + await appClient.index.$post({ + ...userUpdated, + json: { + ...userUpdated.json, + body: { + ...userUpdated.json.body, + id: webhookAccount, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); +}); + const authorization = { header: { signature: "panda-signature" }, json: { @@ -2144,6 +2388,7 @@ const authorization = { merchantCity: "buenos aires", merchantCountry: "AR", merchantName: "99999", + merchantId: "550e8400-e29b-41d4-a716-446655440000", status: "pending", userEmail: "mail@mail.com", userFirstName: "David", @@ -2154,6 +2399,170 @@ const authorization = { }, } as const; +const cardUpdated = { + header: { signature: "panda-signature" }, + json: { + id: "31740000-bd68-40c8-a400-5a0131f58800", + resource: "card", + action: "updated", + body: { + id: "f3d8a9c2-4e7b-4a1c-9f2e-8d5c6b3a7e9f", + userId: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + type: "virtual", + status: "active", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "7392", + expirationMonth: "11", + expirationYear: "2029", + tokenWallets: ["Apple"], + }, + }, +} as const; + +const userUpdated = { + header: { signature: "panda-signature" }, + json: { + id: "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + resource: "user", + action: "updated", + body: { + id: "0e3c467c-01e3-4fe8-8778-1c88e02fd000", + firstName: "David", + lastName: "Mayer", + email: "mail@mail.com", + isActive: true, + isTermsOfServiceAccepted: true, + applicationStatus: "pending", + applicationExternalVerificationLink: { + url: "https://cardmemberportal.com/kyc", + params: { + userId: "0e3c467", + signature: "CiQAmdPUf", + }, + }, + applicationCompletionLink: { + url: "https://cardmemberportal.com/kyc", + params: { + userId: "0e3c467", + signature: "CiQAmdPUf", + }, + }, + applicationReason: "COMPROMISED_PERSONS, PEP", + }, + }, +} as const; + +const transactionCreated = { + header: { signature: "panda-signature" }, + json: { + id: "a2684ac7-13bc-4b0e-ab4d-5a2ac036218a", + body: { + id: "4e19a38e-3161-4db1-ac91-e12630950e2c", + type: "spend", + spend: { + amount: -10_000, + cardId: "827c3893-d7c8-46d4-a518-744b016555bc", + status: "pending", + userId: "8e03decf-26b9-41fb-bb73-4fe1f847042a", + cardType: "virtual", + currency: "usd", + userEmail: "rain@gmail.com", + merchantId: "297f8888-55b4-57df-a55b-800c61a3207b", + localAmount: -10_000, + authorizedAt: "2025-07-03T19:52:59.806Z", + merchantCity: "New York ", + merchantName: "Test Refund ", + userLastName: "approved", + localCurrency: "usd", + userFirstName: "Rain", + merchantCountry: "US", + authorizedAmount: -10_000, + merchantCategory: "5641 - Children's and Infant's Wear Store", + authorizationMethod: "Normal presentment", + merchantCategoryCode: "5641", + }, + }, + action: "created", + resource: "transaction", + }, +} as const; + +const transactionUpdated = { + header: { signature: "panda-signature" }, + json: { + id: "e7b2853e-4bb7-4428-8dc2-27e604766dfa", + body: { + id: "30dcf8c6-a1e5-48f1-9c40-ecffe8253d25", + type: "spend", + spend: { + amount: 8000, + cardId: "827c3893-d7c8-46d4-a518-744b016555bc", + status: "reversed", + userId: "8e03decf-26b9-41fb-bb73-4fe1f847042a", + cardType: "virtual", + currency: "usd", + userEmail: "zjdnflol@gamil.com", + merchantId: "d0a30859-096d-57f4-bffd-fd745f44e048", + localAmount: 8000, + authorizedAt: "2025-06-25T15:24:11.337Z", + merchantCity: " ", + merchantName: "Test ", + userLastName: "approved", + localCurrency: "usd", + userFirstName: "jason", + merchantCountry: " ", + authorizedAmount: 8000, + merchantCategory: " - ", + authorizationMethod: "Normal presentment", + enrichedMerchantName: "Test", + merchantCategoryCode: "", + enrichedMerchantCategory: "Education", + authorizationUpdateAmount: -2000, + }, + }, + action: "updated", + resource: "transaction", + }, +} as const; + +const transactionCompleted = { + header: { signature: "panda-signature" }, + json: { + id: "77474a56-51eb-4918-b09e-73cf20077b1b", + body: { + id: "4e19a38e-3161-4db1-ac91-e12630950e2c", + type: "spend", + spend: { + amount: -10_000, + cardId: "827c3893-d7c8-46d4-a518-744b016555bc", + status: "completed", + userId: "8e03decf-26b9-41fb-bb73-4fe1f847042a", + cardType: "virtual", + currency: "usd", + postedAt: "2025-07-03T19:57:04.332Z", + userEmail: "rain@gmail.com", + localAmount: -10_000, + authorizedAt: "2025-07-03T19:52:59.806Z", + merchantCity: "New York ", + merchantName: "Test Refund ", + userLastName: "approved", + localCurrency: "usd", + userFirstName: "Rain", + merchantCountry: "US", + authorizedAmount: -10_000, + merchantCategory: "Children's and Infant's Wear Store", + authorizationMethod: "Normal presentment", + enrichedMerchantName: "Test Refund", + merchantCategoryCode: "5641", + enrichedMerchantCategory: "Refunds - Insufficient Funds", + merchantId: "297f8888-55b4-57df-a55b-800c61a3207b", + }, + }, + action: "completed", + resource: "transaction", + }, +} as const; + const receipt = { status: "success", blockHash: zeroHash, From e51594de9abc353fa90e73a160e8f9a112e45fe3 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 12 Mar 2026 12:07:39 -0300 Subject: [PATCH 03/16] =?UTF-8?q?=E2=9C=A8=20server:=20add=20webhook=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/olive-onions-tan.md | 5 + .../docs/organization-authentication.md | 78 +++++ server/api/index.ts | 2 + server/api/webhook.ts | 271 ++++++++++++++++++ server/hooks/panda.ts | 10 +- server/test/api/webhook.test.ts | 209 ++++++++++++++ server/test/hooks/panda.test.ts | 16 +- server/utils/auth.ts | 3 + 8 files changed, 579 insertions(+), 15 deletions(-) create mode 100644 .changeset/olive-onions-tan.md create mode 100644 server/api/webhook.ts create mode 100644 server/test/api/webhook.test.ts diff --git a/.changeset/olive-onions-tan.md b/.changeset/olive-onions-tan.md new file mode 100644 index 000000000..ff26342e1 --- /dev/null +++ b/.changeset/olive-onions-tan.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add webhook api diff --git a/docs/src/content/docs/organization-authentication.md b/docs/src/content/docs/organization-authentication.md index 68a3adce5..1e188dbfd 100644 --- a/docs/src/content/docs/organization-authentication.md +++ b/docs/src/content/docs/organization-authentication.md @@ -141,3 +141,81 @@ authClient.siwe console.error("nonce error", error); }); ``` + +## Creating a webhook with the authenticated header + + ```typescript +import { createAuthClient } from "better-auth/client"; +import { siweClient, organizationClient } from "better-auth/client/plugins"; +import { mnemonicToAccount } from "viem/accounts"; +import { optimismSepolia } from "viem/chains"; +import { createSiweMessage } from "viem/siwe"; + +const chainId = optimismSepolia.id; +const baseURL = "http://localhost:3000"; +const authClient = createAuthClient({ + baseURL, + plugins: [siweClient(), organizationClient()], +}); + +const owner = mnemonicToAccount("test test test test test test test test test test test test"); + +authClient.siwe + .nonce({ + walletAddress: owner.address, + chainId, + }) + .then(async ({ data: nonceResult }) => { + const statement = `i accept exa terms and conditions`; + const nonce = nonceResult?.nonce ?? ""; + const message = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce, + uri: `https://localhost`, + address: owner.address, + chainId, + scheme: "https", + version: "1", + domain: "localhost", + }); + const signature = await owner.signMessage({ message }); + + await authClient.siwe.verify( + { + message, + signature, + walletAddress: owner.address, + chainId, + }, + { + onSuccess: async (context) => { + const headers = new Headers(); + headers.set("cookie", context.response.headers.get("set-cookie") ?? ""); + const webhooks = await authClient.$fetch(`${baseURL}/api/webhook`, { + headers, + }); + console.log("webhooks", webhooks); + + // only if owner or admin roles for the organization + const newWebhook = await authClient.$fetch(`${baseURL}/api/webhook`, { + headers, + method: "POST", + body: { + name: "foobar", + url: "https://test.com", + }, + }); + console.log("new webhook", newWebhook); + }, + onError: (context) => { + console.log("authorization error", context); + }, + }, + ); + }) + .catch((error: unknown) => { + console.error("nonce error", error); + }); + + ``` diff --git a/server/api/index.ts b/server/api/index.ts index 6f8d35507..f2540b44f 100644 --- a/server/api/index.ts +++ b/server/api/index.ts @@ -10,6 +10,7 @@ import kyc from "./kyc"; import passkey from "./passkey"; import pax from "./pax"; import ramp from "./ramp"; +import webhook from "./webhook"; import appOrigin from "../utils/appOrigin"; import auth from "../utils/auth"; @@ -28,6 +29,7 @@ const api = new Hono() .route("/passkey", passkey) // eslint-disable-line @typescript-eslint/no-deprecated -- // TODO remove .route("/pax", pax) .route("/ramp", ramp) + .route("/webhook", webhook) .on(["POST", "GET"], "/auth/*", (c) => auth.handler(c.req.raw)); export default api; diff --git a/server/api/webhook.ts b/server/api/webhook.ts new file mode 100644 index 000000000..6e3f989fd --- /dev/null +++ b/server/api/webhook.ts @@ -0,0 +1,271 @@ +import { Mutex } from "async-mutex"; +import { eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { describeRoute } from "hono-openapi"; +import { resolver, validator as vValidator } from "hono-openapi/valibot"; +import { randomBytes } from "node:crypto"; +import { literal, metadata, object, optional, parse, picklist, pipe, record, string, union } from "valibot"; + +import database, { sources } from "../database"; +import authValidator from "../middleware/auth"; +import auth from "../utils/auth"; +import validatorHook from "../utils/validatorHook"; + +const BaseWebhook = object({ + url: string(), + transaction: optional( + object({ created: optional(string()), updated: optional(string()), completed: optional(string()) }), + ), + card: optional(object({ updated: optional(string()) })), + user: optional(object({ updated: optional(string()) })), +}); + +const Webhook = object({ ...BaseWebhook.entries, secret: string() }); + +const WebhookConfig = object({ type: picklist(["uphold"]), webhooks: record(string(), Webhook) }); + +const mutexes = new Map(); +function createMutex(organizationId: string) { + const mutex = new Mutex(); + mutexes.set(organizationId, mutex); + return mutex; +} + +export default new Hono() + .get( + "/", + authValidator(), + describeRoute({ + summary: "Get webhook information", + description: `Retrieve the organization's webhook information for an authenticated user the belongs to the organization. Only owner and admin roles can read the webhook information.`, + tags: ["Webhook"], + security: [{ siweAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Webhook information", + content: { "application/json": { schema: resolver(record(string(), BaseWebhook), { errorMode: "ignore" }) } }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver( + object({ + code: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + legacy: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "User doesn't belong to the organization", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: pipe(literal("no organization"), metadata({ examples: ["no organization"] })) }), + object({ code: pipe(literal("no permission"), metadata({ examples: ["no permission"] })) }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + async (c) => { + const organizations = await auth.api.listOrganizations({ + headers: c.req.raw.headers, + }); + const organizationId = organizations[0]?.id; + if (!organizationId) return c.json({ code: "no organization" }, 403); + + const { success: canRead } = await auth.api.hasPermission({ + headers: c.req.raw.headers, + body: { organizationId, permissions: { webhook: ["read"] } }, + }); + if (!canRead) return c.json({ code: "no permission" }, 403); + + const source = await database.query.sources.findFirst({ + where: eq(sources.id, organizationId), + }); + if (!source) return c.json({}, 200); + const config = parse( + object({ ...WebhookConfig.entries, webhooks: record(string(), BaseWebhook) }), + source.config, + ); + return c.json(config.webhooks, 200); + }, + ) + .post( + "/", + authValidator(), + describeRoute({ + summary: "Creates or updates a webhook", + description: `it creates a new webhook if it doesn't exist or updates the existing webhook if it does. Only owner and admin roles can create or update a webhook.`, + tags: ["Webhook"], + security: [{ siweAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Webhook created or updated", + content: { "application/json": { schema: resolver(Webhook, { errorMode: "ignore" }) } }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver( + object({ + code: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + legacy: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "User doesn't belong to the organization", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: pipe(literal("no organization"), metadata({ examples: ["no organization"] })) }), + object({ code: pipe(literal("no permission"), metadata({ examples: ["no permission"] })) }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + vValidator( + "json", + object({ + name: string(), + url: string(), + transaction: optional( + object({ + created: optional(string()), + updated: optional(string()), + completed: optional(string()), + }), + ), + card: optional(object({ updated: optional(string()) })), + user: optional(object({ updated: optional(string()) })), + }), + validatorHook(), + ), + async (c) => { + const { name, ...payload } = c.req.valid("json"); + const organizations = await auth.api.listOrganizations({ headers: c.req.raw.headers }); + const id = organizations[0]?.id; + if (!id) return c.json({ code: "no organization" }, 403); + const { success: canCreate } = await auth.api.hasPermission({ + headers: c.req.raw.headers, + body: { organizationId: id, permissions: { webhook: ["create"] } }, + }); + if (!canCreate) return c.json({ code: "no permission" }, 403); + + const mutex = mutexes.get(id) ?? createMutex(id); + return mutex.runExclusive(async () => { + const source = await database.query.sources.findFirst({ + where: eq(sources.id, id), + }); + if (source) { + const config = parse(WebhookConfig, source.config); + const webhook = { ...payload, secret: config.webhooks[name]?.secret ?? randomBytes(16).toString("hex") }; + await database + .update(sources) + .set({ config: { ...config, webhooks: { ...config.webhooks, [name]: webhook } } }) + .where(eq(sources.id, id)); + return c.json(webhook, 200); + } else { + const webhook = { ...payload, secret: randomBytes(16).toString("hex") }; + await database.insert(sources).values({ id, config: { type: "uphold", webhooks: { [name]: webhook } } }); + return c.json(webhook, 200); + } + }); + }, + ) + .delete( + "/", + authValidator(), + vValidator("json", object({ name: string() }), validatorHook()), + describeRoute({ + summary: "Deletes a webhook", + description: `it deletes the webhook with the given name. Only owner and admin roles can delete a webhook.`, + tags: ["Webhook"], + security: [{ siweAuth: [] }], + validateResponse: true, + responses: { + 200: { + description: "Webhook deleted", + content: { + "application/json": { schema: resolver(object({ code: literal("ok") }), { errorMode: "ignore" }) }, + }, + }, + 401: { + description: "Unauthorized", + content: { + "application/json": { + schema: resolver( + object({ + code: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + legacy: pipe(literal("unauthorized"), metadata({ examples: ["unauthorized"] })), + }), + { errorMode: "ignore" }, + ), + }, + }, + }, + 403: { + description: "User doesn't belong to the organization", + content: { + "application/json": { + schema: resolver( + union([ + object({ code: pipe(literal("no organization"), metadata({ examples: ["no organization"] })) }), + object({ code: pipe(literal("no permission"), metadata({ examples: ["no permission"] })) }), + ]), + { errorMode: "ignore" }, + ), + }, + }, + }, + }, + }), + async (c) => { + const { name } = c.req.valid("json"); + const organizations = await auth.api.listOrganizations({ headers: c.req.raw.headers }); + const id = organizations[0]?.id; + if (!id) return c.json({ code: "no organization" }, 403); + + const { success: canDelete } = await auth.api.hasPermission({ + headers: c.req.raw.headers, + body: { organizationId: id, permissions: { webhook: ["delete"] } }, + }); + if (!canDelete) return c.json({ code: "no permission" }, 403); + + const mutex = mutexes.get(id) ?? createMutex(id); + return mutex.runExclusive(async () => { + const source = await database.query.sources.findFirst({ + where: eq(sources.id, id), + }); + if (source) { + const config = parse(WebhookConfig, source.config); + const { [name]: _, ...remainingWebhooks } = config.webhooks; + await database + .update(sources) + .set({ config: { ...config, webhooks: remainingWebhooks } }) + .where(eq(sources.id, id)); + } + return c.json({ code: "ok" }, 200); + }); + }, + ); diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index e09cd8caf..a0f4ded15 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1276,9 +1276,6 @@ async function publish(payload: v.InferOutput) { const config = v.parse(webhookConfig, user.source.config); await Promise.allSettled( Object.values(config.webhooks).map(async (webhook) => { - const secret = config.secrets[webhook.secretId]?.key; - if (!secret) throw new Error("secret not found"); - switch (payload.resource) { case "user": return sendWebhook( @@ -1288,7 +1285,7 @@ async function publish(payload: v.InferOutput) { body: { ...payload.body, credentialId: user.id }, }), webhook.card?.[payload.action] ?? webhook.url, - secret, + webhook.secret, ); case "card": // falls through @@ -1299,7 +1296,7 @@ async function publish(payload: v.InferOutput) { timestamp, }), webhook.transaction?.[payload.action] ?? webhook.url, - secret, + webhook.secret, ); } }), @@ -1423,12 +1420,11 @@ const Webhook = v.variant("resource", [ const webhookConfig = v.object({ type: v.picklist(["uphold"]), - secrets: v.record(v.string(), v.object({ key: v.string(), type: v.picklist(["HMAC-SHA256"]) })), webhooks: v.record( v.string(), v.object({ url: v.string(), - secretId: v.string(), + secret: v.string(), transaction: v.optional( v.object({ created: v.optional(v.string()), diff --git a/server/test/api/webhook.test.ts b/server/test/api/webhook.test.ts new file mode 100644 index 000000000..dd72e5cf9 --- /dev/null +++ b/server/test/api/webhook.test.ts @@ -0,0 +1,209 @@ +import "../mocks/sentry"; + +import { eq } from "drizzle-orm"; +import { testClient } from "hono/testing"; +import { mnemonicToAccount } from "viem/accounts"; +import { createSiweMessage } from "viem/siwe"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; + +import chain from "@exactly/common/generated/chain"; + +import app from "../../api/webhook"; +import database, { sources } from "../../database"; +import auth from "../../utils/auth"; + +const appClient = testClient(app); + +const owner = mnemonicToAccount("test test test test test test test test test test test junk"); +const integratorAccount = mnemonicToAccount("test test test test test test test test test test test integrator"); + +describe("webhook", () => { + const integratorHeaders = new Headers(); + + describe("authenticated", () => { + beforeAll(async () => { + const adminNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: owner.address, chainId: chain.id }, + }); + + const statement = "I accept Exa terms and conditions"; + const ownerMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: adminNonceResult.nonce, + uri: `https://localhost`, + address: owner.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + + const adminResponse = await auth.api.verifySiweMessage({ + body: { + message: ownerMessage, + signature: await owner.signMessage({ message: ownerMessage }), + walletAddress: owner.address, + chainId: chain.id, + }, + request: new Request("https://localhost"), + asResponse: true, + }); + const ownerHeaders = new Headers(); + ownerHeaders.set("cookie", `${adminResponse.headers.get("set-cookie")}`); + + const integratorNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: integratorAccount.address, chainId: chain.id }, + }); + const integratorMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: integratorNonceResult.nonce, + uri: `https://localhost`, + address: integratorAccount.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + const integratorResponse = await auth.api.verifySiweMessage({ + body: { + message: integratorMessage, + signature: await integratorAccount.signMessage({ message: integratorMessage }), + walletAddress: integratorAccount.address, + chainId: chain.id, + email: "integrator@external.com", + }, + request: new Request("https://localhost"), + asResponse: true, + }); + integratorHeaders.set("cookie", `${integratorResponse.headers.get("set-cookie")}`); + const integrator = await auth.api.getSession({ headers: integratorHeaders }); + if (!integrator) throw new Error("integrator not found"); + const externalOrganization = await auth.api.createOrganization({ + headers: ownerHeaders, + body: { name: "External Organization", slug: "external-organization" }, + }); + + const integratorInvitation = await auth.api.createInvitation({ + headers: ownerHeaders, + body: { email: integrator.user.email, role: "admin", organizationId: externalOrganization?.id }, + }); + await auth.api.acceptInvitation({ headers: integratorHeaders, body: { invitationId: integratorInvitation.id } }); + }); + + afterEach(async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + await database.delete(sources).where(eq(sources.id, id)); + }); + + it("creates and gets a webhook", async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + const cookie = integratorHeaders.get("cookie") ?? ""; + + const response = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie } }, + ); + const source = await database.query.sources.findFirst({ where: eq(sources.id, id) }); + + const getWebhook = await appClient.index.$get({}, { headers: { cookie } }); + + expect(getWebhook.status).toBe(200); + expect(response.status).toBe(200); + expect(source?.config).toStrictEqual({ + type: "uphold", + webhooks: { + test: { url: "https://test.com", secret: expect.any(String) }, // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }, + }); + + await expect(getWebhook.json()).resolves.toStrictEqual({ + test: { + url: "https://test.com", + }, + }); + }); + + it("updates a webhook", async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + + const create = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const update = await appClient.index.$post( + { + json: { + name: "test", + url: "https://test.updated.com", + transaction: { created: "https://test.updated.com/created" }, + }, + }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const createAnother = await appClient.index.$post( + { + json: { + name: "another", + url: "https://another.updated.com", + transaction: { created: "https://another.updated.com/created" }, + }, + }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const source = await database.query.sources.findFirst({ where: eq(sources.id, id) }); + + expect(source?.config).toStrictEqual({ + type: "uphold", + webhooks: { + test: { + url: "https://test.updated.com", + secret: expect.any(String), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + transaction: { created: "https://test.updated.com/created" }, + }, + another: { + url: "https://another.updated.com", + secret: expect.any(String), // eslint-disable-line @typescript-eslint/no-unsafe-assignment + transaction: { created: "https://another.updated.com/created" }, + }, + }, + }); + + expect(create.status).toBe(200); + expect(update.status).toBe(200); + expect(createAnother.status).toBe(200); + }); + + it("deletes a webhook", async () => { + const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); + const id = organizations[0]?.id ?? ""; + const create = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + + const remove = await appClient.index.$delete( + { json: { name: "test" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + const source = await database.query.sources.findFirst({ where: eq(sources.id, id) }); + + expect(source?.config).toStrictEqual({ type: "uphold", webhooks: {} }); + expect(create.status).toBe(200); + expect(remove.status).toBe(200); + }); + + it("returns 200 when webhook is not found", async () => { + const getWebhook = await appClient.index.$get({}, { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }); + expect(getWebhook.status).toBe(200); + await expect(getWebhook.json()).resolves.toStrictEqual({}); + }); + }); +}); diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 5fa3ba173..feb6d2753 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -9,7 +9,7 @@ import "../mocks/sentry"; import { captureException, setUser } from "@sentry/node"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; -import { createHmac } from "node:crypto"; +import { createHmac, randomBytes } from "node:crypto"; import { object, parse, string } from "valibot"; import { BaseError, @@ -2143,6 +2143,7 @@ describe("concurrency", () => { describe("webhooks", () => { let webhookOwner: WalletClient, typeof chain, ReturnType>; let webhookAccount: Address; + const secret = randomBytes(16).toString("hex"); beforeAll(async () => { webhookOwner = createWalletClient({ @@ -2160,8 +2161,7 @@ describe("webhooks", () => { id: "test", config: { type: "uphold", - secrets: { test: { key: "secret", type: "HMAC-SHA256" } }, - webhooks: { sandbox: { url: "https://exa.test", secretId: "test" } }, + webhooks: { sandbox: { url: "https://exa.test", secret } }, }, }, ]), @@ -2251,7 +2251,7 @@ describe("webhooks", () => { const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); it("forwards transaction updated", async () => { @@ -2279,7 +2279,7 @@ describe("webhooks", () => { const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); it("forwards transaction completed", async () => { @@ -2307,7 +2307,7 @@ describe("webhooks", () => { const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); it("forwards card updated", async () => { @@ -2335,7 +2335,7 @@ describe("webhooks", () => { const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); it("forwards user updated", async () => { @@ -2362,7 +2362,7 @@ describe("webhooks", () => { const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", "secret").update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); }); diff --git a/server/utils/auth.ts b/server/utils/auth.ts index 2d0d8d940..ef68eea5e 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -16,6 +16,7 @@ import authSecret from "./authSecret"; import { authAdapter } from "../database/index"; const ac = createAccessControl({ ...defaultStatements, + webhook: ["create", "delete", "read"], }); export default betterAuth({ @@ -52,9 +53,11 @@ export default betterAuth({ ac, roles: { admin: ac.newRole({ + webhook: ["create", "delete", "read"], ...adminAc.statements, }), owner: ac.newRole({ + webhook: ["create", "delete", "read"], ...ownerAc.statements, }), member: ac.newRole({ From 78864893ec5c00bc02a5807f9d0a6feec5788db7 Mon Sep 17 00:00:00 2001 From: danilo neves cruz Date: Wed, 13 Aug 2025 22:36:45 +0200 Subject: [PATCH 04/16] =?UTF-8?q?=F0=9F=93=9D=20docs:=20document=20server?= =?UTF-8?q?=20webhooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/astro.config.ts | 5 +- docs/src/content/docs/webhooks.md | 611 ++++++++++++++++++++++++++++++ 2 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 docs/src/content/docs/webhooks.md diff --git a/docs/astro.config.ts b/docs/astro.config.ts index f2fc8ce97..d443655e7 100644 --- a/docs/astro.config.ts +++ b/docs/astro.config.ts @@ -15,7 +15,10 @@ export default defineConfig({ { base: "api", schema: "node_modules/@exactly/server/generated/openapi.json", sidebar: { collapsed: false } }, ]), ], - sidebar: [{ label: "Docs", items: ["index", "organization-authentication"] }, ...openAPISidebarGroups], + sidebar: [ + { label: "Docs", items: ["index", "organization-authentication", "webhooks"] }, + ...openAPISidebarGroups, + ], }), mermaid(), ], diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md new file mode 100644 index 000000000..fd806f868 --- /dev/null +++ b/docs/src/content/docs/webhooks.md @@ -0,0 +1,611 @@ +--- +title: Webhooks +sidebar: + label: Webhooks + order: 10 +--- + +Webhooks enable real-time event notifications, allowing you to integrate external systems with Exa. + +## Setting up webhooks + +A default endpoint can be configured and optionally an endpoint for each of the 5 event types: + +- Transaction created +- Transaction updated +- Transaction completed +- User updated +- Card updated + +## Webhook security and signing + +Each webhook request is signed using an HMAC SHA256 signature, based on the exact JSON payload sent in the body. This signature is included in the Signature HTTP header of the request. + +You can verify webhook authenticity by computing the HMAC signature and comparing it to the `Signature` header included in the webhook request. + +Example: Verifying a webhook signature (Node.js) + +```typescript +import { createHmac } from "crypto"; + +const signature = createHmac("sha256", ) + .update() // JSON.stringify(payload) + .digest("hex"); +``` + +Ensure that the computed signature matches the Signature header received in the webhook request before processing the payload. + +## Retry policy and timeout + +An exponential backoff with 20 retries and 60 second timeout is used. Retries occur if the request returns an http status code other than 200 or times out. + +| Retry Count | Delay (ms) | Delay (seconds) | Delay (minutes) | +| --- | --- | --- | --- | +| 0 | 500 | 0.5s | - | +| 1 | 1,000 | 1s | - | +| 2 | 2,000 | 2s | - | +| 3 | 4,000 | 4s | - | +| 4 | 8,000 | 8s | - | +| 5 | 16,000 | 16s | - | +| ..... | | | | +| 16 | 32,768,000 | 32768s | ~546.1min | +| 17 | 65,536,000 | 65536s | ~1092.3min | +| 18 | 131,072,000 | 131072s | ~2184.5min | +| 19 | 262,144,000 | 262144s | ~4369.1min | + +## Webhook flows + +There are 5 different types of flow that uses different events which details are in the `Event reference` section: + +- Purchase lifecycle with settlement +- Partial capture +- Over capture +- Force capture +- Refund + +### Purchase lifecycle with settlement + +This example demonstrates a complete transaction lifecycle through webhook notifications, showing how a transaction progresses from initial transaction created to final settlement with an amount adjustment. + +```mermaid +sequenceDiagram + participant Merchant + participant Exa + participant Blockchain + participant Uphold + + + Merchant->>Exa: auth request ($100) + activate Exa + + Exa->>Blockchain: simulate collect ($100) + activate Blockchain + Note over Blockchain: total collect simulation ($100) + Blockchain-->>Exa: simulation OK + deactivate Blockchain + Exa-->>Merchant: auth approved + + Exa->>Blockchain: collect ($100) + activate Blockchain + Note over Blockchain: total collect ($100) + Blockchain-->>Exa: Transaction hash + deactivate Blockchain + + deactivate Exa + + Exa->>Uphold: transaction.created webhook ($100) + activate Uphold + Uphold-->>Exa: webhook acknowledged + deactivate Uphold + + Note over Merchant,Uphold: Time passes (usually same day) + + + Merchant->>Exa: reversal request (-20) + activate Exa + + Exa->>Blockchain: Refund ($20) + activate Blockchain + Note over Blockchain: Refund + Blockchain-->>Exa: Transaction hash + deactivate Blockchain + Exa-->>Merchant: reversal approved + + Exa->>Uphold: webhook transaction.updated (-20) + activate Uphold + Uphold-->>Exa: webhook acknowledged + deactivate Uphold + + deactivate Exa + Note over Merchant,Uphold: Time passes (usually 3 business days) + + Exa->>Uphold: webhook transaction.completed (80) + activate Uphold + Uphold-->>Exa: webhook acknowledged + deactivate Uphold + +``` + +#### Transaction Created + +Transaction authorized and created with timestamp, for $100.00 amount. + +```typescript +{ + "id": "99493687-78c1-4018-8831-d8b1f66f58e2", + "timestamp": "2025-08-13T14:36:04.586Z", + "resource": "transaction", + "action": "created", + "body": { + "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + "type": "spend", + "spend": { + "amount": 10000, + "currency": "usd", + "cardId": "e874583f-47d9-4211-8ea6-3b92e450821b", + "localAmount": 10000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "-", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 10000, + "status": "pending" + } + } +} +``` + +#### Transaction Updated + +Amount adjusted from $100.00 to $80.00 with status "reversed" and authorizationUpdateAmount of -$20.00 +Note that this is a reversal, 1 of the 3 types of refunds. + +```typescript +{ + "id": "e7b2853e-4bb7-4428-8dc2-27e604766dfa", + "timestamp": "2025-08-12T20:08:37.707Z", + "resource": "transaction", + "action": "updated", + "body": { + "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + "type": "spend", + "spend": { + "amount": 8000, + "currency": "usd", + "cardId": "e874583f-47d9-4211-8ea6-3b92e450821b", + "localAmount": 8000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "-", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 8000, + "authorizationUpdateAmount": -2000, + "status": "reversed", + "enrichedMerchantName": "Test", + "enrichedMerchantCategory": "Education" + } + } +} +``` + +#### Transaction Completed + +Final settlement at $80.00 with status "completed". + +```typescript +{ + "id": "662eb701-f9ac-4baa-9f86-b341a730c98a", + "timestamp": "2025-08-12T20:23:20.662Z", + "resource": "transaction", + "action": "completed", + "body": { + "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", + "type": "spend", + "spend": { + "amount": 8000, + "currency": "usd", + "cardId": "e874583f-47d9-4211-8ea6-3b92e450821b", + "localAmount": 8000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 8000, + "status": "completed", + "enrichedMerchantName": "Test", + "enrichedMerchantCategory": "Education" + } + } +} +``` + +### Partial capture flow + +In a partial capture, the merchant settles for less than the authorized amount. After receiving the transaction completed webhook, the over authorized and captured funds are released to the user. This flow is common in restaurants, where the final charge may be lower than the original authorization after accounting for tips. + +#### Transaction Created + +Transaction authorized and created with timestamp for $100.00 amount. + +```typescript +{ + "id": "99493687-78c1-4018-8831-d8b1f66f58e2", + "timestamp": "2025-08-13T16:37:08.862Z", + "resource": "transaction", + "action": "created", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 10000, + "localCurrency": "usd", + "merchantCity": "", + "merchantCountry": "", + "merchantCategory": "-", + "merchantName": "Test", + "authorizedAt": "2025-06-25T15:24:11.337Z", + "authorizedAmount": 10000, + "status": "pending" + } + } +} +``` + +#### Transaction Completed + +Final settlement at $90.00 with status "completed" and timestamp. The final amount is $90 and previously $100 was authorized and captured to the user so $10 is refunded. This is one of the 3 types of refunds. + +```typescript +{ + "id": "a79306b2-bbbc-4511-9e58-ca9fbc9a2d9a", + "timestamp": "2025-08-13T16:42:28.955Z", + "resource": "transaction", + "action": "completed", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 9000, // notice the partial capture + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 9000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "5511", + "merchantName": "PartialCapture Example", + "authorizedAt": "2025-07-03T18:40:28.024Z", + "authorizedAmount": 10000, + "status": "completed", + "enrichedMerchantName": "Partial capture Example", + "enrichedMerchantCategory": "Business - Software" + } + } +} +``` + +### Over Capture + +In an over capture, the merchant settles for more than the originally authorized amount. This flow is typically used in scenarios that involve tips or additional surcharges, such as dining or hospitality. +Certain industries, like restaurants and bars, are allowed to settle for more than the authorized amount—typically up to 20%—to accommodate tips and similar charges. + +#### Transaction Created + +Transaction authorized and created with timestamp for $100.00 amount. + +```typescript +{ + "id": "9d96c8c9-d10f-4d3a-90b9-978eca13ae2a", + "timestamp": "2025-08-13T16:53:21.455Z", + "resource": "transaction", + "action": "created", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 10000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "5812 - Restaurant", + "merchantName": "OverCapture Example", + "authorizedAt": "2025-07-03T18:53:49.958Z", + "authorizedAmount": 10000, + "status": "pending" + } + } +} +``` + +#### Transaction Completed + +Final settlement at $110.00 with status "completed" and timestamp. Note that the final amount is 110 but 100 was authorized and captured so capturing an extra $10 to the user is needed. + +```typescript +{ + "id": "593b0673-82ba-457b-afce-1cbd725f9e3c", + "timestamp": "2025-08-13T16:55:11.934Z", + "resource": "transaction", + "action": "completed", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": 11000, // notice the increase in the amount of settlement + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": 11000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "Restaurant", + "merchantName": "OverCapture Example", + "authorizedAt": "2025-07-03T18:53:49.958Z", + "authorizedAmount": 10000, + "status": "completed", + "enrichedMerchantName": "Over Capture Example", + "enrichedMerchantCategory": "Restaurants" + } + } +} +``` + +### Force Capture + +A force capture occurs when a merchant settles a transaction without prior authorization. These transactions bypass the authorization phase and proceed directly to settlement. This flow is typically used in offline scenarios, such as in-flight purchases where the merchant does not have internet access. + +#### Transaction completed + +```typescript +{ + "id": "593b0673-82ba-457b-afce-1cbd725f9e3c", + "timestamp": "2025-08-13T17:00:08.061Z", + "resource": "transaction", + "action": "completed", + "body": { + "id": "0x8eFc15407B97a28a537d105AB28fB442324CC2ee-card", + "type": "spend", + "spend": { + "amount": 11000, + "currency": "usd", + "cardId": "0x8eFc15407B97a28a537d105AB28fB442324CC2ee-card", + "localAmount": 11000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "Restaurant", + "merchantName": "OverCapture Example", + "authorizedAt": "2025-07-03T18:53:49.958Z", + "authorizedAmount": 10000, + "status": "completed", + "enrichedMerchantName": "Over Capture Example", + "enrichedMerchantCategory": "Restaurants" + } + } +} +``` + +### Refund + +Refunds are treated as negative transactions and may or may not reference the original transaction completed. Unlike reversals, refunds can be initiated independently of the original transaction and may occur well after the initial settlement. + +#### Transaction created + +The webhook is only for informational purpose, Exa does not return funds to the user with this event, is just to notify that a proper refund is coming and +do sanity checks. + +```typescript +{ + "id": "a2684ac7-13bc-4b0e-ab4d-5a2ac036218a", + "timestamp": "2025-08-13T17:08:50.609Z", + "resource": "transaction", + "action": "created", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": -10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": -10000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "5641 - Children's and Infant's Wear Store", + "merchantName": "Test Refund", + "authorizedAt": "2025-07-03T19:52:59.806Z", + "authorizedAmount": -10000, + "status": "pending" + } + } +} + ``` + +#### Transaction Completed + +Final settlement of -$100.00 with status "completed" and timestamp. Refund $100 to the user. + +```typescript +{ + "id": "77474a56-51eb-4918-b09e-73cf20077b1b", + "timestamp": "2025-08-13T17:12:48.858Z", + "resource": "transaction", + "action": "completed", + "body": { + "id": "be67eeb7-294a-42d9-b337-77bfad198aad", + "type": "spend", + "spend": { + "amount": -10000, + "currency": "usd", + "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", + "localAmount": -10000, + "localCurrency": "usd", + "merchantCity": "New York", + "merchantCountry": "US", + "merchantCategory": "Children's and Infant's Wear Store", + "merchantName": "Test Refund", + "authorizedAt": "2025-07-03T19:52:59.806Z", + "authorizedAmount": -10000, + "status": "completed", + "enrichedMerchantName": "Test Refund", + "enrichedMerchantCategory": "Refunds - Insufficient Funds" + } + } +} +``` + +## Refunds + +There are 3 types of operations that return funds to the user: reversal, partial capture, and refund. + +### Reversal + +This occurs when the user calls an uber, for example. Authorizes $30 but then the travel is cancelled, so exa instantly return the funds to the user in a $30 reversal. This happens before the settlement and can happen many times. Timing: reversals are usually during the same day. + +#### Partial capture + +This happens when a transaction enters a terminal state, which means no more reversals or other event types are allowed. This is the last event. If the authorized amount is higher than the final amount, funds need to be returned to the user. This looks pretty much like a reversal but also signals to the user that no more assets will be requested or returned as part of the purchase flow. Timing: usually 2 or 3 business days after swiping the card. + +#### Refund + +Refunds come after the purchase enters a terminal state and could be associated with the purchase or not. That is not guaranteed, but if it is not the same, using the merchant name to link is suggested. Timing: more than a week. + +| Operation | Display | Time | +| --- | --- | --- | +| reversal | purchase details | same day | +| partial | purchase details | 2 or 3 business day | +| refunds | activity | weeks | + +## Event reference + +### Transaction created event + +The transaction created webhook is sent when the transaction flow is created, whether it has been authorized or declined. You must persist this information. +This event initiates the purchase lifecycle in case of `pending`, then could exist many intermediate state changes done by `transaction update` event and finally the `transaction complete` event sets the purchase in terminal state. No more events coming except of a refund which transaction id could be the same as the original purchase or not. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhookId and always the same when retry | 372d1a76-8a57-403e-a7f3-ac3231be144c | +| timestamp | string | Time when sent the event. Always the same when retry | 2025-08-06T20:29:23.870Z | +| resource | "transaction" | | transaction | +| action | "created" | | created | +| body.id | string | Transaction id. Is the same for many events in the life cycle of the purchase | f1083e93-afd5-4271-85c6-dd47099e9746 | +| body.type | "spend" | | spend | +| body.spend.amount | integer | Amount of the purchase in USD in cents. 1 USD = 100 | 100 | +| body.spend.currency | string | Always in usd | usd | +| body.spend.cardId | string | | 47c3c3b3-b197-4a97-ace3-901a6ad7cf61 | +| body.spend.localAmount | integer | Purchase amount in local currency | 100 | +| body.spend.localCurrency | string | The local currency | usd, eur, ars | +| body.spend.merchantCity? | string | The merchant city | "San Francisco" | +| body.spend.merchantCountry? | string | The merchant country | "US" | +| body.spend.merchantCategory? | string | The merchant category | "5814 - Quick Payment Service-Fast Food Restaurants" | +| body.spend.merchantName | string | The merchant name | SQ *BLUE BOTTLE COFFEE | +| body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | +| body.spend.authorizedAt | string | Time when purchase was authorized in ISO 8601 | 2025-08-06T20:29:23.288Z | +| body.spend.authorizedAmount | integer | The authorized amount | 100 | +| body.spend.status | "pending" \| "declined" | Can be pending or declined. In case of declined, the field `declinedReason` has the reason | pending | +| body.spend.declinedReason? | string | Decline message | webhook declined | + +### Transaction updated event + +This webhook is sent whenever a transaction is updated. Note that the transaction may not have been created before this update. +Triggered for events such as incremental authorizations or reversals (a type of refund). + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | e972a2b0-a990-47af-b460-500ff75fbf65 | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-11T15:30:39.939Z | +| resource | "transaction" | | transaction | +| action | "updated" | | updated | +| body.id | string | transaction id. the same in the life cycle of the purchase | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | +| body.type | "spend" | | spend | +| body.spend.amount | number | amount in usd authorized | 2499 | +| body.spend.currency | string | always dollars | usd | +| body.spend.cardId | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | +| body.spend.localAmount | number | amount in local currency authorized | 2499 | +| body.spend.localCurrency | string | currency of the purchase | usd | +| body.spend.merchantCity? | string | city of the merchant | SAN FRANCISCO | +| body.spend.merchantCountry? | string | country of the merchant | US | +| body.spend.merchantCategory? | string | category of the merchant | 4121 - Taxicabs and Limousines | +| body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | +| body.spend.merchantName | string | name of the merchant | UBER *TRIP | +| body.spend.authorizedAt | string | time when purchase was authorized in ISO 8601 | 2025-08-10T04:28:39.547Z | +| body.spend.authorizedAmount | number | amount authorized | 2499 | +| body.spend.authorizationUpdateAmount | number | amount difference authorized. it can be positive in case of status pending or negative if is a reversal. will be declined if was not possible to authorize the increment or decrement of the authorization | 726 | +| body.spend.status | "pending" \| "reversed" \| "declined" | current status of the transaction | pending | +| body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | | +| body.spend.enrichedMerchantName? | string | name of the enriched merchant | Uber | +| body.spend.enrichedMerchantCategory? | string | category of the enriched merchant | Transport - Rides | + +### Transaction completed event + +This webhook is sent whenever a transaction reaches a final state. Note that the transaction may not have been created before this update. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | 662eb701-f9ac-4baa-9f86-b341a730c6dc | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T18:29:20.499Z | +| resource | "transaction" | | transaction | +| action | "completed" | | completed | +| body.id | string | Is the Transaction id and is the same in the life cycle of the purchase. With refunds could be different from the original purchase. | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | +| body.type | "spend" | | spend | +| body.spend.amount | number | final settled amount in usd | 1041 | +| body.spend.currency | string | always dollars | usd | +| body.spend.cardId | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | +| body.spend.localAmount | number | final settled amount in local currency | 1270000 | +| body.spend.localCurrency | string | currency of the purchase | ars | +| body.spend.merchantCity? | string | city of the merchant | CAP.FEDERAL | +| body.spend.merchantCountry? | string | country of the merchant | AR | +| body.spend.merchantCategory? | string | category of the merchant | Recreation Services | +| body.spend.merchantName | string | name of the merchant | JOCKEY CLUB | +| body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | +| body.spend.authorizedAt | string | time when purchase was authorized in ISO 8601 | 2025-08-08T17:55:14.312Z | +| body.spend.authorizedAmount | number | original authorized amount | 1035 | +| body.spend.status | "completed" | final status of the transaction | completed | +| body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | | +| body.spend.enrichedMerchantName? | string | name of the enriched merchant | Jockey | +| body.spend.enrichedMerchantCategory? | string | category of the enriched merchant | Shopping | + +### User updated + +This webhook is sent whenever a user's compliance status is updated. No response is required. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | bdc87700-bf6d-4d7d-ac29-3effb06e3000 | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T19:16:56.709Z | +| resource | "user" | | user | +| action | "updated" | | updated | +| body.credentialId | string | credential id | 0xE18847D2f02cE2800C07c5b42e66c819eC78d35f | +| body.applicationReason | string | reason for application status | COMPROMISED_PERSONS, PEP | +| body.applicationStatus | "approved" \| "pending" \| "needsInformation" \| "needsVerification" \| "manualReview" \| "denied" \| "locked" \| "canceled" | current status of the application | pending | +| body.isActive | boolean | whether the user is active | true | + +### Card updated + +This webhook is currently triggered when a user adds their card to a digital wallet. + +| field | type | description | example | +| --- | --- | --- | --- | +| id | string | webhook id and always the same when retry | 31740000-bd68-40c8-a400-5a0131f58800 | +| timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T18:47:33.687Z | +| resource | "card" | | card | +| action | "updated" | | updated | +| body.id | string | card identifier | e874583f-47d9-4211-8ea6-3b92e450821b | +| body.last4 | string | last 4 digits of the card | 7392 | +| body.limit.amount | number | spending limit amount | 1000000 | +| body.limit.frequency | "per24HourPeriod" \| "per7DayPeriod" \| "per30DayPeriod" \| "perYearPeriod" | frequency of the spending limit | per7DayPeriod | +| body.status | "notActivated" \| "active" \| "locked" \| "canceled" | current status of the card | active | +| body.tokenWallets | ["Apple"] \| ["Google Pay"] | array of token wallets | ["Apple"] | From e9c3dc3c96b6834a2e60de4976a9f3bd2f3c0820 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Tue, 28 Oct 2025 16:30:20 -0300 Subject: [PATCH 05/16] =?UTF-8?q?=E2=9C=A8=20server:=20add=20transaction?= =?UTF-8?q?=20receipt=20to=20webhook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/quick-ants-write.md | 5 ++ docs/src/content/docs/webhooks.md | 64 ++++++++++++++++---- server/hooks/panda.ts | 46 +++++++++++---- server/test/hooks/panda.test.ts | 97 +++++++++++++++++++++++-------- server/utils/keeper.ts | 4 ++ 5 files changed, 171 insertions(+), 45 deletions(-) create mode 100644 .changeset/quick-ants-write.md diff --git a/.changeset/quick-ants-write.md b/.changeset/quick-ants-write.md new file mode 100644 index 000000000..48dc1dc7f --- /dev/null +++ b/.changeset/quick-ants-write.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ add transaction receipt to webhook diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index fd806f868..a00b24d8c 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -37,7 +37,7 @@ Ensure that the computed signature matches the Signature header received in the ## Retry policy and timeout -An exponential backoff with 20 retries and 60 second timeout is used. Retries occur if the request returns an http status code other than 200 or times out. +An exponential backoff with 20 retries and 60 second timeout is used. Retries occur if the request returns an http status code other than 2xx or times out. | Retry Count | Delay (ms) | Delay (seconds) | Delay (minutes) | | --- | --- | --- | --- | @@ -130,12 +130,16 @@ sequenceDiagram Transaction authorized and created with timestamp, for $100.00 amount. -```typescript +```json { "id": "99493687-78c1-4018-8831-d8b1f66f58e2", "timestamp": "2025-08-13T14:36:04.586Z", "resource": "transaction", "action": "created", + "receipt": { + "blockNumber": 97, + "transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db", + }, "body": { "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", "type": "spend", @@ -162,12 +166,16 @@ Transaction authorized and created with timestamp, for $100.00 amount. Amount adjusted from $100.00 to $80.00 with status "reversed" and authorizationUpdateAmount of -$20.00 Note that this is a reversal, 1 of the 3 types of refunds. -```typescript +```json { "id": "e7b2853e-4bb7-4428-8dc2-27e604766dfa", "timestamp": "2025-08-12T20:08:37.707Z", "resource": "transaction", "action": "updated", + "receipt": { + "blockNumber": 98, + "transactionHash": "0x8c6ef90db7901c43018b3b079ac5ccf84e9c1eb2aaf0fd5f1f8b3e2b97d25fa3", + }, "body": { "id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000", "type": "spend", @@ -196,7 +204,7 @@ Note that this is a reversal, 1 of the 3 types of refunds. Final settlement at $80.00 with status "completed". -```typescript +```json { "id": "662eb701-f9ac-4baa-9f86-b341a730c98a", "timestamp": "2025-08-12T20:23:20.662Z", @@ -233,12 +241,16 @@ In a partial capture, the merchant settles for less than the authorized amount. Transaction authorized and created with timestamp for $100.00 amount. -```typescript +```json { "id": "99493687-78c1-4018-8831-d8b1f66f58e2", "timestamp": "2025-08-13T16:37:08.862Z", "resource": "transaction", "action": "created", + "receipt": { + "blockNumber": 108, + "transactionHash": "0x59be2972d1094e6abc14f595b71ed4e9e6ec4e2cd8d61e292f6debcba37e19b4", + }, "body": { "id": "be67eeb7-294a-42d9-b337-77bfad198aad", "type": "spend", @@ -264,12 +276,16 @@ Transaction authorized and created with timestamp for $100.00 amount. Final settlement at $90.00 with status "completed" and timestamp. The final amount is $90 and previously $100 was authorized and captured to the user so $10 is refunded. This is one of the 3 types of refunds. -```typescript +```json { "id": "a79306b2-bbbc-4511-9e58-ca9fbc9a2d9a", "timestamp": "2025-08-13T16:42:28.955Z", "resource": "transaction", "action": "completed", + "receipt": { + "blockNumber": 109, + "transactionHash": "0xd3b27341a97f4621865d896713a82be4099c5e0ad18782fb134fa33a77bba937", + }, "body": { "id": "be67eeb7-294a-42d9-b337-77bfad198aad", "type": "spend", @@ -302,12 +318,16 @@ Certain industries, like restaurants and bars, are allowed to settle for more th Transaction authorized and created with timestamp for $100.00 amount. -```typescript +```json { "id": "9d96c8c9-d10f-4d3a-90b9-978eca13ae2a", "timestamp": "2025-08-13T16:53:21.455Z", "resource": "transaction", "action": "created", + "receipt": { + "blockNumber": 300, + "transactionHash": "0x7faf9d14fde333a946c27f9e173c2d640ef3b4fbafc7e75d2a8a4b8743efb001", + }, "body": { "id": "be67eeb7-294a-42d9-b337-77bfad198aad", "type": "spend", @@ -333,12 +353,16 @@ Transaction authorized and created with timestamp for $100.00 amount. Final settlement at $110.00 with status "completed" and timestamp. Note that the final amount is 110 but 100 was authorized and captured so capturing an extra $10 to the user is needed. -```typescript +```json { "id": "593b0673-82ba-457b-afce-1cbd725f9e3c", "timestamp": "2025-08-13T16:55:11.934Z", "resource": "transaction", "action": "completed", + "receipt": { + "blockNumber": 499, + "transactionHash": "0x2d3a8b61a94f5f36b0d64f3e6a7c5e1bb7eeba6004cd3f1dc7c02b265aec7b02", + }, "body": { "id": "be67eeb7-294a-42d9-b337-77bfad198aad", "type": "spend", @@ -368,12 +392,16 @@ A force capture occurs when a merchant settles a transaction without prior autho #### Transaction completed -```typescript +```json { "id": "593b0673-82ba-457b-afce-1cbd725f9e3c", "timestamp": "2025-08-13T17:00:08.061Z", "resource": "transaction", "action": "completed", + "receipt": { + "blockNumber": 97, + "transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db", + }, "body": { "id": "0x8eFc15407B97a28a537d105AB28fB442324CC2ee-card", "type": "spend", @@ -406,7 +434,7 @@ Refunds are treated as negative transactions and may or may not reference the or The webhook is only for informational purpose, Exa does not return funds to the user with this event, is just to notify that a proper refund is coming and do sanity checks. -```typescript +```json { "id": "a2684ac7-13bc-4b0e-ab4d-5a2ac036218a", "timestamp": "2025-08-13T17:08:50.609Z", @@ -437,12 +465,16 @@ do sanity checks. Final settlement of -$100.00 with status "completed" and timestamp. Refund $100 to the user. -```typescript +```json { "id": "77474a56-51eb-4918-b09e-73cf20077b1b", "timestamp": "2025-08-13T17:12:48.858Z", "resource": "transaction", "action": "completed", + "receipt": { + "blockNumber": 97, + "transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db", + }, "body": { "id": "be67eeb7-294a-42d9-b337-77bfad198aad", "type": "spend", @@ -494,6 +526,7 @@ Refunds come after the purchase enters a terminal state and could be associated The transaction created webhook is sent when the transaction flow is created, whether it has been authorized or declined. You must persist this information. This event initiates the purchase lifecycle in case of `pending`, then could exist many intermediate state changes done by `transaction update` event and finally the `transaction complete` event sets the purchase in terminal state. No more events coming except of a refund which transaction id could be the same as the original purchase or not. +The onchain receipt will be present only if a onchain transaction is necessary. | field | type | description | example | | --- | --- | --- | --- | @@ -501,6 +534,8 @@ This event initiates the purchase lifecycle in case of `pending`, then could exi | timestamp | string | Time when sent the event. Always the same when retry | 2025-08-06T20:29:23.870Z | | resource | "transaction" | | transaction | | action | "created" | | created | +| receipt?.blockNumber | number | onchain transaction block number | 97 | +| receipt?.transactionHash | string | Transaction hash | 0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db | | body.id | string | Transaction id. Is the same for many events in the life cycle of the purchase | f1083e93-afd5-4271-85c6-dd47099e9746 | | body.type | "spend" | | spend | | body.spend.amount | integer | Amount of the purchase in USD in cents. 1 USD = 100 | 100 | @@ -529,6 +564,8 @@ Triggered for events such as incremental authorizations or reversals (a type of | timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-11T15:30:39.939Z | | resource | "transaction" | | transaction | | action | "updated" | | updated | +| receipt.blockNumber | number | onchain transaction block number | 97 | +| receipt.transactionHash | string | Transaction hash | 0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db | | body.id | string | transaction id. the same in the life cycle of the purchase | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | | body.type | "spend" | | spend | | body.spend.amount | number | amount in usd authorized | 2499 | @@ -551,7 +588,8 @@ Triggered for events such as incremental authorizations or reversals (a type of ### Transaction completed event -This webhook is sent whenever a transaction reaches a final state. Note that the transaction may not have been created before this update. +This webhook is sent whenever a transaction reaches a final state. Note that the transaction may not have been created before this update. The `receipt` exist only +if an onchain transaction is necessary. | field | type | description | example | | --- | --- | --- | --- | @@ -559,6 +597,8 @@ This webhook is sent whenever a transaction reaches a final state. Note that the | timestamp | string | time when the event was triggered in ISO 8601 format | 2025-08-12T18:29:20.499Z | | resource | "transaction" | | transaction | | action | "completed" | | completed | +| receipt?.blockNumber | number | onchain transaction block number. | 97 | +| receipt?.transactionHash | string | Transaction hash | 0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db | | body.id | string | Is the Transaction id and is the same in the life cycle of the purchase. With refunds could be different from the original purchase. | 96fbeb61-b4b0-59ab-93e0-2f2afce7637c | | body.type | "spend" | | spend | | body.spend.amount | number | final settled amount in usd | 1041 | diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index a0f4ded15..9f81d21a4 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -31,6 +31,7 @@ import { toBytes, withRetry, zeroHash, + type TransactionReceipt, } from "viem"; import domain from "@exactly/common/domain"; @@ -248,10 +249,6 @@ export default new Hono().post( setContext("panda", jsonBody); // eslint-disable-line @typescript-eslint/no-unsafe-argument getActiveSpan()?.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, `panda.${payload.resource}.${payload.action}`); - startSpan({ name: "webhook", op: "panda.webhook" }, () => publish(payload)).catch((error: unknown) => - captureException(error), - ); - if (payload.resource !== "transaction") { if (payload.resource === "dispute") return c.json({ code: "ok" }); const pandaId = @@ -266,6 +263,9 @@ export default new Hono().post( where: eq(credentials.pandaId, pandaId), }); if (user) setUser({ id: user.account }); + startSpan({ name: "webhook", op: `panda.webhook.${payload.id}` }, () => publish(payload)).catch( + (error: unknown) => captureException(error, { level: "error" }), + ); } return c.json({ code: "ok" }); } @@ -556,6 +556,10 @@ export default new Hono().post( }, ])); }, + onReceipt: (receipt) => + startSpan({ name: "webhook", op: `panda.webhook.${payload.id}` }, () => + publish(payload, receipt), + ).catch((error: unknown) => captureException(error, { level: "error" })), }, ); sendPushNotification({ @@ -649,8 +653,8 @@ export default new Hono().post( where: eq(cards.id, payload.body.spend.cardId), with: { credential: { columns: { account: true, id: true, source: true } } }, }); - if (!card) return c.json({ code: "card not found" }, 404); + const account = v.parse(Address, card.credential.account); setUser({ id: account }); @@ -683,6 +687,10 @@ export default new Hono().post( feedback: { type: "authorization", status: "approved" }, }).catch((error: unknown) => captureException(error, { level: "error" })); + startSpan({ name: "webhook", op: `panda.webhook.${payload.id}` }, () => publish(payload)).catch( + (error: unknown) => captureException(error, { level: "error" }), + ); + return c.json({ code: "ok" }); } if (payload.body.spend.status !== "pending" && payload.action !== "completed") return c.json({ code: "ok" }); @@ -719,6 +727,11 @@ export default new Hono().post( : { type: "settlement", status: "settled" }), }, }).catch((error: unknown) => captureException(error, { level: "error" })); + + startSpan({ name: "webhook", op: `panda.webhook.${payload.body.id}` }, () => publish(payload)).catch( + (error: unknown) => captureException(error, { level: "error" }), + ); + return c.json({ code: "ok" }); } try { @@ -769,6 +782,10 @@ export default new Hono().post( }, ])); }, + onReceipt: (receipt) => + startSpan({ name: "webhook", op: `panda.webhook.${payload.body.id}` }, () => + publish(payload, receipt), + ).catch((error: unknown) => captureException(error, { level: "error" })), }, ); @@ -1194,8 +1211,9 @@ const TransactionPayload = v.object( "invalid transaction payload", ); -async function publish(payload: v.InferOutput) { +async function publish(payload: v.InferOutput, receipt?: TransactionReceipt) { if (payload.resource === "transaction" && payload.action === "requested") return; + if (receipt?.status === "reverted") return; if (payload.resource === "dispute") return; if (payload.resource === "card" && payload.action === "notification") return; @@ -1243,10 +1261,7 @@ async function publish(payload: v.InferOutput) { if (error instanceof Error && error.message === "WebhookFailed") { debugWebhook(error.cause); } else { - debugWebhook({ - error: error.message, - payload: webhookPayload, - }); + debugWebhook({ error: error.message, payload: webhookPayload }); } } throw error; @@ -1293,6 +1308,7 @@ async function publish(payload: v.InferOutput) { return sendWebhook( v.parse(Webhook, { ...payload, + ...(receipt && { receipt }), timestamp, }), webhook.transaction?.[payload.action] ?? webhook.url, @@ -1326,6 +1342,13 @@ const BaseWebhook = v.object({ }), }); +const Receipt = v.pipe( + v.object({ blockNumber: v.bigint(), transactionHash: v.string() }), + v.transform((r) => { + return { ...r, blockNumber: Number(r.blockNumber) }; + }), +); + const Webhook = v.variant("resource", [ v.variant("action", [ v.object({ @@ -1333,6 +1356,7 @@ const Webhook = v.variant("resource", [ timestamp: v.pipe(v.string(), v.isoTimestamp()), resource: v.literal("transaction"), action: v.literal("created"), + receipt: v.optional(Receipt), body: v.object({ ...BaseWebhook.entries, spend: v.object({ @@ -1347,6 +1371,7 @@ const Webhook = v.variant("resource", [ timestamp: v.pipe(v.string(), v.isoTimestamp()), resource: v.literal("transaction"), action: v.literal("updated"), + receipt: v.optional(Receipt), body: v.object({ ...BaseWebhook.entries, spend: v.object({ @@ -1366,6 +1391,7 @@ const Webhook = v.variant("resource", [ timestamp: v.pipe(v.string(), v.isoTimestamp()), resource: v.literal("transaction"), action: v.literal("completed"), + receipt: v.optional(Receipt), body: v.object({ ...BaseWebhook.entries, spend: v.object({ diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index feb6d2753..ca7a6d9f3 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -2229,11 +2229,15 @@ describe("webhooks", () => { it("forwards transaction created", async () => { const cardId = `${webhookAccount}-card`; - - const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - } as Response); + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200 } as Response; + } + return fetch(url, init); + }); await appClient.index.$post({ ...transactionCreated, @@ -2242,12 +2246,17 @@ describe("webhooks", () => { body: { ...transactionCreated.json.body, id: cardId, - spend: { ...transactionCreated.json.body.spend, cardId, userId: webhookAccount }, + spend: { + ...transactionCreated.json.body.spend, + cardId, + userId: webhookAccount, + amount: 100, + authorizedAt: new Date().toISOString(), + }, }, }, }); - - await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + await vi.waitUntil(() => publish, 60_000); const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); @@ -2258,10 +2267,15 @@ describe("webhooks", () => { vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); const cardId = `${webhookAccount}-card`; - const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - } as Response); + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200 } as Response; + } + return fetch(url, init); + }); await appClient.index.$post({ ...transactionUpdated, @@ -2269,13 +2283,20 @@ describe("webhooks", () => { ...transactionUpdated.json, body: { ...transactionUpdated.json.body, - id: cardId, - spend: { ...transactionUpdated.json.body.spend, cardId, userId: webhookAccount }, + id: "forward-transaction-updated", + spend: { + ...transactionUpdated.json.body.spend, + cardId, + userId: webhookAccount, + authorizedAt: new Date().toISOString(), + status: "pending", + authorizationUpdateAmount: 98, + }, }, }, }); - await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + await vi.waitUntil(() => publish, 60_000); const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); @@ -2286,10 +2307,32 @@ describe("webhooks", () => { vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); const cardId = `${webhookAccount}-card`; - const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ - ok: true, - status: 200, - } as Response); + const fetch = globalThis.fetch; + let publishCounter = 0; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publishCounter++; + return { ok: true, status: 200 } as Response; + } + return fetch(url, init); + }); + await appClient.index.$post({ + ...transactionCreated, + json: { + ...transactionCreated.json, + body: { + ...transactionCreated.json.body, + id: "forward-transaction-completed", + spend: { + ...transactionCreated.json.body.spend, + cardId, + userId: webhookAccount, + amount: 99, + authorizedAt: new Date().toISOString(), + }, + }, + }, + }); await appClient.index.$post({ ...transactionCompleted, @@ -2297,14 +2340,22 @@ describe("webhooks", () => { ...transactionCompleted.json, body: { ...transactionCompleted.json.body, - id: cardId, - spend: { ...transactionCompleted.json.body.spend, cardId, userId: webhookAccount }, + id: "forward-transaction-completed", + spend: { + ...transactionCompleted.json.body.spend, + cardId, + userId: webhookAccount, + postedAt: new Date().toISOString(), + status: "completed", + amount: 99, + authorizedAmount: 99, + }, }, }, }); - await vi.waitUntil(() => mockFetch.mock.calls.length > 1, 10_000); - const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + await vi.waitUntil(() => publishCounter > 1, 60_000); + const options = mockFetch.mock.calls.filter(([url]) => url === "https://exa.test")[1]?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); diff --git a/server/utils/keeper.ts b/server/utils/keeper.ts index 0b767c0d7..f894d3b65 100644 --- a/server/utils/keeper.ts +++ b/server/utils/keeper.ts @@ -59,6 +59,7 @@ export function extender(keeper: WalletClient MaybePromise) | string[]; level?: "error" | "warning" | ((reason: string, error: unknown) => "error" | "warning" | false) | false; onHash?: (hash: Hash) => MaybePromise; + onReceipt?: (receipt: TransactionReceipt) => MaybePromise; }, ) => withScope((scope) => @@ -147,6 +148,9 @@ export function extender(keeper: WalletClient + captureException(error, { level: "error" }), + ); const trace = await startSpan({ name: "trace transaction", op: "tx.trace" }, () => withRetry(() => traceClient.traceTransaction(hash), { delay: 1000, From 1e5b05082460bc9effc63459126b8563f2f75835 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Tue, 11 Nov 2025 14:08:03 -0300 Subject: [PATCH 06/16] =?UTF-8?q?=E2=9C=A8=20server:=20add=20merchant=20ca?= =?UTF-8?q?tegory=20code=20to=20webhooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/src/content/docs/webhooks.md | 3 +++ server/hooks/panda.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index a00b24d8c..7f82d8b2b 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -546,6 +546,7 @@ The onchain receipt will be present only if a onchain transaction is necessary. | body.spend.merchantCity? | string | The merchant city | "San Francisco" | | body.spend.merchantCountry? | string | The merchant country | "US" | | body.spend.merchantCategory? | string | The merchant category | "5814 - Quick Payment Service-Fast Food Restaurants" | +| body.spend.merchantCategoryCode? | string | The merchant category code | "5599" | | body.spend.merchantName | string | The merchant name | SQ *BLUE BOTTLE COFFEE | | body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | | body.spend.authorizedAt | string | Time when purchase was authorized in ISO 8601 | 2025-08-06T20:29:23.288Z | @@ -576,6 +577,7 @@ Triggered for events such as incremental authorizations or reversals (a type of | body.spend.merchantCity? | string | city of the merchant | SAN FRANCISCO | | body.spend.merchantCountry? | string | country of the merchant | US | | body.spend.merchantCategory? | string | category of the merchant | 4121 - Taxicabs and Limousines | +| body.spend.merchantCategoryCode? | string | The merchant category code | "5599" | | body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | | body.spend.merchantName | string | name of the merchant | UBER *TRIP | | body.spend.authorizedAt | string | time when purchase was authorized in ISO 8601 | 2025-08-10T04:28:39.547Z | @@ -609,6 +611,7 @@ if an onchain transaction is necessary. | body.spend.merchantCity? | string | city of the merchant | CAP.FEDERAL | | body.spend.merchantCountry? | string | country of the merchant | AR | | body.spend.merchantCategory? | string | category of the merchant | Recreation Services | +| body.spend.merchantCategoryCode? | string | The merchant category code | "5599" | | body.spend.merchantName | string | name of the merchant | JOCKEY CLUB | | body.spend.merchantId? | string | Id of the merchant | 550e8400-e29b-41d4-a716-446655440000 | | body.spend.authorizedAt | string | time when purchase was authorized in ISO 8601 | 2025-08-08T17:55:14.312Z | diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 9f81d21a4..0b8a30897 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1335,6 +1335,7 @@ const BaseWebhook = v.object({ merchantCity: v.nullish(v.pipe(v.string(), v.trim())), merchantCountry: v.nullish(v.pipe(v.string(), v.trim())), merchantCategory: v.nullish(v.pipe(v.string(), v.trim())), + merchantCategoryCode: v.string(), merchantName: v.pipe(v.string(), v.trim()), authorizedAt: v.optional(v.pipe(v.string(), v.isoTimestamp())), authorizedAmount: v.nullish(v.number()), From d96fc5d4a9c399be56bc04450c2a6def2531fb1d Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 26 Nov 2025 11:40:07 -0300 Subject: [PATCH 07/16] =?UTF-8?q?=F0=9F=90=9B=20server:=20fix=20webhook=20?= =?UTF-8?q?retries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/long-moons-brake.md | 5 +++++ server/hooks/panda.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/long-moons-brake.md diff --git a/.changeset/long-moons-brake.md b/.changeset/long-moons-brake.md new file mode 100644 index 000000000..adba8b5e5 --- /dev/null +++ b/.changeset/long-moons-brake.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix webhook retries diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 0b8a30897..37b001cc0 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1242,7 +1242,7 @@ async function publish(payload: v.InferOutput, receipt?: Transac }, { delay: ({ count }) => Math.trunc(1 << count) * 500, - retryCount: domain === "web.exactly.app" ? 20 : 3, + retryCount: domain === "base-sepolia.exactly.app" ? 3 : 20, shouldRetry: ({ error }) => { if (error instanceof Error) { return error.message === "WebhookFailed" || error.name === "TimeoutError"; From 6960079cf1585f41f30c1e1e36a6a4625f8b656f Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 4 Feb 2026 09:33:19 -0300 Subject: [PATCH 08/16] =?UTF-8?q?=F0=9F=90=9B=20server:=20fix=20user=20web?= =?UTF-8?q?hook=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/loud-shoes-visit.md | 5 +++++ server/hooks/panda.ts | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 .changeset/loud-shoes-visit.md diff --git a/.changeset/loud-shoes-visit.md b/.changeset/loud-shoes-visit.md new file mode 100644 index 000000000..2ab24c16d --- /dev/null +++ b/.changeset/loud-shoes-visit.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix user webhook routing diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 37b001cc0..0e347df18 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1299,11 +1299,10 @@ async function publish(payload: v.InferOutput, receipt?: Transac timestamp, body: { ...payload.body, credentialId: user.id }, }), - webhook.card?.[payload.action] ?? webhook.url, + webhook.user?.[payload.action] ?? webhook.url, webhook.secret, ); case "card": - // falls through case "transaction": return sendWebhook( v.parse(Webhook, { From 9b7054d10058ed2f33cc4301755fd317e55fd6a2 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 1 Apr 2026 16:42:28 -0300 Subject: [PATCH 09/16] =?UTF-8?q?=E2=9C=A8=20server:=20add=20card=20webhoo?= =?UTF-8?q?k=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/hooks/panda.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 0e347df18..464a1f16d 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1303,6 +1303,20 @@ async function publish(payload: v.InferOutput, receipt?: Transac webhook.secret, ); case "card": + return sendWebhook( + v.parse(Webhook, { + ...payload, + timestamp, + body: { + ...payload.body, + status: { active: "ACTIVE", locked: "FROZEN", canceled: "DELETED", notActivated: "INACTIVE" }[ + payload.body.status + ], + }, + }), + webhook.card?.[payload.action] ?? webhook.url, + webhook.secret, + ); case "transaction": return sendWebhook( v.parse(Webhook, { @@ -1417,8 +1431,8 @@ const Webhook = v.variant("resource", [ amount: v.number(), frequency: v.picklist(["per24HourPeriod", "per7DayPeriod", "per30DayPeriod", "perYearPeriod"]), }), - status: v.picklist(["notActivated", "active", "locked", "canceled"]), - tokenWallets: v.union([v.array(v.literal("Apple")), v.array(v.literal("Google Pay"))]), + status: v.picklist(["ACTIVE", "FROZEN", "DELETED", "INACTIVE"]), + tokenWallets: v.nullish(v.union([v.array(v.literal("Apple")), v.array(v.literal("Google Pay"))])), }), }), v.object({ From 1edf9be63b5469f2dab2e79c12904bc00facb23b Mon Sep 17 00:00:00 2001 From: mainqueg Date: Tue, 6 Jan 2026 11:24:35 -0300 Subject: [PATCH 10/16] =?UTF-8?q?=F0=9F=90=9B=20server:=20fix=20update=20c?= =?UTF-8?q?ard=20webhook=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/dry-peas-ring.md | 6 ++++ docs/src/content/docs/webhooks.md | 4 +-- server/test/hooks/panda.test.ts | 48 ++++++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 .changeset/dry-peas-ring.md diff --git a/.changeset/dry-peas-ring.md b/.changeset/dry-peas-ring.md new file mode 100644 index 000000000..64a76b183 --- /dev/null +++ b/.changeset/dry-peas-ring.md @@ -0,0 +1,6 @@ +--- +"@exactly/server": patch +"@exactly/docs": patch +--- + +🐛 fix update card webhook schema diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index 7f82d8b2b..38173e729 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -650,5 +650,5 @@ This webhook is currently triggered when a user adds their card to a digital wal | body.last4 | string | last 4 digits of the card | 7392 | | body.limit.amount | number | spending limit amount | 1000000 | | body.limit.frequency | "per24HourPeriod" \| "per7DayPeriod" \| "per30DayPeriod" \| "perYearPeriod" | frequency of the spending limit | per7DayPeriod | -| body.status | "notActivated" \| "active" \| "locked" \| "canceled" | current status of the card | active | -| body.tokenWallets | ["Apple"] \| ["Google Pay"] | array of token wallets | ["Apple"] | +| body.status | "ACTIVE" \| "FROZEN" \| "DELETED" | current status of the card | ACTIVE | +| body.tokenWallets | ["Apple"] \| ["Google Pay"] \| undefined | array of token wallets | ["Apple"] | diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index ca7a6d9f3..18bd07add 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -2361,7 +2361,7 @@ describe("webhooks", () => { expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); - it("forwards card updated", async () => { + it("forwards card updated active", async () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, status: 200, @@ -2389,6 +2389,33 @@ describe("webhooks", () => { expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); + it("forwards card updated canceled", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + json() { + return Promise.resolve({}); + }, + } as Response); + + await appClient.index.$post({ + ...cardCanceled, + json: { + ...cardCanceled.json, + body: { + ...cardCanceled.json.body, + userId: webhookAccount, + }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); + + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + }); + it("forwards user updated", async () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, @@ -2470,6 +2497,25 @@ const cardUpdated = { }, } as const; +const cardCanceled = { + header: { signature: "panda-signature" }, + json: { + id: "31740000-bd68-40c8-a400-5a0131f58800", + resource: "card", + action: "updated", + body: { + id: "f3d8a9c2-4e7b-4a1c-9f2e-8d5c6b3a7e9f", + userId: "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + type: "virtual", + status: "canceled", + limit: { amount: 1_000_000, frequency: "per7DayPeriod" }, + last4: "7392", + expirationMonth: "11", + expirationYear: "2029", + }, + }, +} as const; + const userUpdated = { header: { signature: "panda-signature" }, json: { From c8072e41ef1f74ec909ee2985bcc5e2f63188969 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Mon, 2 Mar 2026 16:30:56 -0300 Subject: [PATCH 11/16] =?UTF-8?q?=F0=9F=90=9B=20server:=20fix=20webhook=20?= =?UTF-8?q?logging=20for=20text=20response?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/fifty-friends-bet.md | 5 +++ server/hooks/panda.ts | 16 +++++++- server/test/hooks/panda.test.ts | 71 ++++++++++++++++++++++++++++----- 3 files changed, 81 insertions(+), 11 deletions(-) create mode 100644 .changeset/fifty-friends-bet.md diff --git a/.changeset/fifty-friends-bet.md b/.changeset/fifty-friends-bet.md new file mode 100644 index 000000000..b3d74ac32 --- /dev/null +++ b/.changeset/fifty-friends-bet.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +🐛 fix webhook logging for text response diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 464a1f16d..1b3584c2d 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1234,7 +1234,13 @@ async function publish(payload: v.InferOutput, receipt?: Transac throw new Error("WebhookFailed", { cause: { code: response.status, - response: await response.json(), + response: await response.text().then((text) => { + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } + }), payload: webhookPayload, }, }); @@ -1253,7 +1259,13 @@ async function publish(payload: v.InferOutput, receipt?: Transac ); debugWebhook({ code: result.status, - response: await result.json(), + response: await result.text().then((text) => { + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } + }), payload: webhookPayload, }); } catch (error) { diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 18bd07add..a4eb2799b 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -2234,7 +2234,7 @@ describe("webhooks", () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { if (url === "https://exa.test") { publish = true; - return { ok: true, status: 200 } as Response; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; } return fetch(url, init); }); @@ -2272,7 +2272,7 @@ describe("webhooks", () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { if (url === "https://exa.test") { publish = true; - return { ok: true, status: 200 } as Response; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; } return fetch(url, init); }); @@ -2312,7 +2312,7 @@ describe("webhooks", () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { if (url === "https://exa.test") { publishCounter++; - return { ok: true, status: 200 } as Response; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; } return fetch(url, init); }); @@ -2365,8 +2365,8 @@ describe("webhooks", () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, status: 200, - json() { - return Promise.resolve({}); + text() { + return Promise.resolve("{}"); }, } as Response); @@ -2393,8 +2393,8 @@ describe("webhooks", () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, status: 200, - json() { - return Promise.resolve({}); + text() { + return Promise.resolve("{}"); }, } as Response); @@ -2420,8 +2420,8 @@ describe("webhooks", () => { const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true, status: 200, - json() { - return Promise.resolve({}); + text() { + return Promise.resolve("{}"); }, } as Response); @@ -2442,6 +2442,52 @@ describe("webhooks", () => { expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); }); + + it("logs text on webhook ok response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve("OK"), + } as unknown as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { + ...cardUpdated.json.body, + userId: webhookAccount, + tokenWallets: ["Apple"], + }, + }, + }); + + await vi.waitUntil(() => webhookLogger.mock.calls.length > 0, 10_000); + expect(webhookLogger).toHaveBeenCalledWith(expect.objectContaining({ response: "OK" })); + }); + + it("logs json on webhook ok response", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify({ status: 200, message: "OK" })), + } as unknown as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { + ...cardUpdated.json.body, + userId: webhookAccount, + tokenWallets: ["Apple"], + }, + }, + }); + + await vi.waitUntil(() => webhookLogger.mock.calls.length > 0, 10_000); + expect(webhookLogger).toHaveBeenCalledWith(expect.objectContaining({ response: { status: 200, message: "OK" } })); + }); }); const authorization = { @@ -2755,6 +2801,13 @@ const userResponseTemplate = { vi.mock("@sentry/node", { spy: true }); +const webhookLogger = vi.hoisted(() => vi.fn()); + +vi.mock("debug", () => { + const createDebug = vi.fn().mockReturnValueOnce(vi.fn()).mockReturnValueOnce(webhookLogger); + return { default: createDebug }; +}); + afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); From 3448230c835708d833c9c294f7b235d72a21b700 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 1 Apr 2026 15:36:29 -0300 Subject: [PATCH 12/16] =?UTF-8?q?=F0=9F=93=9D=20docs:=20fix=20webhook=20ma?= =?UTF-8?q?rkdown=20from=20pr=20review=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/src/content/docs/webhooks.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index 38173e729..4a280129f 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -290,7 +290,7 @@ Final settlement at $90.00 with status "completed" and timestamp. The final amou "id": "be67eeb7-294a-42d9-b337-77bfad198aad", "type": "spend", "spend": { - "amount": 9000, // notice the partial capture + "amount": 9000, "currency": "usd", "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", "localAmount": 9000, @@ -367,7 +367,7 @@ Final settlement at $110.00 with status "completed" and timestamp. Note that the "id": "be67eeb7-294a-42d9-b337-77bfad198aad", "type": "spend", "spend": { - "amount": 11000, // notice the increase in the amount of settlement + "amount": 11000, "currency": "usd", "cardId": "827c3893-d7c8-46d4-a518-744b016555bc", "localAmount": 11000, @@ -459,7 +459,7 @@ do sanity checks. } } } - ``` +``` #### Transaction Completed @@ -506,18 +506,18 @@ There are 3 types of operations that return funds to the user: reversal, partial This occurs when the user calls an uber, for example. Authorizes $30 but then the travel is cancelled, so exa instantly return the funds to the user in a $30 reversal. This happens before the settlement and can happen many times. Timing: reversals are usually during the same day. -#### Partial capture +### Partial capture This happens when a transaction enters a terminal state, which means no more reversals or other event types are allowed. This is the last event. If the authorized amount is higher than the final amount, funds need to be returned to the user. This looks pretty much like a reversal but also signals to the user that no more assets will be requested or returned as part of the purchase flow. Timing: usually 2 or 3 business days after swiping the card. -#### Refund +### Refund Refunds come after the purchase enters a terminal state and could be associated with the purchase or not. That is not guaranteed, but if it is not the same, using the merchant name to link is suggested. Timing: more than a week. | Operation | Display | Time | | --- | --- | --- | | reversal | purchase details | same day | -| partial | purchase details | 2 or 3 business day | +| partial | purchase details | 2 or 3 business days | | refunds | activity | weeks | ## Event reference @@ -584,7 +584,7 @@ Triggered for events such as incremental authorizations or reversals (a type of | body.spend.authorizedAmount | number | amount authorized | 2499 | | body.spend.authorizationUpdateAmount | number | amount difference authorized. it can be positive in case of status pending or negative if is a reversal. will be declined if was not possible to authorize the increment or decrement of the authorization | 726 | | body.spend.status | "pending" \| "reversed" \| "declined" | current status of the transaction | pending | -| body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | | +| body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | `https://storage.googleapis.com/heron-merchant-assets/icons/mrc_Syjxck7oqeRQxzHAjc9XrD.png` | | body.spend.enrichedMerchantName? | string | name of the enriched merchant | Uber | | body.spend.enrichedMerchantCategory? | string | category of the enriched merchant | Transport - Rides | @@ -617,7 +617,7 @@ if an onchain transaction is necessary. | body.spend.authorizedAt | string | time when purchase was authorized in ISO 8601 | 2025-08-08T17:55:14.312Z | | body.spend.authorizedAmount | number | original authorized amount | 1035 | | body.spend.status | "completed" | final status of the transaction | completed | -| body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | | +| body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | `https://storage.googleapis.com/heron-merchant-assets/icons/mrc_BqxmeYFvJmCprexvXUDF7h.png` | | body.spend.enrichedMerchantName? | string | name of the enriched merchant | Jockey | | body.spend.enrichedMerchantCategory? | string | category of the enriched merchant | Shopping | @@ -650,5 +650,5 @@ This webhook is currently triggered when a user adds their card to a digital wal | body.last4 | string | last 4 digits of the card | 7392 | | body.limit.amount | number | spending limit amount | 1000000 | | body.limit.frequency | "per24HourPeriod" \| "per7DayPeriod" \| "per30DayPeriod" \| "perYearPeriod" | frequency of the spending limit | per7DayPeriod | -| body.status | "ACTIVE" \| "FROZEN" \| "DELETED" | current status of the card | ACTIVE | +| body.status | "ACTIVE" \| "FROZEN" \| "DELETED" \| "INACTIVE" | current status of the card | ACTIVE | | body.tokenWallets | ["Apple"] \| ["Google Pay"] \| undefined | array of token wallets | ["Apple"] | From 0c466e17d7b8b97ebec34080426f40a16bbe2376 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Wed, 1 Apr 2026 16:32:12 -0300 Subject: [PATCH 13/16] =?UTF-8?q?=E2=9C=85=20server:=20verify=20member=20c?= =?UTF-8?q?annot=20access=20webhooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/test/api/webhook.test.ts | 64 +++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/server/test/api/webhook.test.ts b/server/test/api/webhook.test.ts index dd72e5cf9..f93005caa 100644 --- a/server/test/api/webhook.test.ts +++ b/server/test/api/webhook.test.ts @@ -16,9 +16,11 @@ const appClient = testClient(app); const owner = mnemonicToAccount("test test test test test test test test test test test junk"); const integratorAccount = mnemonicToAccount("test test test test test test test test test test test integrator"); +const memberAccount = mnemonicToAccount("test test test test test test test test test test test member"); describe("webhook", () => { const integratorHeaders = new Headers(); + const memberHeaders = new Headers(); describe("authenticated", () => { beforeAll(async () => { @@ -80,6 +82,36 @@ describe("webhook", () => { integratorHeaders.set("cookie", `${integratorResponse.headers.get("set-cookie")}`); const integrator = await auth.api.getSession({ headers: integratorHeaders }); if (!integrator) throw new Error("integrator not found"); + + const memberNonceResult = await auth.api.getSiweNonce({ + body: { walletAddress: memberAccount.address, chainId: chain.id }, + }); + const memberMessage = createSiweMessage({ + statement, + resources: ["https://exactly.github.io/exa"], + nonce: memberNonceResult.nonce, + uri: `https://localhost`, + address: memberAccount.address, + chainId: chain.id, + scheme: "https", + version: "1", + domain: "localhost", + }); + const memberResponse = await auth.api.verifySiweMessage({ + body: { + message: memberMessage, + signature: await memberAccount.signMessage({ message: memberMessage }), + walletAddress: memberAccount.address, + chainId: chain.id, + email: "member@external.com", + }, + request: new Request("https://localhost"), + asResponse: true, + }); + memberHeaders.set("cookie", `${memberResponse.headers.get("set-cookie")}`); + const member = await auth.api.getSession({ headers: memberHeaders }); + if (!member) throw new Error("member not found"); + const externalOrganization = await auth.api.createOrganization({ headers: ownerHeaders, body: { name: "External Organization", slug: "external-organization" }, @@ -90,6 +122,12 @@ describe("webhook", () => { body: { email: integrator.user.email, role: "admin", organizationId: externalOrganization?.id }, }); await auth.api.acceptInvitation({ headers: integratorHeaders, body: { invitationId: integratorInvitation.id } }); + + const memberInvitation = await auth.api.createInvitation({ + headers: ownerHeaders, + body: { email: member.user.email, role: "member", organizationId: externalOrganization?.id }, + }); + await auth.api.acceptInvitation({ headers: memberHeaders, body: { invitationId: memberInvitation.id } }); }); afterEach(async () => { @@ -205,5 +243,31 @@ describe("webhook", () => { expect(getWebhook.status).toBe(200); await expect(getWebhook.json()).resolves.toStrictEqual({}); }); + + describe("member", () => { + it("denies get webhook", async () => { + const response = await appClient.index.$get({}, { headers: { cookie: memberHeaders.get("cookie") ?? "" } }); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no permission" }); + }); + + it("denies create webhook", async () => { + const response = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie: memberHeaders.get("cookie") ?? "" } }, + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no permission" }); + }); + + it("denies delete webhook", async () => { + const response = await appClient.index.$delete( + { json: { name: "test" } }, + { headers: { cookie: memberHeaders.get("cookie") ?? "" } }, + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toStrictEqual({ code: "no permission" }); + }); + }); }); }); From 70cee4c7752b80a55a829ef56271e12811642e05 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Mon, 6 Apr 2026 14:49:29 -0300 Subject: [PATCH 14/16] =?UTF-8?q?=F0=9F=A6=BA=20server:=20send=20webhooks?= =?UTF-8?q?=20only=20to=20public=20destinations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/api/webhook.ts | 45 +++++++-- server/hooks/panda.ts | 1 + server/test/api/webhook.test.ts | 33 ++++++- server/test/hooks/panda.test.ts | 21 ++++- server/test/utils/webhook.test.ts | 149 ++++++++++++++++++++++++++++++ server/utils/webhook.ts | 29 ++++++ 6 files changed, 269 insertions(+), 9 deletions(-) create mode 100644 server/test/utils/webhook.test.ts create mode 100644 server/utils/webhook.ts diff --git a/server/api/webhook.ts b/server/api/webhook.ts index 6e3f989fd..173b37f66 100644 --- a/server/api/webhook.ts +++ b/server/api/webhook.ts @@ -1,15 +1,17 @@ +import { captureException } from "@sentry/core"; import { Mutex } from "async-mutex"; import { eq } from "drizzle-orm"; import { Hono } from "hono"; import { describeRoute } from "hono-openapi"; import { resolver, validator as vValidator } from "hono-openapi/valibot"; import { randomBytes } from "node:crypto"; -import { literal, metadata, object, optional, parse, picklist, pipe, record, string, union } from "valibot"; +import { literal, metadata, object, optional, parse, picklist, pipe, record, string, union, url } from "valibot"; import database, { sources } from "../database"; import authValidator from "../middleware/auth"; import auth from "../utils/auth"; import validatorHook from "../utils/validatorHook"; +import isValid from "../utils/webhook"; const BaseWebhook = object({ url: string(), @@ -128,6 +130,17 @@ export default new Hono() }, }, }, + 400: { + description: "Invalid webhook URL", + content: { + "application/json": { + schema: resolver( + object({ code: pipe(literal("invalid url"), metadata({ examples: ["https://example.com/webhook"] })) }), + { errorMode: "ignore" }, + ), + }, + }, + }, 403: { description: "User doesn't belong to the organization", content: { @@ -148,16 +161,16 @@ export default new Hono() "json", object({ name: string(), - url: string(), + url: pipe(string(), url()), transaction: optional( object({ - created: optional(string()), - updated: optional(string()), - completed: optional(string()), + created: optional(pipe(string(), url())), + updated: optional(pipe(string(), url())), + completed: optional(pipe(string(), url())), }), ), - card: optional(object({ updated: optional(string()) })), - user: optional(object({ updated: optional(string()) })), + card: optional(object({ updated: optional(pipe(string(), url())) })), + user: optional(object({ updated: optional(pipe(string(), url())) })), }), validatorHook(), ), @@ -172,6 +185,24 @@ export default new Hono() }); if (!canCreate) return c.json({ code: "no permission" }, 403); + try { + await Promise.all( + [ + payload.url, + payload.transaction?.created, + payload.transaction?.updated, + payload.transaction?.completed, + payload.card?.updated, + payload.user?.updated, + ] + .filter((u): u is string => u !== undefined) + .map((u) => isValid(u)), + ); + } catch (error) { + captureException(error, { level: "error" }); + return c.json({ code: "invalid url" as const }, 400); + } + const mutex = mutexes.get(id) ?? createMutex(id); return mutex.runExclusive(async () => { const source = await database.query.sources.findFirst({ diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 1b3584c2d..1daad7933 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1223,6 +1223,7 @@ async function publish(payload: v.InferOutput, receipt?: Transac async () => { const response = await fetch(url, { method: "POST", + redirect: "error", headers: { "Content-Type": "application/json", Signature: createHmac("sha256", secret).update(JSON.stringify(webhookPayload)).digest("hex"), diff --git a/server/test/api/webhook.test.ts b/server/test/api/webhook.test.ts index f93005caa..2fd15b366 100644 --- a/server/test/api/webhook.test.ts +++ b/server/test/api/webhook.test.ts @@ -2,9 +2,10 @@ import "../mocks/sentry"; import { eq } from "drizzle-orm"; import { testClient } from "hono/testing"; +import { resolve4, resolve6 } from "node:dns/promises"; import { mnemonicToAccount } from "viem/accounts"; import { createSiweMessage } from "viem/siwe"; -import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import chain from "@exactly/common/generated/chain"; @@ -12,6 +13,11 @@ import app from "../../api/webhook"; import database, { sources } from "../../database"; import auth from "../../utils/auth"; +vi.mock("node:dns/promises", () => ({ + resolve4: vi.fn<() => Promise>(), + resolve6: vi.fn<() => Promise>(), +})); + const appClient = testClient(app); const owner = mnemonicToAccount("test test test test test test test test test test test junk"); @@ -130,6 +136,11 @@ describe("webhook", () => { await auth.api.acceptInvitation({ headers: memberHeaders, body: { invitationId: memberInvitation.id } }); }); + beforeEach(() => { + vi.mocked(resolve4).mockResolvedValue(["93.184.216.34"]); + vi.mocked(resolve6).mockResolvedValue([]); + }); + afterEach(async () => { const organizations = await auth.api.listOrganizations({ headers: integratorHeaders }); const id = organizations[0]?.id ?? ""; @@ -244,6 +255,26 @@ describe("webhook", () => { await expect(getWebhook.json()).resolves.toStrictEqual({}); }); + it("rejects url resolving to private ip", async () => { + vi.mocked(resolve4).mockResolvedValue(["10.0.0.1"]); + const response = await appClient.index.$post( + { json: { name: "test", url: "https://test.com" } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "invalid url" }); + }); + + it("rejects event-specific url resolving to private ip", async () => { + vi.mocked(resolve4).mockResolvedValueOnce(["93.184.216.34"]).mockResolvedValueOnce(["169.254.169.254"]); + const response = await appClient.index.$post( + { json: { name: "test", url: "https://test.com", transaction: { created: "https://evil.internal" } } }, + { headers: { cookie: integratorHeaders.get("cookie") ?? "" } }, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toStrictEqual({ code: "invalid url" }); + }); + describe("member", () => { it("denies get webhook", async () => { const response = await appClient.index.$get({}, { headers: { cookie: memberHeaders.get("cookie") ?? "" } }); diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index a4eb2799b..9f4e05b36 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -2488,6 +2488,26 @@ describe("webhooks", () => { await vi.waitUntil(() => webhookLogger.mock.calls.length > 0, 10_000); expect(webhookLogger).toHaveBeenCalledWith(expect.objectContaining({ response: { status: 200, message: "OK" } })); }); + + it("passes redirect error option to fetch", async () => { + const mockFetch = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + status: 200, + text: () => Promise.resolve("OK"), + } as unknown as Response); + + await appClient.index.$post({ + ...cardUpdated, + json: { + ...cardUpdated.json, + body: { ...cardUpdated.json.body, userId: webhookAccount, tokenWallets: ["Apple"] }, + }, + }); + + await vi.waitUntil(() => mockFetch.mock.calls.length > 0, 10_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + expect(options).toStrictEqual(expect.objectContaining({ redirect: "error" })); + }); }); const authorization = { @@ -2800,7 +2820,6 @@ const userResponseTemplate = { } as const; vi.mock("@sentry/node", { spy: true }); - const webhookLogger = vi.hoisted(() => vi.fn()); vi.mock("debug", () => { diff --git a/server/test/utils/webhook.test.ts b/server/test/utils/webhook.test.ts new file mode 100644 index 000000000..b662c20a5 --- /dev/null +++ b/server/test/utils/webhook.test.ts @@ -0,0 +1,149 @@ +import { resolve4, resolve6 } from "node:dns/promises"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import isValid from "../../utils/webhook"; + +vi.mock("node:dns/promises", () => ({ + resolve4: vi.fn<() => Promise>(), + resolve6: vi.fn<() => Promise>(), +})); + +describe("validateWebhookUrl", () => { + beforeEach(() => { + vi.mocked(resolve4).mockResolvedValue(["93.184.216.34"]); + vi.mocked(resolve6).mockResolvedValue([]); + }); + + it("accepts valid https url with public ip", async () => { + await expect(isValid("https://example.com/webhook")).resolves.toBeUndefined(); + }); + + it("rejects http scheme", async () => { + await expect(isValid("http://example.com")).rejects.toThrow("url must use https"); + }); + + it("rejects ftp scheme", async () => { + await expect(isValid("ftp://example.com/file")).rejects.toThrow("url must use https"); + }); + + it("rejects malformed url", async () => { + await expect(isValid("not-a-url")).rejects.toThrow(); + }); + + it("rejects when dns does not resolve", async () => { + vi.mocked(resolve4).mockRejectedValue(new Error("ENOTFOUND")); + vi.mocked(resolve6).mockRejectedValue(new Error("ENOTFOUND")); + await expect(isValid("https://nonexistent.invalid")).rejects.toThrow("url does not resolve"); + }); + + it("rejects 127.0.0.0/8", async () => { + vi.mocked(resolve4).mockResolvedValue(["127.0.0.1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects 10.0.0.0/8", async () => { + vi.mocked(resolve4).mockResolvedValue(["10.0.0.1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects 172.16.0.0/12", async () => { + vi.mocked(resolve4).mockResolvedValue(["172.16.0.1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("accepts 172.15.255.255", async () => { + vi.mocked(resolve4).mockResolvedValue(["172.15.255.255"]); + await expect(isValid("https://example.com")).resolves.toBeUndefined(); + }); + + it("accepts 172.32.0.0", async () => { + vi.mocked(resolve4).mockResolvedValue(["172.32.0.0"]); + await expect(isValid("https://example.com")).resolves.toBeUndefined(); + }); + + it("rejects 192.168.0.0/16", async () => { + vi.mocked(resolve4).mockResolvedValue(["192.168.1.1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects 169.254.0.0/16", async () => { + vi.mocked(resolve4).mockResolvedValue(["169.254.169.254"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects 0.0.0.0", async () => { + vi.mocked(resolve4).mockResolvedValue(["0.0.0.0"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects ::1", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["::1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects fc00::/7", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["fd12::1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects fe80::/10", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["fe80::1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects fe90::1 (fe80::/10)", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["fe90::1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects fea0::1 (fe80::/10)", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["fea0::1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects feb0::1 (fe80::/10)", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["feb0::1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("accepts fec0::1 (outside fe80::/10)", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["fec0::1"]); + await expect(isValid("https://example.com")).resolves.toBeUndefined(); + }); + + it("rejects ::ffff-mapped private ipv4", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["::ffff:10.0.0.1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("rejects when any address is private", async () => { + vi.mocked(resolve4).mockResolvedValue(["93.184.216.34", "10.0.0.1"]); + await expect(isValid("https://example.com")).rejects.toThrow("url resolves to private address"); + }); + + it("accepts public ipv6", async () => { + vi.mocked(resolve4).mockResolvedValue([]); + vi.mocked(resolve6).mockResolvedValue(["2001:db8::1"]); + await expect(isValid("https://example.com")).resolves.toBeUndefined(); + }); + + it("resolves when only ipv6 succeeds", async () => { + vi.mocked(resolve4).mockRejectedValue(new Error("ENOTFOUND")); + vi.mocked(resolve6).mockResolvedValue(["2001:db8::1"]); + await expect(isValid("https://example.com")).resolves.toBeUndefined(); + }); + + it("resolves when only ipv4 succeeds", async () => { + vi.mocked(resolve4).mockResolvedValue(["93.184.216.34"]); + vi.mocked(resolve6).mockRejectedValue(new Error("ENOTFOUND")); + await expect(isValid("https://example.com")).resolves.toBeUndefined(); + }); +}); diff --git a/server/utils/webhook.ts b/server/utils/webhook.ts new file mode 100644 index 000000000..3e32dfdff --- /dev/null +++ b/server/utils/webhook.ts @@ -0,0 +1,29 @@ +import { resolve4, resolve6 } from "node:dns/promises"; + +export default async function isValid(raw: string) { + const { hostname, protocol } = new URL(raw); + if (protocol !== "https:") throw new Error("url must use https"); + const [v4, v6] = await Promise.all([resolve4(hostname).catch(() => []), resolve6(hostname).catch(() => [])]); + const addresses = [...v4, ...v6]; + if (addresses.length === 0) throw new Error("url does not resolve"); + + if ( + addresses + .map((ip) => (ip.startsWith("::ffff:") ? ip.slice(7).toLowerCase() : ip.toLowerCase())) + .some((lowerIp) => isPrivate(lowerIp)) + ) + throw new Error("url resolves to private address"); +} + +function isPrivate(ip: string) { + if (ip.includes(":")) return ip === "::1" || /^fe[89ab]/.test(ip) || ip.startsWith("fc") || ip.startsWith("fd"); + const parts = ip.split(".").map(Number); + return ( + parts[0] === 127 || + parts[0] === 10 || + (parts[0] === 172 && parts[1] !== undefined && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254) || + ip === "0.0.0.0" + ); +} From 230d5af57e8e20c8bb6f14151b2a325f02a15eb6 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 26 Mar 2026 10:37:45 -0300 Subject: [PATCH 15/16] =?UTF-8?q?=E2=9C=A8=20server:=20forward=20exchange?= =?UTF-8?q?=20rate=20to=20webhooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/shy-foxes-trade.md | 5 +++ docs/src/content/docs/webhooks.md | 2 ++ server/hooks/panda.ts | 11 ++++++ server/test/hooks/panda.test.ts | 60 ++++++++++++++++++++++++++++--- 4 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 .changeset/shy-foxes-trade.md diff --git a/.changeset/shy-foxes-trade.md b/.changeset/shy-foxes-trade.md new file mode 100644 index 000000000..9a0a6491e --- /dev/null +++ b/.changeset/shy-foxes-trade.md @@ -0,0 +1,5 @@ +--- +"@exactly/server": patch +--- + +✨ forward exchange rate to webhooks diff --git a/docs/src/content/docs/webhooks.md b/docs/src/content/docs/webhooks.md index 4a280129f..fd5e6b8cf 100644 --- a/docs/src/content/docs/webhooks.md +++ b/docs/src/content/docs/webhooks.md @@ -553,6 +553,7 @@ The onchain receipt will be present only if a onchain transaction is necessary. | body.spend.authorizedAmount | integer | The authorized amount | 100 | | body.spend.status | "pending" \| "declined" | Can be pending or declined. In case of declined, the field `declinedReason` has the reason | pending | | body.spend.declinedReason? | string | Decline message | webhook declined | +| body.spend.exchangeRate? | number | Present when `currency` differs from `localCurrency`. The exchange rate applied to the transaction | 1.1806900825 | ### Transaction updated event @@ -620,6 +621,7 @@ if an onchain transaction is necessary. | body.spend.enrichedMerchantIcon? | string | url of the enriched merchant icon | `https://storage.googleapis.com/heron-merchant-assets/icons/mrc_BqxmeYFvJmCprexvXUDF7h.png` | | body.spend.enrichedMerchantName? | string | name of the enriched merchant | Jockey | | body.spend.enrichedMerchantCategory? | string | category of the enriched merchant | Shopping | +| body.spend.exchangeRate? | number | Present when `currency` differs from `localCurrency`. The exchange rate applied to the transaction | 1.1806900825 | ### User updated diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 1daad7933..849bcfbfc 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -107,6 +107,7 @@ const Transaction = v.variant("action", [ ...BaseTransaction.entries.spend.entries, status: v.picklist(["pending", "declined"]), declinedReason: v.optional(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), @@ -156,6 +157,7 @@ const Transaction = v.variant("action", [ enrichedMerchantIcon: v.nullish(v.string()), enrichedMerchantName: v.nullish(v.string()), enrichedMerchantCategory: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), @@ -1336,6 +1338,13 @@ async function publish(payload: v.InferOutput, receipt?: Transac ...payload, ...(receipt && { receipt }), timestamp, + ...(payload.action !== "updated" && + payload.body.spend.currency !== payload.body.spend.localCurrency && { + body: { + ...payload.body, + spend: { ...payload.body.spend, exchangeRate: payload.body.spend.exchangeRate }, + }, + }), }), webhook.transaction?.[payload.action] ?? webhook.url, webhook.secret, @@ -1390,6 +1399,7 @@ const Webhook = v.variant("resource", [ ...BaseWebhook.entries.spend.entries, status: v.picklist(["pending", "declined"]), declinedReason: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), @@ -1428,6 +1438,7 @@ const Webhook = v.variant("resource", [ enrichedMerchantIcon: v.nullish(v.string()), enrichedMerchantName: v.nullish(v.string()), enrichedMerchantCategory: v.nullish(v.string()), + exchangeRate: v.optional(v.number()), }), }), }), diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 9f4e05b36..7d0a3aba4 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -2227,7 +2227,7 @@ describe("webhooks", () => { afterEach(() => vi.resetAllMocks()); - it("forwards transaction created", async () => { + it("forwards transaction created with exchangeRate", async () => { const cardId = `${webhookAccount}-card`; const fetch = globalThis.fetch; let publish = false; @@ -2251,6 +2251,9 @@ describe("webhooks", () => { cardId, userId: webhookAccount, amount: 100, + localAmount: 85, + localCurrency: "eur", + exchangeRate: 1.176_470_588_2, authorizedAt: new Date().toISOString(), }, }, @@ -2259,11 +2262,48 @@ describe("webhooks", () => { await vi.waitUntil(() => publish, 60_000); const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); + expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).toMatchObject({ + body: { spend: { exchangeRate: 1.176_470_588_2 } }, + }); + }); + + it("forwards transaction created without exchangeRate when same currency", async () => { + const cardId = `${webhookAccount}-card`; + const fetch = globalThis.fetch; + let publish = false; + const mockFetch = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { + if (url === "https://exa.test") { + publish = true; + return { ok: true, status: 200, text: () => Promise.resolve("OK") } as Response; + } + return fetch(url, init); + }); + await appClient.index.$post({ + ...transactionCreated, + json: { + ...transactionCreated.json, + body: { + ...transactionCreated.json.body, + id: "same-currency-tx", + spend: { + ...transactionCreated.json.body.spend, + cardId, + userId: webhookAccount, + authorizedAt: new Date().toISOString(), + }, + }, + }, + }); + await vi.waitUntil(() => publish, 60_000); + const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; + const headers = parse(object({ Signature: string() }), options?.headers); expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).not.toHaveProperty("body.spend.exchangeRate"); }); - it("forwards transaction updated", async () => { + it("forwards transaction updated without exchangeRate", async () => { vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); const cardId = `${webhookAccount}-card`; @@ -2288,6 +2328,8 @@ describe("webhooks", () => { ...transactionUpdated.json.body.spend, cardId, userId: webhookAccount, + localCurrency: "eur", + localAmount: 6800, authorizedAt: new Date().toISOString(), status: "pending", authorizationUpdateAmount: 98, @@ -2299,11 +2341,11 @@ describe("webhooks", () => { await vi.waitUntil(() => publish, 60_000); const options = mockFetch.mock.calls.find(([url]) => url === "https://exa.test")?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).not.toHaveProperty("body.spend.exchangeRate"); }); - it("forwards transaction completed", async () => { + it("forwards transaction completed with exchangeRate", async () => { vi.spyOn(panda, "getUser").mockResolvedValue(userResponseTemplate); const cardId = `${webhookAccount}-card`; @@ -2328,6 +2370,9 @@ describe("webhooks", () => { cardId, userId: webhookAccount, amount: 99, + localAmount: 84, + localCurrency: "eur", + exchangeRate: 1.178_571_428_6, authorizedAt: new Date().toISOString(), }, }, @@ -2348,6 +2393,9 @@ describe("webhooks", () => { postedAt: new Date().toISOString(), status: "completed", amount: 99, + localAmount: 84, + localCurrency: "eur", + exchangeRate: 1.178_571_428_6, authorizedAmount: 99, }, }, @@ -2357,8 +2405,10 @@ describe("webhooks", () => { await vi.waitUntil(() => publishCounter > 1, 60_000); const options = mockFetch.mock.calls.filter(([url]) => url === "https://exa.test")[1]?.[1]; const headers = parse(object({ Signature: string() }), options?.headers); - expect(createHmac("sha256", secret).update(parse(string(), options?.body)).digest("hex")).toBe(headers.Signature); + expect(JSON.parse(parse(string(), options?.body))).toMatchObject({ + body: { spend: { exchangeRate: 1.178_571_428_6 } }, + }); }); it("forwards card updated active", async () => { From 2598ecee6b52dbe4aab6f23b7328cad68c644a31 Mon Sep 17 00:00:00 2001 From: nfmelendez Date: Thu, 26 Mar 2026 15:55:56 -0300 Subject: [PATCH 16/16] =?UTF-8?q?=F0=9F=94=8A=20server:=20fix=20multiline?= =?UTF-8?q?=20webhook=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/hooks/panda.ts | 8 ++++---- server/test/hooks/panda.test.ts | 7 +++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/server/hooks/panda.ts b/server/hooks/panda.ts index 849bcfbfc..c889d545b 100644 --- a/server/hooks/panda.ts +++ b/server/hooks/panda.ts @@ -1260,7 +1260,7 @@ async function publish(payload: v.InferOutput, receipt?: Transac }, }, ); - debugWebhook({ + debugWebhook("%j", { code: result.status, response: await result.text().then((text) => { try { @@ -1273,10 +1273,10 @@ async function publish(payload: v.InferOutput, receipt?: Transac }); } catch (error) { if (error instanceof Error) { - if (error instanceof Error && error.message === "WebhookFailed") { - debugWebhook(error.cause); + if (error.message === "WebhookFailed") { + debugWebhook("%j", error.cause); } else { - debugWebhook({ error: error.message, payload: webhookPayload }); + debugWebhook("%j", { error: error.message, payload: webhookPayload }); } } throw error; diff --git a/server/test/hooks/panda.test.ts b/server/test/hooks/panda.test.ts index 7d0a3aba4..40300ecfa 100644 --- a/server/test/hooks/panda.test.ts +++ b/server/test/hooks/panda.test.ts @@ -2513,7 +2513,7 @@ describe("webhooks", () => { }); await vi.waitUntil(() => webhookLogger.mock.calls.length > 0, 10_000); - expect(webhookLogger).toHaveBeenCalledWith(expect.objectContaining({ response: "OK" })); + expect(webhookLogger).toHaveBeenCalledWith("%j", expect.objectContaining({ response: "OK" })); }); it("logs json on webhook ok response", async () => { @@ -2536,7 +2536,10 @@ describe("webhooks", () => { }); await vi.waitUntil(() => webhookLogger.mock.calls.length > 0, 10_000); - expect(webhookLogger).toHaveBeenCalledWith(expect.objectContaining({ response: { status: 200, message: "OK" } })); + expect(webhookLogger).toHaveBeenCalledWith( + "%j", + expect.objectContaining({ response: { status: 200, message: "OK" } }), + ); }); it("passes redirect error option to fetch", async () => {