pluggy-rb

An unofficial Ruby client for the Pluggy open-finance API, written by hand in the style of stripe-ruby.

It covers the read paths needed to identify every transaction reachable from a connected item — accounts, transactions, credit-card bills, loans, connectors, items, categories and merchants — with transparent API-key renewal, one uniform pagination interface over Pluggy's three different paging shapes, and exact decimal money.

client = Pluggy::Client.new(
  client_id: ENV["PLUGGY_CLIENT_ID"],
  client_secret: ENV["PLUGGY_CLIENT_SECRET"]
)

client.accounts.list(item_id: item_id).each do ||
  puts "#{.name}: #{.balance} #{.currency_code}"
end

Installation

gem "pluggy-rb"

Requires Ruby >= 3.1. The only runtime dependency is bigdecimal (see Money).

Authentication

Pluggy authentication is two steps, and the first one expires.

  1. POST /auth with your client keys returns an apiKey that lasts 2 hours.
  2. Every other endpoint takes that key as an X-API-KEY header.

The SDK does all of this for you. It authenticates lazily on your first request, caches the key until the expiry inside the key's own JWT, and if Pluggy ever rejects it mid-session it renews and retries once — so a long-running process never sees an expiry.

client = Pluggy::Client.new(client_id: "...", client_secret: "...")

You can also set defaults that every new client inherits:

Pluggy.configure do |c|
  c.client_id     = ENV["PLUGGY_CLIENT_ID"]
  c.client_secret = ENV["PLUGGY_CLIENT_SECRET"]
  c.logger        = Rails.logger
end

Pluggy::Client.new

Bringing your own key

If you manage the apiKey yourself, pass it instead of credentials. There is then nothing to renew with, so an expiry raises Pluggy::AuthenticationError telling you to supply credentials:

Pluggy::Client.new(api_key: existing_key)

Connect tokens are not API keys

POST /connect_token returns a 30-minute token for the Connect Widget in your frontend. It cannot authenticate API calls, and issuing one requires an apiKey — so it happens server-side.

token = client.create_connect_token(
  options: { client_user_id: "user-42", webhook_url: "https://example.com/hooks/pluggy" }
)
render json: { accessToken: token.access_token }

Pass item_id: to let the widget update an existing connection instead of creating a new one.

The four headline flows

1. Accounts for an item

accounts = client.accounts.list(item_id: item_id)

accounts.each do |a|
  puts "#{a.type}/#{a.subtype} #{a.name} #{a.number}#{a.balance} #{a.currency_code}"
end

checking = accounts.find(&:checking?)
card     = accounts.find(&:credit_card?)

bank?, credit?, credit_card?, checking? and savings? are all available. type: "BANK" or type: "CREDIT" filters server-side.

2. Every transaction in a checking account

checking.transactions(date_from: Date.new(2024, 1, 1)).auto_paging_each do |t|
  puts format("%s %10s  %s", t.date.strftime("%F"), t.amount, t.description)
end

auto_paging_each walks every page. Without a block it returns an Enumerator, so it stays lazy:

checking.transactions.auto_paging_each.lazy.select(&:pending?).first(10)

3. A credit card's bills and each bill's line items

client.bills.list(account_id: card.id).each do |bill|
  puts "closing #{bill.bill_closing_date} due #{bill.due_date}: " \
       "#{bill.total_amount} #{bill.total_amount_currency_code}#{' (paid)' if bill.paid?}"

  bill.transactions.each do |t|
    puts format("   %s %10s  %-40s %s",
                t.date.strftime("%F"), t.amount, t.description, t.installment_label)
  end
end

Bills carry no line items of their own — see Bill line items for how this works and what it costs.

4. Loans

Loans hang off the item, not an account:

client.loans.list(item_id: item_id).each do |loan|
  puts "#{loan.product_name} #{loan.contract_amount} CET=#{loan.cet}"
  puts "  #{loan.installments.paid_installments} paid, #{loan.installments.due_installments} due"
end

Concepts

Items are connections to an institution. Accounts belong to items. Transactions belong to accounts. Bills belong to CREDIT accounts. Loans belong to items, not accounts.

item = client.items.retrieve(item_id)
item.accounts            # all of them
item.credit_accounts     # type: "CREDIT"
item.loans
item.transactions        # every transaction on every account, as one stream

Gotchas

Most of these are Pluggy's, not the gem's, and the gem cannot paper over them.

There is no GET /items

The API has no endpoint to list items, so you must persist item ids yourself, normally keyed by your own clientUserId. client.items deliberately has no list method. This is the most common surprise for people arriving from Plaid.

Money

Amounts are parsed straight off the wire with JSON.parse(body, decimal_class: BigDecimal), so a value written -212.45 becomes an exact BigDecimal and never passes through a Float. Whole numbers stay Integer. Both are exact, which is the property that matters for reconciliation:

bill.total_amount - bill.minimum_payment_amount   # exact, no float drift

This is why bigdecimal is a declared dependency: it became a bundled rather than default gem in Ruby 3.4, so it has to be requested even though it ships with every Ruby. Set Pluggy.decimal_amounts = false for plain Floats and a dependency-free install.

to_json round-trips correctly — BigDecimals are rendered as unquoted JSON numbers, not strings.

Methods give you Ruby values; [] gives you the wire value

t.date         # => 2024-03-15 00:00:00 UTC  (Time)
t[:date]       # => same
t["date"]      # => "2024-03-15T00:00:00.000Z"  (verbatim)
t["createdAt"] # => camelCase keys work too
t.to_h         # => wire-shaped hash

Date-only values like bill.due_date become Date. Values that only look temporal are left alone: bill_forecast_date and month_year are "2024-03" and "01-2025", so they stay Strings.

