Skip to content

Vendors

Last updated: 2026-07-26 — payment and compliance switched from Stripe test mode to live mode (real sk_live_... key + new live-mode webhook endpoints for both, prior test-mode endpoints superseded not deleted). Prior: Stripe Identity added as the compliance Worker’s KYC/KYB provider, replacing the Persona integration removed the same day (see the Stripe Identity entry below). OFAC remains deferred per docs/cf-rewrite-deferred-scope.md. Prior: 2026-07-25, ADR-9: finstack-rs deleted, finstack is the sole backend. Secrets/storage locations rewritten where they pointed at deleted Fly/Postgres infra; per-vendor integration code now lives in the relevant finstack/workers/* Worker rather than a Rust crate.

  • Purpose: Payment processor — PaymentIntents API for card capture, Stripe-hosted checkout, refunds
  • Integration: finstack’s payment Worker (workers/payment/src/adapters/stripe.ts)
  • Accounts per env: DEV/TEST: acct_1RNJATPEqBRBRw4k (Tap2 test keys), acct_1TdsnHAAGi1faYZS (“Finstack sandbox” — FinStack’s own designated test account, used for the 2026-07-25/26 CF-rewrite live-verification work and ADR-12 Task 8) | PROD (live mode): switched 2026-07-26payment now runs a real sk_live_... key against acct_1TdsmwAZwgWtny1w (“Finstack”, a separate account from the sandbox one above); livemode: true confirmed via a read-only GET /v1/balance probe. No real charge has been run yet — real end-to-end verification awaits actual production traffic or an explicit user-authorized live test (see BUGS.md/CHANGELOG.md for what’s verified vs. not).
  • Webhook endpoint (live mode): we_1TxdzwAZwgWtny1wnu7fvwBE at https://webhooks.finstack.sh/webhooks/stripe, registered 2026-07-26, subscribed to payment_intent.{succeeded,payment_failed,canceled}/refund.{created,updated}. Prior test-mode endpoint we_1Tx5inAAGi1faYZSdM4OH72S (also at webhooks.finstack.sh) is superseded, not deleted — still valid if test mode is needed again. The pre-2026-07-25 Fly-backend endpoint (we_1TfnK7PEqBRBRw4kZfqxhREw) is dead now that finstack-rs is deleted.
  • Secrets location: Worker secrets STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET on the payment Worker — live-mode values as of 2026-07-26, stored in vault as stripe/FINSTACK_LIVE_SECRET_KEY / stripe/FINSTACK_LIVE_WEBHOOK_SECRET (test-mode values remain at stripe/FINSTACK_TEST_SECRET_KEY / stripe/FINSTACK_WEBHOOK_SECRET / finstack-cf/PAYMENT_STRIPE_WEBHOOK_SECRET if a rollback to test mode is ever needed)
  • API version: PaymentIntents v1 — manual capture flow (capture_method=manual)
  • Idempotency: All Stripe API calls set Idempotency-Key: {payment_id} (create), {payment_id}/capture, {payment_id}/cancel, {refund_id} (refund), {payout_id}/transfer (Connect Transfer — see Stripe Connect entry below)
  • Webhook: POST /webhooks/stripe — HMAC-SHA256 signature validation, idempotency guard (see payment Worker’s webhook-idempotency handling)
  • Data storage: payment Worker’s D1 database (see finstack/workers/payment/migrations/)

Stripe Connect (ADR-12, added 2026-07-28 — payout rail, Task 8 live-verified end-to-end)

Section titled “Stripe Connect (ADR-12, added 2026-07-28 — payout rail, Task 8 live-verified end-to-end)”
  • Purpose: Payout rail for finstack’s own money owed to recipients — Custom connected accounts, “separate charges and transfers” (a standalone Transfer against finstack’s own balance, not a destination charge on the original PaymentIntent). First of two planned payout rails (Moov second), both routed through processor_configs/ProcessorRouter.
  • Integration — Transfer side: finstack’s routing Worker (workers/routing/src/adapters/stripe.tscheckTransfersCapability(), createTransfer()), PR #506 (Task 2), called by POST /payouts/execute (PR #507, Task 3), called for real by payout’s POST /payouts/:id/process (PR #513, Task 7).
  • Integration — account onboarding: finstack’s recipient Worker (workers/recipient/src/stripe-account.tsbuildCustomAccountPayload(), buildPersonPayload(), createCustomAccount(), createPerson()), Task 5. POST /recipients/:id/stripe-account accepts an individual or business payload, creates the Custom account via POST /v1/accounts, then — for a business — creates the representative and each beneficial owner via POST /v1/accounts/{id}/persons (a separate sub-resource call per person, not inline account fields). All Stripe calls in the chain use a {recipientId}/...-derived Idempotency-Key; nothing is persisted onto the recipients row until every call succeeds, so a caller retrying after a partial failure (e.g. a rejected beneficial-owner SSN) safely resumes into the same Stripe resources.
  • Endpoints used: GET /v1/accounts/:id (capability pre-check — must confirm capabilities.transfers === 'active' before ever attempting a Transfer), POST /v1/transfers (Transfer creation), POST /v1/accounts (Custom account creation), POST /v1/accounts/{id}/persons (representative/beneficial-owner creation).
  • Account: acct_1TdsnHAAGi1faYZS (“Finstack sandbox”) — FinStack’s designated test/sandbox Stripe account, used for test-mode verification per Task 8’s own instruction (not real money). This is a separate Stripe account from acct_1TdsmwAZwgWtny1w (“Finstack”), the production account payment/compliance run live against — the two are independent accounts with independent capabilities and Connect-enrollment status, not test/live modes of one account. Enrolled in Stripe Connect 2026-07-28 (was not enrolled when Task 8 first attempted live verification; the user enrolled it via the Dashboard the same day, confirmed by a real POST /v1/accounts succeeding).
  • type param bug found + fixed 2026-07-28 (Task 8): buildCustomAccountPayload() sent both type: 'custom' and the controller[...] params — Stripe now rejects this combination outright (“mutually exclusive”; type is deprecated, controller is its replacement per Stripe’s current docs). This meant every POST /recipients/:id/stripe-account call would have 400’d against real Stripe since Task 5 shipped, masked because recipient’s last real code deploy predated Task 5 by a week (see below). Fixed by dropping type entirely; live-verified the mutual-exclusivity error is gone.
  • Live-verified end-to-end 2026-07-28 (Task 8, final): create recipient → POST /recipients/:id/stripe-account (real Custom account acct_1TyKQgA...) → funded its transfers capability to active (added a test external bank account + business_profile.url, both required by Stripe before a capability activates) → manual KYC approve (/compliance/kyc/review/decide, propagated to recipients.status = kyc_approved) → POST /payoutsPOST /payouts/:id/process. First attempt hit balance_insufficient — the sandbox platform account had zero available balance to fund a Transfer from, an ordinary test-account state, not a bug; correctly classified as an ambiguous failure by Task 7’s design (left in processing/processor_status: unknown, not reverted — see the /process entry in ARCHITECTURE.md). Resolved by funding the platform’s available balance with Stripe’s tok_bypassPending test token (bypasses the pending period), then /fail-ing the stuck payout (the plan’s own documented exit path for that state) and creating a fresh one. Second attempt succeeded: POST /payouts/:id/process returned 200 with a real processor_payout_id (tr_1TyKWOAAGi1faYZS5BGolR8P), independently confirmed via a direct GET /v1/transfers/:id against Stripe (amount: 500, destination: acct_1TyKQgA..., reversed: false). All test D1 rows (recipient, both payouts, the KYC check) and the disposable finstack-cf/ADR12_TASK8_LIVE_VERIFY_KEY were deleted after; the Stripe-side test objects (connected account, Transfer) were left in place as they’re test-mode, zero-cost, and useful as an audit trail.
  • Secrets location — two different mechanisms on this one integration: the Transfer side (routing) has no static Worker secret — its key is decrypted at call time from processor_configs.credentials (AES-256-GCM, ROUTING_CREDENTIALS_MASTER_KEY on routing — see SECURITY.md’s “Encryption at Rest” section), selected per-tenant via routePayout(). ROUTING_CREDENTIALS_MASTER_KEY had never actually been set in production despite Task 1 shipping the encryption code (found + fixed 2026-07-28, Task 8 — see ENVIRONMENTS.md). Account onboarding (recipient) uses a static STRIPE_SECRET_KEY Worker secret instead. Correction: this entry and ENVIRONMENTS.md previously claimed that secret was set with the live key (stripe/FINSTACK_LIVE_SECRET_KEY) when Task 5 shipped — it had never actually been set at all (same discovery, wrangler secret list); now genuinely set, using the test-mode key (stripe/FINSTACK_TEST_SECRET_KEY), matching Task 8’s own instruction to verify with test-mode, not live, credentials.
  • Idempotency: Idempotency-Key: {payout_id}/transfer on POST /v1/transfers; {recipientId}/stripe-account, {recipientId}/representative, {recipientId}/owner/{index} on the three account-onboarding calls.
  • Known gap Stripe’s own API creates: the Transfer object has no status field (unlike Charges/PaymentIntents/Payouts) — a non-throwing response is the success signal; reversed/amount_reversed are the only post-creation state exposed.
  • Data storage: recipients.payout_method/recipients.stripe_account_id (Task 4/5); payout’s payouts.processor_payout_id/processor_status (Task 7).

Stripe Identity (added 2026-07-26, replaces Persona)

Section titled “Stripe Identity (added 2026-07-26, replaces Persona)”
  • Purpose: Embedded/client-side document verification for KYC (recipients) and primary-signer KYB (merchants) — the compliance Worker’s provider: 'stripe_identity' path on POST /compliance/kyc and POST /compliance/kyb, alongside the pre-existing fully-manual operator-driven flow (both providers coexist; manual /review//decide is unchanged).
  • Integration: finstack’s compliance Worker (workers/compliance/src/stripe-identity.tsStripeIdentityClient + verifyStripeIdentityWebhook)
  • Account: Same Stripe account as payment — Identity is a separately-activated product capability, not a separate account. Switched to live mode 2026-07-26 alongside payment (see the Stripe entry above); test-mode verification (acct_1TdsnHAAGi1faYZS) confirmed enabled via a live POST /v1/identity/verification_sessions probe before the live-mode swap.
  • Webhook endpoint (live mode): we_1TxdzwAZwgWtny1wIWMsKpHN at https://webhooks.finstack.sh/webhooks/stripe-identity, registered 2026-07-26, subscribed to identity.verification_session.{verified,requires_input,canceled}. Prior test-mode endpoint we_1TxXsiAAGi1faYZSKKJIVXUf (same URL) is superseded, not deleted.
  • Secrets location: Worker secrets STRIPE_IDENTITY_SECRET_KEY (same raw key as payment’s STRIPE_SECRET_KEY, now the live key), STRIPE_IDENTITY_WEBHOOK_SECRET on the compliance Worker — live-mode values stored in vault as stripe/FINSTACK_LIVE_SECRET_KEY / stripe/FINSTACK_LIVE_IDENTITY_WEBHOOK_SECRET (test-mode value remains at finstack-cf/STRIPE_IDENTITY_WEBHOOK_SECRET if a rollback is ever needed)
  • Webhook: POST /webhooks/stripe-identity — HMAC-SHA256 signature validation (same t=/v1= scheme as payment’s Stripe webhook), two-phase idempotency via stripe_identity_webhook_events (migration 0003_stripe_identity.sql)
  • Closing the loop: on a terminal event (verified→approved, canceled→rejected), the webhook handler calls RECIPIENT’s POST /recipients/:id/update-kyc-status or MERCHANT’s PATCH /merchants/:id before committing compliance’s own local state transition — a retry-safety invariant verified by a dedicated adversarial test (test/index.test.ts’s “CRITICAL” describe block). A 404 from either downstream Worker (subject_id permanently unresolvable) is distinguished from a 5xx (transient) via SubjectNotFoundError — the former is marked processed to stop Stripe’s retries, the latter is left unprocessed for retry.
  • Scope: primary-signer identity verification only, not full beneficial-ownership KYB completeness — multi-owner cases still route through the existing manual /beneficial-owners + /decide flow.
  • Data storage: compliance Worker’s D1 database (compliance_kyc_checks/compliance_kyb_checks, unchanged shape; new stripe_identity_webhook_events table)
  • Live-verified 2026-07-26 against production (gateway.glcf.workers.dev/webhooks.finstack.sh): real VerificationSession created for both a real recipient and a real merchant, real signed webhook delivered for each, both closed the loop (recipients.statuskyc_approved, merchant_accounts.statusactive) — all test rows and the disposable verification sk_* key deleted after.
  • Design/plan: docs/superpowers/specs/2026-07-26-stripe-identity-kyc-design.md, docs/superpowers/plans/2026-07-26-stripe-identity-kyc.md

Persona (removed 2026-07-26, superseded by Stripe Identity above)

Section titled “Persona (removed 2026-07-26, superseded by Stripe Identity above)”

Persona was never live on finstack (PERSONA_API_KEY was always unvaulted) and an audit found the integration wasn’t actually reachable end-to-end even if the key had been configured — gateway never routed /webhooks/persona, and no code connected a KYC decision to recipients.status/payout gating. Removed outright rather than finished: PersonaClient, the webhook receiver, and persona_webhook_events (migration 0002_drop_persona.sql) were deleted from the compliance Worker.


Stubbed no-op on finstack (not CF-specific — was also stubbed on the now-deleted Rust backend; see docs/cf-rewrite-deferred-scope.md). Description below is the target contract, not a current integration. Clarified 2026-07-28 (user): this is a gap in compliance’s own independent screening, not a claim that no sanctions screening happens anywhere in FinStack’s money-movement paths — Stripe (Connect Custom account underwriting for the payout rail, Radar/card-network compliance for charges) and Moov (once integrated) both perform their own screening as regulated processors. See SECURITY.md’s OFAC entry.

  • Purpose: Sanctions screening — OFAC Specially Designated Nationals (SDN) list fuzzy name matching
  • Source URL: https://www.treasury.gov/ofac/downloads/sdn.xml
  • Auth: None — public endpoint
  • Caching: In-memory Arc<RwLock<SdnList>> refreshed every 24h via background tokio task started at server startup
  • Matching algorithm: Jaro-Winkler similarity ≥ 0.88 = Hit; ≥ 0.78 = Inconclusive; < 0.78 = Clear
  • Data storage: No persistent storage — in-memory only; re-loaded on restart + 24h refresh

  • Purpose: Transactional email delivery (POST /v1/messages/email)
  • Integration: finstack’s messaging Worker
  • API Endpoint: https://api.resend.com/emails
  • Auth: Bearer token — RESEND_API_KEY Worker secret
  • Env vars: RESEND_API_KEY, MESSAGING_FROM_EMAIL (default sender)
  • Error handling: Non-2xx Resend response → hard error (500), not silent
  • Scope required: messaging:write

  • Purpose: General SMS delivery (POST /v1/messages/sms) via the messaging Worker. Also the delivery channel for consumer OTP auth (svc-auth’s POST /v1/auth/otp/send, shipped PR #511 — see CLAUDE.md’s Current State and INTERFACES.md’s Consumer OTP auth entry). The OTP code is always generated locally (generateOtpCode() in consumer-auth.ts, crypto.getRandomValues-backed) and sent as real custom SMS text — never a vendor-generated or vendor-echoed code. See CLAUDE.md’s Current State for why: a same-day incident (PR #512, reverted) briefly shipped a workaround that used Twilio’s own auto-generated code instead, which turned out to be a fixed constant on this trial account rather than random per-request — a live authentication bypass. Do not reintroduce that pattern without independently verifying per-request randomness across multiple live calls first.
  • Integration: finstack’s messaging Worker
  • API Endpoint: https://api.twilio.com/2010-04-01/Accounts/{SID}/Messages.json
  • Auth: HTTP Basic — TWILIO_ACCOUNT_SID (username) + TWILIO_AUTH_TOKEN (password), Worker secrets, vaulted under twilio/
  • Env vars: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER (E.164 format)
  • Error handling: Non-2xx Twilio response → hard error, not silent
  • Scope required: messaging:write
  • Account tier: trial (provisioned 2026-07-28), not upgraded. Two trial-only restrictions currently block otp/send from actually delivering (fails loud, 502 sms_send_failed — this is the correct, safe state, not a bug):
    • Custom SMS body text is rejected (error 572006, “Invalid template name”) — trial accounts only accept predefined template names as Body, not real message text. otp/send’s real custom-text message (“Your FinStack verification code is…”) hits this every time on the current trial tier.
    • SMS delivery is restricted to pre-verified destination numbers (separate from the above) — a number must be added under Verified Caller IDs before Twilio will send to it, via a voice-call verification flow. Also observed: brand-new trial accounts can hit a third restriction (verification calls themselves policy-blocked, likely anti-fraud, resolves without action after the account ages).
    • Revisit trigger: upgrade the Twilio account (removes both restrictions at once) before any real, non-test Tap2 Wallet user needs an OTP. No code change is needed when that happens — handleOtpSend’s existing locally-generated-code design already sends real custom text; it will simply start succeeding instead of 502ing.

  • Purpose: Multi-tenant AI gateway — routes LLM calls for the ai Worker’s configurable task execution engine to OpenAI/Anthropic/Workers AI depending on the selected model
  • Integration: finstack’s ai Worker (workers/ai/src/neureus.ts). Contract confirmed via neureus/neureus#247/#248 — request/response are OpenAI-envelope-compatible (choices[0].message.content, snake_case usage.prompt_tokens/completion_tokens)
  • API Endpoint: {NEUREUS_BASE_URL}/ai/chat (chat completion), {NEUREUS_BASE_URL}/ai/models (model catalog); default base URL https://api.neureus.ai
  • Auth: Bearer token — NEUREUS_API_KEY Worker secret; single platform-level tenant finstack-platform
  • Env vars: NEUREUS_API_KEY, NEUREUS_BASE_URL
  • Error handling: env.NEUREUS_API_KEY unset → hard error, not a silent fallback
  • Data storage: ai Worker’s D1 database
  • Scope required: ai:write (task creation/run), ai:read (task/run listing)

Products outside this repo that call finstack as their backend. Distinct from Vendors above (services FinStack depends on) — these depend on FinStack.

Tap2 Wallet / Tap2 One (g-a-l-a-c-t-i-c/tap2, apps/wallet-api + apps/merchant-api)

Section titled “Tap2 Wallet / Tap2 One (g-a-l-a-c-t-i-c/tap2, apps/wallet-api + apps/merchant-api)”
  • Purpose: Tap2’s consumer wallet (balance, topup, pay, disputes, seller KYC/payout) and merchant POS (customers, payments, catalog) proxy to FinStack instead of Tap2’s own deprecated backend.
  • Tenant ID: bbaaa798-374f-4bbd-a1d9-df40674ab9d6 (fixed, not rotated as part of this onboarding — matches what Tap2’s wallet-api already has configured as FINSTACK_WALLET_TENANT)
  • Scopes: wallet:read, wallet:write, compliance:read, compliance:write, recipients:read, recipients:write
  • Secrets location: sk_live_* key minted 2026-07-27, stored in vault finstack-cf/TAP2_WALLET_API_KEY — Tap2 sets this as their own FINSTACK_SVK Worker secret (wrangler secret put FINSTACK_SVK on wallet-api/merchant-api)
  • Status as of 2026-07-27: FinStack side ready; Tap2’s client code is not yet compatible — confirmed broken in production (wrong credential type, wrong tenant, and a field-naming mismatch across the wallet/recipient domains). Full detail, live-verification evidence, and the exact fixes needed: docs/tap2-wallet-integration-readiness.md. Filed as tap2#107 (auth) and tap2#108 (API contract).
  • Data storage: Tap2’s wallet ledger is entirely fresh in finstack’s wallet Worker (per-consumer Durable Objects) — no migration from Tap2’s pre-cutover finstack-rs/Neon data exists or is planned. Any real balances predating the 2026-07-25 cutover are not reflected. The source Neon instance was deleted 2026-07-27 (ADR-11) after a full export; if reconciliation is ever decided on, it now has to come from the local backup (/Users/am/backups/finstack-neon-2026-07-27/, see DEPLOYMENT.md) rather than a live query.

  • Purpose:
  • Accounts per env: DEV: | STAGE: | PROD:
  • Secrets location: [Cloudflare Worker secret VENDOR_API_KEY]
  • API version:
  • Pricing:
  • Data storage:
  • Reconciliation: