BankSync turns bank transaction notifications into durable, signed webhooks. It is a TypeScript package and a deployable Cloudflare Worker maintained by Festapp.
The included Worker supports:
- Fio Bank notification email and Fio API ingestion;
- Air Bank notification email ingestion;
- MIME parsing, sender allowlisting and per-account pairing addresses;
- layered transaction deduplication in Cloudflare D1;
- durable webhook delivery through Cloudflare Queues with retry and healing;
- encrypted webhook secrets, audit records, retention and optional R2 backups.
v0.1.0 is an unsafe preview and must not be used in production. Install only
the latest version published to npm by the protected release workflow after its
checksum and provenance are available:
pnpm add @festapp/banksyncThe root export contains runtime-neutral parsing and webhook helpers:
import {
detectProvider,
parseEmail,
verifyWebhook,
type WebhookEnvelope,
} from "@festapp/banksync";The Cloudflare Worker is a separate export so importing the core does not pull Cloudflare bindings into application code:
export { default } from "@festapp/banksync/cloudflare";
export type { Env } from "@festapp/banksync/cloudflare";- Clone this repository and install dependencies with
pnpm install. - Copy
wrangler.example.tomltowrangler.tomland replace every placeholder. - Create the D1 database, queues and optional R2 bucket named in the config.
- For a fresh database apply the baseline; for an existing production database
whose history ends at
0009, apply only0010_security_hardening.sqlthrough a scoped reviewed migration operation. - Set
ADMIN_SECRET,WEBHOOK_KEK,ENCRYPTION_KEY_V1, and the activeBACKUP_ENCRYPTION_KEY_Vnwithpnpm wrangler secret put <NAME>. - Configure
CALLBACK_HOST_ALLOWLISTwith exact consumer hostnames. Production refuses an empty allowlist, credentials, IP literals, special-use hosts, fragments, suffix matches, and redirects. - Inspect one sanitized accepted and rejected Cloudflare message to establish
the Cloudflare-owned Authentication-Results authserv-id, then configure it as
EMAIL_AUTHSERV_ID. Email ingest intentionally remains disabled while it is absent; MIME headers are never an identity fallback. - Run the release gates and deploy the exact attested artifact.
All three key families must be independent random 32-byte base64 values.
BACKUP_ENCRYPTION_KEY_VERSION selects the active backup key. Store backup
keys outside R2 and test restore before rotation. Never commit wrangler.toml,
.dev.vars, bank API tokens, backup keys, or consumer webhook secrets.
Fresh databases are created from the single canonical schema baseline
migrations/0001_schema.sql (schema version 10). The original incremental
0001–0009 history remains recorded in existing D1 databases but is not
needed to create a new deployment. To stay compatible with those databases,
production upgrade is 0010_security_hardening.sql; all future migrations must
start at 0011 or higher. Never renumber
or replace the baseline filename.
BankSync's D1 schema is intentionally independent of any consumer's billing database. Billing settlement belongs behind each consumer's signed webhook endpoint, so installing BankSync never creates or changes Mendelio/Supabase billing tables or routines.
For local development, copy .dev.vars.example to .dev.vars, use placeholder
Cloudflare resource identifiers in wrangler.toml, apply migrations locally,
and run pnpm dev.
BankSync sends transaction.received version 1. delivery_id is stable
across retries and is the consumer idempotency key. Each request includes:
X-BankSync-TimestampX-BankSync-Delivery-IdX-BankSync-Signature: sha256=<hex>
The HMAC input is the exact byte sequence
timestamp + "." + deliveryId + "." + bodyBytes. Verify the raw body before
JSON parsing. verifyWebhook validates raw-body HMAC, timestamp syntax and
tolerance, delivery header/body equality, event, and event version, then returns
a typed verified envelope. It throws WebhookVerificationError with a stable
code. There is intentionally no HMAC-only public verifier.
import { verifyWebhook } from "@festapp/banksync";
const bodyBytes = new Uint8Array(await request.arrayBuffer());
const envelope = await verifyWebhook({
secret,
timestamp: request.headers.get("x-banksync-timestamp") ?? "",
deliveryId: request.headers.get("x-banksync-delivery-id") ?? "",
signature: request.headers.get("x-banksync-signature") ?? "",
bodyBytes,
});
// Atomically claim envelope.delivery_id in durable consumer storage before any
// billing or other side effect. The verifier cannot provide durable replay
// protection on its own.- Cloudflare Email Routing is the trusted SMTP/envelope boundary. The Worker requires one configured trusted auth result plus aligned DKIM or DMARC and exact agreement between envelope and MIME identities.
bank_accounts.owner_app_idis the tenant authorization authority. Only an admin can create a cross-owner subscription.- Public HTTP exposes only
GET /healthwith{ "ok": true }or a stable unavailable response./statusand/health/deeprequire admin auth. - Callback delivery revalidates the current exact-host policy immediately before every fetch and never follows redirects.
- Idempotency is scoped by principal, method, and canonical path. Credential
creation/rotation endpoints reject
Idempotency-Keyand never cache responses. - R2 backups are AES-256-GCM
.sql.encenvelopes. Ephemeral idempotency and rate-limit tables are excluded. Decrypt to a new mode-0600 file without printing plaintext:
BACKUP_DECRYPTION_KEY='<base64 key>' \
node scripts/decrypt-backup.mjs backup.sql.enc restored.sqlpnpm check
pnpm pack --dry-runProvider-live tests are intentionally not part of the default suite and must never be run with production bank credentials.
MIT