Enum-ish fields are raw Strings, on purpose

The published spec and the live API disagree. Pluggy's own examples return EFETIVA where the schema says EFFECTIVE, MES where it says MONTH, UNICA where it says UNIQUE. Modelling these as closed constants would reject real data, so the gem never validates them — it only offers predicates (loan.financing?, t.pix?, item.updated?) that compare strings.

Related: LoanContractedFinanceCharge is documented with rate/additionalInfo but sends chargeRate/chargeAdditionalInfo; both spellings are readable, and #charge_rate/#info pick whichever arrived. Bill#accountId and BillFinanceCharge#creditCardBillId are required by the schema yet missing from its properties — they are present at runtime and readable.

loan.cet

CET (Custo Efetivo Total) is the only uppercase field name in the API. All three of loan.cet, loan.CET and loan["CET"] work.

Pagination

Three shapes in the API, one interface in the gem: everything answers each and auto_paging_each.

endpoint shape notes
/v2/transactions cursor {results, next} the only cursor-paginated endpoint
/transactions (v1) offset {results, page, total, totalPages} deprecated, accepts page/pageSize
/connectors offset accepts page/pageSize
/accounts, /bills, /loans, statements offset single page — no page parameter exists
/categories array or offset the spec contradicts itself; the gem sniffs the payload

For /accounts, /bills and /loans the API returns a page envelope but documents no way to request page 2, so auto_paging_each yields the one page and stops rather than re-requesting it.

Resuming a cursor

Persist next_token — the whole query string Pluggy handed back, filters included — not a bare cursor:

page = client.transactions.list(account_id: id)
redis.set("cursor:#{id}", page.next_token)

# later, in another process
page = client.transactions.resume(redis.get("cursor:#{id}"))

Transactions: v2 vs v1

GET /v2/transactions is the default. GET /transactions is deprecated with a 2026-12-31 sunset, and is the only place billId, page and pageSize exist — passing any of them routes there automatically and logs a deprecation notice.

client.transactions.list(account_id: id, date_from: "2024-01-01")  # v2
client.transactions.list(account_id: id, bill_id: bill.id)         # v1, logged
client.transactions.list(account_id: id, version: :v1)             # v1, explicit
Pluggy.transactions_api_version = :v1                              # global default

v2 renamed from/to to date_from/date_to, and rejects date_from combined with created_at_from (the gem catches that locally rather than spending a round-trip on a 400).

Bill line items

v2 dropped the billId filter that v1 had, so there is no supported server-side way to list one bill's transactions. bill.transactions lists the account's transactions over the statement cycle and filters on credit_card_metadata.bill_id.

The window is derived, not guessed: bills.list sees every bill with its closing date, so each bill knows its predecessor's, and the window is exactly one cycle. The billId check is authoritative and always runs, so the window only affects how many requests happen, never which transactions come back — a wrong window costs time, never correctness. A bill fetched alone via bills.retrieve has no predecessor and falls back to a 62-day window, logging that fact.

bill.transactions                                     # Enumerator; nothing fetched until iterated
bill.transactions.to_a
bill.transactions(date_from: "2024-01-01")            # override the window
bill.transactions(strategy: :legacy)                  # v1's server-side billId filter, one request

transaction.bill_id is public if you would rather group them yourself.

Errors

All inherit from Pluggy::Error and carry the whole transcript: http_status, http_body, json_body, http_headers, plus Pluggy's code and code_description. message appends the code_description because that is what you want in a log; api_message is the undecorated text.

class when
AuthenticationError bad client keys, or an apiKey that could not be renewed
PermissionError a 403 with a codeDescription, e.g. BALANCE_CONSENT_ERROR
InvalidRequestError 400 — #parameter_errors, #invalid_cursor?
NotFoundError 404
ConflictError 409 — #duplicate_item_ids
RateLimitError 429 — #retry_after
APIError / BadGatewayError 500 / 502 (institution unavailable)
ConnectionError / TimeoutError transport level

Pluggy signals an expired apiKey with a 403 that has no codeDescription, and a genuine denial with a 403 that has one. The gem renews only on the former; the latter raises PermissionError immediately, un-retried.

GETs and DELETEs are retried on connection errors and 429/500/502/503/504 with exponential backoff and jitter. POST /items is never retried — the API has no idempotency key, so a retry could open a duplicate bank connection.

Configuration

Pluggy::Client.new(
  client_id: "...", client_secret: "...",
  read_timeout: 80,             # /accounts/{id}/balance queries the institution live
  max_network_retries: 2,
  logger: Rails.logger,         # secrets are redacted
  log_level: :info,             # :debug logs every request
  decimal_amounts: true,
  coerce_times: true,
  transactions_api_version: :v2
)

Connections are pooled per thread. After forking (Puma, Unicorn, Sidekiq), clear the inherited sockets:

on_worker_boot { Pluggy::ConnectionManager.current.clear! }

Out of scope

Payments, smart transfers, boletos, consents, webhooks, investments and identity are not modelled. Reach them with the raw escape hatches, which handle auth and retries but return parsed JSON rather than resource objects:

client.get("/investments", item_id: item_id)
client.post("/payments/customers", name: "...")

Development

mise install
bundle install
bundle exec rake                  # rubocop + rspec
bundle exec rake fixtures:extract # regenerate fixtures from the vendored OpenAPI spec
bundle exec rake "schema:fields[Transaction]"

The suite is hermetic (WebMock, no network). Fixtures are generated from Pluggy's own OpenAPI examples where they exist and hand-written where they do not. To run the live smoke test against the real API:

PLUGGY_CLIENT_ID=... PLUGGY_CLIENT_SECRET=... bundle exec rspec --tag live

License

MIT.