Skip to content
A Bekkour
Writing

Wiring up an open-banking feed in Rails without leaking or getting locked in

4 min read

Pulling a user’s bank transactions into a bookkeeping app sounds like an API call. It’s three problems wearing a trench coat. The provider is slow and asynchronous — connecting a bank kicks off a multi-step job you poll for minutes. Everything you touch is PII you must never log. And you do not want to marry a single aggregator, because you’ll want to swap or add one later. On a fintech product for freelancers, I integrated an open-banking provider (Basiq, for the Australian market) with those three problems designed for from the start.

Don’t marry the vendor

Every provider-specific detail lives behind an interface, and providers register themselves into a tiny registry so the rest of the app asks for “the bank provider” by key, never for “Basiq”:

module BankFeeds
  class ProviderRegistry
    @providers = {}
    class << self
      def register_provider(provider)
        @providers[provider.provider_id.to_sym] = provider
      end
      def get(provider_key)
        @providers.fetch(provider_key&.to_sym, nil)
      end
    end
  end
end

That’s not architecture astronautics — it’s the seam that let a fake provider stand in for the real one in tests (bank sandboxes are slow and flaky), and it’s where a second aggregator would slot in without touching the code that consumes transactions. The rule I held to: nothing outside the BankFeeds::Basiq namespace is allowed to know the provider’s name.

Credentials get encrypted at rest, always

Bank connection credentials are about the most sensitive thing an app can hold. They’re encrypted before they ever hit the database, with MessageEncryptor (which both encrypts and signs, so you detect tampering, not just hide the value):

class BankCredentialsEncryptor
  def self.encrypt(data)          = crypt.encrypt_and_sign(data)
  def self.decrypt(encrypted)     = crypt.decrypt_and_verify(encrypted)

  def self.crypt
    enc_key = [RoundedSecrets[:secret_bank_feed_credentials_key]].pack('H*')
    ActiveSupport::MessageEncryptor.new(enc_key)
  end
  private_class_method :crypt
end

A dedicated key, separate from the app’s other secrets, so this data has its own blast radius if a key ever leaks. Small thing, but “one key for everything” is how a single leak becomes total.

The logger that can’t leak

Here’s the one that bites people. You add request/response logging to debug a flaky integration, ship it, and three weeks later realize your logs are full of bank tokens and account numbers. So the HTTP client’s logger plugin redacts before it writes, walking the JSON body recursively and blanking any configured sensitive key at any depth:

def recursive_censor(hash)
  result = {}
  hash.each do |field, value|
    result[field] = if field.in?(@censor_keys)
      censor(value)                       # blanks the whole subtree
    elsif value.is_a?(Hash)
      recursive_censor(value)             # keep walking
    else
      value
    end
  end
  result
end

The recursion is the point: a token nested five levels deep in a response is exactly where it hides, and a top-level key check would miss it. censor blanks an entire subtree, not just a scalar, so redacting credentials removes everything under it. When the body isn’t valid JSON, it logs a marker instead of crashing the request over a log line — logging must never take down the thing it’s observing.

Connecting a bank is a state machine, not a request

The part that surprises people building their first aggregator integration: connecting a bank isn’t a call that returns accounts. It kicks off a job on the provider’s side that moves through steps — verify credentials, retrieve accounts, retrieve transactions — and you poll it until it’s done or it fails. So the client side is a small poll-until-done loop with a real timeout, and it surfaces each step so the UI can say “Retrieving accounts…” instead of showing a dead spinner:

def wait_until_done
  start_time = Time.current
  while @connection_url.nil?
    raise TimeoutError if Time.current - start_time > TimeoutInSeconds   # 15 min ceiling
    fetch_job_response
    yield(self) if block_given?                                          # lets caller report progress

    if failed_step.present?
      error_config = BasiqErrorConfig.get(code: failure_error.fetch('code'))
      return FailedResult.new(error_config[:message], error_config[:recover_by] == :login)
    elsif success?
      @connection_url = current_job_response.body.dig('links', 'source')
      return SuccessResult.new(connection_id)
    else
      sleep(PollIntervalSeconds)                                         # 6s between polls
    end
  end
end

A few decisions in there earned their keep. The 15-minute timeout means a bank that never finishes doesn’t hang a job forever. The yield(self) lets the caller report the current human-readable step to the user on every poll. Failures map through an error config that decides whether the message is user-facing and whether the user can recover by re-logging-in versus needing support — because “wrong password” and “the bank’s API is down” are different conversations. And it asserts the provider’s step list matches what the code expects, logging the raw (encrypted) body if it doesn’t, so a provider changing their flow surfaces as a clear error instead of a mystery.

What I’d tell someone starting one

Integrations rot at the edges you didn’t think about on day one. The API call is the easy 10%. The 90% is: a seam so you’re not stuck with one vendor, encryption so the credentials aren’t a liability, a logger that redacts by construction so debugging doesn’t become a breach, and treating the “connect” flow as the asynchronous, failure-prone state machine it actually is. Build those four in from the start and the integration stays boring. Bolt them on after an incident and they’re a rewrite.


Bank feeds, Stripe, QuickBooks, FreshBooks — I’ve built the financial integrations where a leaked token or a stuck sync is a real problem. If that’s what you’re wiring up, I’d be glad to help.

Al Mokhtar Bekkour

Senior Rails & Go engineer in Quebec. I build multi-tenant SaaS and financial integrations, and ship my own products — Constat, Railyard, and Recall. Open to work.

← All writing