From bdf63ef293e1d83f6d1ff0c7b2f96f1425e55009 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Thu, 28 May 2026 04:47:16 +0100 Subject: [PATCH 1/3] feat(externalAccounts): Webhook endpoint firing for external accounts feat: external accounts event triggering this commit initialize the webhook endpoint to register on Bridge to trigger external account link after plaid completion --- src/config/schema.ts | 3 +- src/config/schema.types.d.ts | 1 + .../mutation/bridge-add-external-account.ts | 4 +- .../object/bridge-external-account-link.ts | 11 +++ .../types/object/bridge-external-account.ts | 2 +- src/services/bridge/webhook-server/index.ts | 2 + .../middleware/verify-signature.ts | 2 +- .../webhook-server/routes/external-account.ts | 80 +++++++++++++++++++ 8 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 src/graphql/public/types/object/bridge-external-account-link.ts create mode 100644 src/services/bridge/webhook-server/routes/external-account.ts diff --git a/src/config/schema.ts b/src/config/schema.ts index c4e6077c2..11f0b9a53 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -661,8 +661,9 @@ export const configSchema = { kyc: { type: "string" }, deposit: { type: "string" }, transfer: { type: "string" }, + external_account: { type: "string" }, }, - required: ["kyc", "deposit", "transfer"], + required: ["kyc", "deposit", "transfer", "external_account"], }, timestampSkewMs: { type: "integer" }, replaySecret: { type: "string" }, diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index 20c1952a4..ed94535f1 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -35,6 +35,7 @@ type BridgeWebhookPublicKeys = { kyc: string deposit: string transfer: string + external_account: string } type BridgeWebhook = { diff --git a/src/graphql/public/root/mutation/bridge-add-external-account.ts b/src/graphql/public/root/mutation/bridge-add-external-account.ts index 0c4a2fd19..1c3ccfbe5 100644 --- a/src/graphql/public/root/mutation/bridge-add-external-account.ts +++ b/src/graphql/public/root/mutation/bridge-add-external-account.ts @@ -1,7 +1,7 @@ import { GT } from "@graphql/index" import { mapAndParseErrorForGqlResponse } from "@graphql/error-map" import IError from "@graphql/shared/types/abstract/error" -import BridgeExternalAccount from "@graphql/public/types/object/bridge-external-account" +import BridgeExternalAccountLink from "@graphql/public/types/object/bridge-external-account-link" import { BridgeConfig } from "@config" import BridgeService from "@services/bridge" import { BridgeDisabledError, BridgeAccountLevelError } from "@services/bridge/errors" @@ -10,7 +10,7 @@ const BridgeAddExternalAccountPayload = GT.Object({ name: "BridgeAddExternalAccountPayload", fields: () => ({ errors: { type: GT.NonNullList(IError) }, - externalAccount: { type: BridgeExternalAccount }, + externalAccount: { type: BridgeExternalAccountLink }, }), }) diff --git a/src/graphql/public/types/object/bridge-external-account-link.ts b/src/graphql/public/types/object/bridge-external-account-link.ts new file mode 100644 index 000000000..5d51c664d --- /dev/null +++ b/src/graphql/public/types/object/bridge-external-account-link.ts @@ -0,0 +1,11 @@ +import { GT } from "@graphql/index" + +const BridgeExternalAccountLink = GT.Object({ + name: "BridgeExternalAccountLink", + fields: () => ({ + linkUrl: { type: GT.NonNull(GT.String) }, + expiresAt: { type: GT.NonNull(GT.String) }, + }), +}) + +export default BridgeExternalAccountLink diff --git a/src/graphql/public/types/object/bridge-external-account.ts b/src/graphql/public/types/object/bridge-external-account.ts index 890733978..92cae18ad 100644 --- a/src/graphql/public/types/object/bridge-external-account.ts +++ b/src/graphql/public/types/object/bridge-external-account.ts @@ -3,7 +3,7 @@ import { GT } from "@graphql/index" const BridgeExternalAccount = GT.Object({ name: "BridgeExternalAccount", fields: () => ({ - id: { type: GT.NonNullID }, + id: { type: GT.NonNullID, resolve: (obj) => obj.bridgeExternalAccountId }, bankName: { type: GT.NonNull(GT.String) }, accountNumberLast4: { type: GT.NonNull(GT.String) }, status: { type: GT.NonNull(GT.String) }, diff --git a/src/services/bridge/webhook-server/index.ts b/src/services/bridge/webhook-server/index.ts index 2cf97522a..e89060179 100644 --- a/src/services/bridge/webhook-server/index.ts +++ b/src/services/bridge/webhook-server/index.ts @@ -14,6 +14,7 @@ import { verifyBridgeSignature } from "./middleware/verify-signature" import { kycHandler } from "./routes/kyc" import { depositHandler } from "./routes/deposit" import { transferHandler } from "./routes/transfer" +import { externalAccountHandler } from "./routes/external-account" import { replayAuthMiddleware, replayHandler } from "./routes/replay" type RawBodyRequest = express.Request & { rawBody?: string } @@ -43,6 +44,7 @@ export const startBridgeWebhookServer = () => { app.post("/kyc", verifyBridgeSignature("kyc"), kycHandler) app.post("/deposit", verifyBridgeSignature("deposit"), depositHandler) app.post("/transfer", verifyBridgeSignature("transfer"), transferHandler) + app.post("/external-account", verifyBridgeSignature("external_account"), externalAccountHandler) app.post("/internal/replay", replayAuthMiddleware, replayHandler) if (!BridgeConfig.webhook.replaySecret && !process.env.BRIDGE_WEBHOOK_REPLAY_SECRET) { diff --git a/src/services/bridge/webhook-server/middleware/verify-signature.ts b/src/services/bridge/webhook-server/middleware/verify-signature.ts index 03b75494f..447ff9aaf 100644 --- a/src/services/bridge/webhook-server/middleware/verify-signature.ts +++ b/src/services/bridge/webhook-server/middleware/verify-signature.ts @@ -14,7 +14,7 @@ import { baseLogger } from "@services/logger" type RawBodyRequest = Request & { rawBody?: string } -export const verifyBridgeSignature = (publicKeyType: "kyc" | "deposit" | "transfer") => { +export const verifyBridgeSignature = (publicKeyType: "kyc" | "deposit" | "transfer" | "external_account") => { return (req: Request, res: Response, next: NextFunction) => { const signature = req.headers["x-webhook-signature"] as string diff --git a/src/services/bridge/webhook-server/routes/external-account.ts b/src/services/bridge/webhook-server/routes/external-account.ts new file mode 100644 index 000000000..6618f5a31 --- /dev/null +++ b/src/services/bridge/webhook-server/routes/external-account.ts @@ -0,0 +1,80 @@ +/** + * Bridge External Account Webhook Handler + * Handles external_account.created and external_account.updated events from Bridge.xyz + * + * Fires after a user completes the Plaid bank-linking flow. + * Persists the linked account to MongoDB so it appears in bridgeExternalAccounts queries. + * + * Status mapping: + * active: true → "verified" + * active: false → "failed" + */ + +import { Request, Response } from "express" +import { AccountsRepository } from "@services/mongoose/accounts" +import { LockService } from "@services/lock" +import { baseLogger } from "@services/logger" +import { toBridgeCustomerId } from "@domain/primitives/bridge" +import * as BridgeAccountsRepo from "@services/mongoose/bridge-accounts" + +const toStatus = (active: boolean | undefined): "pending" | "verified" | "failed" => { + if (active === true) return "verified" + if (active === false) return "failed" + return "pending" +} + +export const externalAccountHandler = async (req: Request, res: Response) => { + const { event_id, event_object } = req.body + const { id, customer_id, bank_name, last_4, active } = event_object ?? {} + + if (!id || !customer_id || !event_id) { + return res.status(400).json({ error: "Invalid payload" }) + } + + try { + const bridgeCustomerId = toBridgeCustomerId(customer_id) + const account = await AccountsRepository().findByBridgeCustomerId(bridgeCustomerId) + if (account instanceof Error) { + baseLogger.warn( + { customer_id, event_id }, + "Account not found for Bridge customer — may be a timing issue, Bridge will retry", + ) + return res.status(503).json({ error: "Account not ready" }) + } + + const lockKey = `bridge-external-account:${event_id}` + const lockResult = await LockService().lockIdempotencyKey(lockKey as IdempotencyKey) + if (lockResult instanceof Error) { + baseLogger.info({ customer_id, event_id, id }, "Duplicate Bridge external account webhook") + return res.status(200).json({ status: "already_processed" }) + } + + const status = toStatus(active) + + const result = await BridgeAccountsRepo.createExternalAccount({ + accountId: String(account.id), + bridgeExternalAccountId: id, + bankName: bank_name ?? "Unknown", + accountNumberLast4: last_4 ?? "0000", + status, + }) + + if (result instanceof Error) { + baseLogger.error( + { accountId: account.id, event_id, id, error: result }, + "Failed to persist Bridge external account", + ) + return res.status(500).json({ error: "Failed to persist external account" }) + } + + baseLogger.info( + { accountId: account.id, bridgeExternalAccountId: id, status }, + "Bridge external account persisted", + ) + + return res.status(200).json({ status: "success" }) + } catch (error) { + baseLogger.error({ error, customer_id, event_id }, "Error processing Bridge external account webhook") + return res.status(500).json({ error: "Internal server error" }) + } +} From 85c67d584db922232aa00b217e1a3468b005c748 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Thu, 28 May 2026 04:58:09 +0100 Subject: [PATCH 2/3] fix(ENG-350) : reset pending withdraw state on transfer failure. this commit mark the transfer as failed in our bridge withdrawals collection when something went wrong during the trasfer flow, the user is notified with a popup and on return state, as well for transfer successfully completed --- src/services/bridge/webhook-server/routes/replay.ts | 6 +++++- src/services/bridge/webhook-server/routes/transfer.ts | 8 +++----- src/services/mongoose/bridge-accounts.ts | 7 ++++++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/services/bridge/webhook-server/routes/replay.ts b/src/services/bridge/webhook-server/routes/replay.ts index 33b499bd9..009414c28 100644 --- a/src/services/bridge/webhook-server/routes/replay.ts +++ b/src/services/bridge/webhook-server/routes/replay.ts @@ -14,14 +14,16 @@ import { } from "../transfer-direction" import { depositHandler } from "./deposit" +import { externalAccountHandler } from "./external-account" import { kycHandler } from "./kyc" import { transferHandler } from "./transfer" -type RouteKey = "kyc" | "deposit" | "transfer" +type RouteKey = "kyc" | "deposit" | "transfer" | "external_account" const HANDLERS: Record Promise> = { kyc: kycHandler, deposit: depositHandler, transfer: transferHandler, + external_account: externalAccountHandler, } const DEPOSIT_EVENT_TYPES = new Set([ @@ -39,6 +41,7 @@ const DEPOSIT_EVENT_TYPES = new Set([ const toRouteKey = (bridgeEventType: string): RouteKey | null => { if (bridgeEventType.startsWith("kyc")) return "kyc" if (bridgeEventType.startsWith("transfer")) return "transfer" + if (bridgeEventType.startsWith("external_account")) return "external_account" if (DEPOSIT_EVENT_TYPES.has(bridgeEventType)) return "deposit" return null } @@ -114,6 +117,7 @@ const toHandlerBody = ({ event_id: eventId, event_object: eventObject, } + } export const replayAuthMiddleware = (req: Request, res: Response, next: () => void) => { diff --git a/src/services/bridge/webhook-server/routes/transfer.ts b/src/services/bridge/webhook-server/routes/transfer.ts index a24a32fdc..6330ad94a 100644 --- a/src/services/bridge/webhook-server/routes/transfer.ts +++ b/src/services/bridge/webhook-server/routes/transfer.ts @@ -1,6 +1,6 @@ /** * Bridge Transfer Webhook Handler - * Handles transfer.completed and transfer.failed events from Bridge.xyz + * Handles transfer webhook events from Bridge.xyz (transfer.completed, transfer.updated.status_transitioned) */ import { Request, Response } from "express" @@ -65,7 +65,7 @@ export const transferHandler = async (req: Request, res: Response) => { event === "transfer.payment_processed" || state === "payment_processed" - const isFailure = event === "transfer.failed" || TERMINAL_FAILURE_STATES.has(state) + const isFailure = TERMINAL_FAILURE_STATES.has(state) if (!isCompletion && !isFailure) { baseLogger.info({ transfer_id, state, event }, "Bridge transfer event not handled") @@ -118,9 +118,7 @@ export const transferHandler = async (req: Request, res: Response) => { const failureReason = state === "refund_failed" ? (return_reason as string | undefined) - : event === "transfer.failed" - ? (reason as string | undefined) - : ((reason as string | undefined) ?? (return_reason as string | undefined)) + : ((reason as string | undefined) ?? (return_reason as string | undefined)) const result = await BridgeAccountsRepo.updateWithdrawalStatus( bridgeTransferId, diff --git a/src/services/mongoose/bridge-accounts.ts b/src/services/mongoose/bridge-accounts.ts index c045295be..4afd2c974 100644 --- a/src/services/mongoose/bridge-accounts.ts +++ b/src/services/mongoose/bridge-accounts.ts @@ -61,7 +61,12 @@ export const createExternalAccount = async (data: { status?: "pending" | "verified" | "failed" }) => { try { - const record = await BridgeExternalAccount.create(data) + const { bridgeExternalAccountId, accountId, status, ...immutable } = data + const record = await BridgeExternalAccount.findOneAndUpdate( + { bridgeExternalAccountId, accountId }, + { $setOnInsert: { bridgeExternalAccountId, accountId, ...immutable }, $set: { status: status ?? "pending" } }, + { upsert: true, new: true, setDefaultsOnInsert: true }, + ) return record } catch (error) { return new RepositoryError(String(error)) From b86c0865185bd8223c033fa6c19e9b692c8932c2 Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Thu, 28 May 2026 04:59:15 +0100 Subject: [PATCH 3/3] feat: bruno configurations --- dev/bruno/Flash GraphQL API/collection.bru | 3 +- .../mutations/bridgeAddExternalAccount.bru | 35 ++++++++++++++ .../mutations/bridgeInitiateWithdrawal.bru | 47 +++++++++++++++++++ .../token/queries/bridgeExternalAccounts.bru | 31 ++++++++++++ .../token/queries/bridgeWithdrawals.bru | 32 +++++++++++++ 5 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 dev/bruno/Flash GraphQL API/token/mutations/bridgeAddExternalAccount.bru create mode 100644 dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateWithdrawal.bru create mode 100644 dev/bruno/Flash GraphQL API/token/queries/bridgeExternalAccounts.bru create mode 100644 dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawals.bru diff --git a/dev/bruno/Flash GraphQL API/collection.bru b/dev/bruno/Flash GraphQL API/collection.bru index 40b0cbaea..97ea30b6a 100644 --- a/dev/bruno/Flash GraphQL API/collection.bru +++ b/dev/bruno/Flash GraphQL API/collection.bru @@ -10,6 +10,7 @@ vars:pre-request { protocol: http domain: localhost port: 4000 + bridgeExternalAccountId: ext_plaid_a4a2b7e0175c } docs { @@ -32,7 +33,7 @@ docs { ``` ERPNEXT_JWT_SECRET= node -e "const c=require('crypto'),b=o=>Buffer.from(JSON.stringify(o)).toString('base64url'),h=b({alg:'HS256',typ:'JWT'}),p=b({userId:'admin',roles:['Accounts Manager']}),s=c.createHmac('sha256',process.env.ERPNEXT_JWT_SECRET).update(h+'.'+p).digest('base64url');console.log(h+'.'+p+'.'+s)" ``` - + # Extra Resources If you use Postman, we have a collection you can import to test the API. Download it here: [galoy_graphql_main_api.postman_collection.json](https://github.com/GaloyMoney/galoy/tree/main/src/graphql/docs/galoy_graphql_main_api.postman_collection.json) } diff --git a/dev/bruno/Flash GraphQL API/token/mutations/bridgeAddExternalAccount.bru b/dev/bruno/Flash GraphQL API/token/mutations/bridgeAddExternalAccount.bru new file mode 100644 index 000000000..9535124f1 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/mutations/bridgeAddExternalAccount.bru @@ -0,0 +1,35 @@ +meta { + name: bridgeAddExternalAccount + type: graphql + seq: 38 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + mutation BridgeAddExternalAccount { + bridgeAddExternalAccount { + errors { + message + code + } + externalAccount { + linkUrl + expiresAt + } + } + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateWithdrawal.bru b/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateWithdrawal.bru new file mode 100644 index 000000000..77df58a56 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/mutations/bridgeInitiateWithdrawal.bru @@ -0,0 +1,47 @@ +meta { + name: bridgeInitiateWithdrawal + type: graphql + seq: 39 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + mutation BridgeInitiateWithdrawal($input: BridgeInitiateWithdrawalInput!) { + bridgeInitiateWithdrawal(input: $input) { + errors { + message + code + } + withdrawal { + id + amount + currency + status + createdAt + } + } + } +} + +body:graphql:vars { + { + "input": { + "amount": "10.00", + "externalAccountId": "{{bridgeExternalAccountId}}" + } + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/dev/bruno/Flash GraphQL API/token/queries/bridgeExternalAccounts.bru b/dev/bruno/Flash GraphQL API/token/queries/bridgeExternalAccounts.bru new file mode 100644 index 000000000..4fae717a8 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/queries/bridgeExternalAccounts.bru @@ -0,0 +1,31 @@ +meta { + name: bridgeExternalAccounts + type: graphql + seq: 19 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + query BridgeExternalAccounts { + bridgeExternalAccounts { + id + bankName + accountNumberLast4 + status + } + } +} + +settings { + encodeUrl: false + timeout: 30000 +} diff --git a/dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawals.bru b/dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawals.bru new file mode 100644 index 000000000..397404f13 --- /dev/null +++ b/dev/bruno/Flash GraphQL API/token/queries/bridgeWithdrawals.bru @@ -0,0 +1,32 @@ +meta { + name: bridgeWithdrawals + type: graphql + seq: 20 +} + +post { + url: {{flashGraphqlUrl}} + body: graphql + auth: inherit +} + +headers { + Content-Type: application/json +} + +body:graphql { + query BridgeWithdrawals { + bridgeWithdrawals { + id + amount + currency + status + createdAt + } + } +} + +settings { + encodeUrl: false + timeout: 30000 +}