Aller au contenu

omnisense/omnibank

Omnibank: one contract for bank access - accounts, balances, transactions, transfers, consents and webhooks - over statement files and bank APIs, and its Symfony bundle (Omnitrade's sibling for banking).

Étage
Famille
Version
1.x
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnisense/omnibank:1.x-dev

Omnibank is one PHP contract for bank access. An application asks every source the same questions

  • open the access, which accounts, what do they hold, which lines were booked, send this transfer, what does this webhook say - and gets the same answers back: Account, Balance, BankTransaction, TransferResult, Notification. A source is a package of its own: the statement files a bank exports (omnibank/files), a bank's own API (omnibank/qonto), or an aggregator that reaches many banks after the user's consent (omnibank/powens, omnibank/bridge).
$gateway = $registry->get('treasury');

$result = $gateway->connect($connection, 'https://app.example/bank/back');
$connection = $result->connection;                     // keep it: the provider's user, its tokens
if ($result->url) { return redirect($result->url); }   // the provider's consent page

foreach ($gateway->accounts($connection) as $account) {
    $gateway->balances($connection, $account);                                          // list<Balance>
    $gateway->transactions($connection, $account, new \DateTimeImmutable('-30 days'));  // list<BankTransaction>, oldest first
}
$gateway->transfer($connection, $transfer);                          // a TransferResult (a pain.001 file, for files)
$gateway->notify($request->getContent(), $request->headers->all());  // a webhook, checked and read

The design is Payum's, as in Omnibus and Omnitrade: a provider's factory fills a configuration with its name, its options, its API client and one action per request it answers; the gateway it builds runs the action that supports each request; a registry holds the application's gateways by name. The core needs no framework: it requires symfony/http-client-contracts and nothing else. A provider that calls an API requires symfony/http-client, a library that stands alone; omnibank/files calls nothing and requires neither. A Symfony bundle is included (Symfony).

Published as omnisense/omnibank, formerly omniscient/omnibank and before that glitchr/omnibank: the package replaces the old names. The namespace is Omnibank\; providers keep the omnibank/ vendor and the Omnibank\<Provider>\ namespace.

When to use it

  • An application that reads its own bank accounts - a treasury screen, a reconciliation of invoices with what was paid - from more than one bank, or wants to change source without rewriting it.
  • Bank statement files dropped by a user (CAMT.053, OFX, CSV), read into the same models as an API.
  • SEPA transfers prepared as the pain.001 file a bank's website takes.
  • An aggregator's consent flow (a hosted page, a connection to keep, webhooks that say when the consent must be renewed).

It is not a payment gateway (charging cards is Omnitrade's) and it stores nothing: keeping the Connection (it holds tokens), the accounts and the transactions is the application's job.

Providers

The capabilities are what each gateway's supports() answers with its required options set (probed on 2026-10-26).

Package Factory name Source Connect Accounts Balances Transactions Transfer Notify
omnibank/files files statement files (CAMT.053, OFX, CSV); transfers as pain.001.001.03 yes (nothing to open) yes yes yes yes (a file) -
omnibank/qonto qonto Qonto's Business API, an API key yes (nothing to open) yes yes yes - -
omnibank/powens powens Powens (ex Budget Insight), its Webview yes yes yes yes - yes
omnibank/bridge bridge Bridge API v3 (bridgeapi.io), Connect sessions yes yes yes yes - yes

Qonto, Powens and Bridge are written from their published documentation and recorded answers: none has been run against the real service or its sandbox yet (Verified, and not).

How this manual is organised

Proposer une modification docs/index.md

Installation

composer require omnisense/omnibank omnibank/files                 # statement files: no network
composer require omnibank/qonto omnibank/powens omnibank/bridge  # a bank's API, an aggregator

Requirements

  • PHP 8.2 or later.
  • omnisense/omnibank requires symfony/http-client-contracts (^3.0) and nothing else.
  • omnibank/qonto, omnibank/powens and omnibank/bridge require symfony/http-client (^6.4, ^7.0 or ^8.0).
  • omnibank/files requires the dom and libxml extensions (in most PHP builds); intl is suggested (names put in the SEPA character set; iconv otherwise) and iconv (Windows-1252 exports read as UTF-8).

Omnibank needs no framework. The core requires nothing but symfony/http-client-contracts; a provider that calls an API requires symfony/http-client, a library that stands alone; omnibank/files calls nothing and requires neither. It runs the same in plain PHP, in a worker, in Laravel or Slim, and in Symfony, where a bundle does the wiring (Symfony).

Plain PHP

<?php // bare.php

require __DIR__.'/vendor/autoload.php';

use Omnibank\Files\FilesGatewayFactory;
use Omnibank\Model\Connection;
use Omnibank\Registry;

$registry = new Registry([new FilesGatewayFactory()], [
    'statements' => ['factory' => 'files'],
]);
$gateway = $registry->get('statements');

// What a French bank exports: semicolons, dd/mm/yyyy, a decimal comma, the balance after each line
$csv = <<<'CSV'
Date;Date de valeur;Débit;Crédit;Libellé;Solde
01/09/2026;01/09/2026;-1 234,56;;PRLV SEPA LOYER SCI DES TILLEULS;8 765,44
02/09/2026;02/09/2026;;2 500,00;VIR SEPA RECU /DE NAKAYA SAS /MOTIF SALAIRE;11 265,44
05/09/2026;05/09/2026;-45,90;;PRLV SEPA EDF CLIENT 12345;11 219,54
CSV;
$connection = new Connection(['statements' => [
    ['name' => 'export.csv', 'content' => $csv, 'account' => ['iban' => 'FR7630006000011234567890189', 'name' => 'Compte courant']],
]]);

foreach ($gateway->accounts($connection) as $account) {
    echo $account->name, ' ', $account->iban, "\n";
    foreach ($gateway->balances($connection, $account) as $balance) {
        echo $balance->type, ' ', $balance->amount->decimal(), ' ', $account->currency, ' on ', $balance->at->format('Y-m-d'), "\n\n";
    }
    foreach ($gateway->transactions($connection, $account) as $transaction) {
        printf("%s %9s  %s\n", $transaction->bookedOn->format('Y-m-d'), $transaction->amount->decimal(), $transaction->label);
    }
}
$ php bare.php
Compte courant FR7630006000011234567890189
booked 11219.54 EUR on 2026-09-05

2026-09-01  -1234.56  PRLV SEPA LOYER SCI DES TILLEULS
2026-09-02   2500.00  VIR SEPA RECU /DE NAKAYA SAS /MOTIF SALAIRE
2026-09-05    -45.90  PRLV SEPA EDF CLIENT 12345

(run on 2026-10-05 in an empty directory, after composer require omnibank/files and the core: three packages installed, symfony/http-client-contracts the only one that is not Omnibank's; run again on 2026-10-26 with PHP 8.4.27 on the family's checkouts, the same output; the account, the names and the amounts are made up)

No key, no network: the statement travels in the connection. That is all there is to it:

  • a factory per provider package (FilesGatewayFactory, QontoGatewayFactory, PowensGatewayFactory, BridgeGatewayFactory); one that calls an API takes the HTTP client to call with - the application's, a MockHttpClient in a test - and with none given makes its own (HttpClient::create());
  • the registry, built by hand from the factories and the gateways' options, by name;
  • the gateways it gives: connect(), accounts(), balances(), transactions(), transfer(), notify() - the same questions for every provider;
  • the connection, which the application keeps (serialized, as it keeps secrets) and hands back on every call.

No class of a framework is loaded on the way - a test of this package checks it in a process of its own (Tests/BareTest.php), and so does docker compose run --rm omnibank bare (harness).

A bank's API

use Omnibank\Model\Connection;
use Omnibank\Qonto\QontoGatewayFactory;
use Symfony\Component\HttpClient\HttpClient;

$qonto = (new QontoGatewayFactory(HttpClient::create()))->create([
    'login' => getenv('QONTO_LOGIN'),
    'secret_key' => getenv('QONTO_SECRET_KEY'),
]);

$connection = new Connection();                  // Qonto: the key is the access, nothing to keep
foreach ($qonto->accounts($connection) as $account) {
    $qonto->balances($connection, $account);                                          // list<Balance>
    $qonto->transactions($connection, $account, new DateTimeImmutable('-30 days'));   // list<BankTransaction>, oldest first
}

In a test, the same factory on a client that answers from files:

use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$http = new MockHttpClient(static fn (string $method, string $url) => new MockResponse(file_get_contents(__DIR__.'/organization.json')));
$qonto = (new QontoGatewayFactory($http))->create(['login' => 'example-organization', 'secret_key' => 'qonto_test_not_a_real_key']);
use Omnibank\Model\Connection;
use Omnibank\Powens\PowensGatewayFactory;
use Omnibank\Registry;

$registry = new Registry([new PowensGatewayFactory($http)], [
    'banks' => ['factory' => 'powens', 'options' => ['domain' => '...', 'client_id' => '...', 'client_secret' => '...']],
]);
$gateway = $registry->get('banks');

$connection = Connection::fromArray($stored ?? []);     // what was kept for this user, if anything
$result = $gateway->connect($connection, 'https://app.example/bank/back');
$store($result->connection->toArray());                 // keep it: the provider's user, its tokens
if ($result->url) {
    header('Location: '.$result->url);                  // the provider's consent page
    exit;
}

$gateway->supports(Request\Transfer::class) says beforehand what a provider does; what it does not do is a RequestNotSupportedException.

In a framework

  • Symfony: Omnibank\Bridge\Symfony\OmnibankBundle registers the factories on the application's http_client, builds the registry from config/packages/omnibank.yaml and makes each gateway injectable by its name: see Symfony. Its components (symfony/config, symfony/dependency-injection, symfony/http-kernel) are not required by this package: a Symfony application has them.
  • Laravel: no bridge package exists; a service provider builds the registry once: Laravel.
  • Any other: build the Registry once, where the framework builds its services (a service provider, a container definition), as the script above does.

Errors

Exception When
RequestNotSupportedException the provider does not do that: supports() says so beforehand
InvalidConfigException a gateway not configured, a factory not installed, an option missing
ProviderException the provider refused (wrong credentials, an unknown account)
InvalidNotificationException a webhook whose signature does not hold
UnavailableException the provider could not be reached or failed (network, timeout, 5xx, 429): never "no data"

All implement Omnibank\Exception\OmnibankException; Errors has each one's parent and where it is thrown.

Proposer une modification docs/installation.md

Symfony

Omnibank runs without a framework (installation); in a Symfony application its bundle does the wiring. Its components - symfony/config, symfony/dependency-injection, symfony/http-kernel - are not required by omnisense/omnibank: the application has them, and nothing of them is loaded outside Symfony.

Register Omnibank\Bridge\Symfony\OmnibankBundle (no Flex recipe):

// config/bundles.php
return [
    // ...
    Omnibank\Bridge\Symfony\OmnibankBundle::class => ['all' => true],
];
# config/packages/omnibank.yaml
omnibank:
    gateways:                      # by name: a factory and its options
        statements: { factory: files, options: { debtor_name: 'Nakaya SAS', debtor_bic: AGRIFRPP882 } }
        treasury:   { factory: qonto, options: { login: '%env(QONTO_LOGIN)%', secret_key: '%env(QONTO_SECRET_KEY)%' } }
        banks:      { factory: powens, options: { domain: '%env(POWENS_DOMAIN)%', client_id: '%env(POWENS_CLIENT_ID)%', client_secret: '%env(POWENS_CLIENT_SECRET)%', webhook_secret: '%env(POWENS_WEBHOOK_SECRET)%' } }
# .env.local, or bin/console secrets:set
QONTO_LOGIN=...
QONTO_SECRET_KEY=...

The configuration tree

As the bundle's configure() declares it (dumped with Symfony's YamlReferenceDumper, the dumper config:dump-reference omnibank uses):

omnibank:

    # The application's bank accesses, by name: a factory (files, qonto, powens, bridge...) and its options.
    gateways:

        # Prototype
        name:
            factory:              ~ # Required
            options:              []
Key Type Default
gateways.<name>.factory string required a factory's name: files, qonto, powens, bridge, or an application factory's getName()
gateways.<name>.options any [] handed whole to the factory's create(); each provider's options are in Configuration

What the bundle registers

Every omnibank/* package installed registers its factory, on the application's http_client (service('http_client')->nullOnInvalid(): without FrameworkBundle's HTTP client the factory gets none and makes its own with HttpClient::create()).

Service
Omnibank\Files\FilesGatewayFactory, Omnibank\Qonto\QontoGatewayFactory, Omnibank\Powens\PowensGatewayFactory, Omnibank\Bridge\BridgeGatewayFactory private, tagged omnibank.gateway_factory each when its package is installed
Omnibank\Registry public every service tagged omnibank.gateway_factory, the configured gateways
omnibank.gateway.<name> private Registry::get('<name>'), autowired as GatewayInterface $<name>

Any service of the application implementing Omnibank\GatewayFactoryInterface is tagged omnibank.gateway_factory by autoconfiguration. The bundle defines no command, event or Twig function.

The services compiled for one gateway statements (factory: files), with the four providers installed, and the gateway an argument GatewayInterface $statements receives:

Omnibank\Bridge\BridgeGatewayFactory
Omnibank\Files\FilesGatewayFactory
Omnibank\Powens\PowensGatewayFactory
Omnibank\Qonto\QontoGatewayFactory
Omnibank\Registry
Omnibank\Gateway files
files, qonto, powens, bridge

(run on 2026-10-26, Symfony 8 DependencyInjection, the family's checkouts; the last two lines are the injected gateway's class and name, then Registry::factories())

What is autowired:

Service
GatewayInterface $treasury one gateway by the argument's name (the configured name)
Registry every configured gateway by name (get(), has(), names(), create() with other options)
public function __construct(private readonly GatewayInterface $treasury)
{
}

Nothing is built when the container compiles: a gateway is built the first time it is asked for, and an option left empty only shows then (InvalidConfigException).

An application's own provider - a class implementing GatewayFactoryInterface - is registered too, autoconfigured, and can be named as a factory.

final class BankWebhookController extends AbstractController
{
    #[Route('/bank/webhook', methods: ['POST'])]
    public function __invoke(Request $request, GatewayInterface $banks): Response
    {
        try {
            $notification = $banks->notify($request->getContent(), $request->headers->all());
        } catch (InvalidNotificationException) {
            return new Response(status: 400);
        }
        // $notification->connectionId, ->consent: fetch again, or ask the user to renew

        return new Response(status: 204);
    }
}

Proposer une modification docs/symfony.md

Laravel

There is no Laravel bridge for Omnibank: omnibridge/laravel has none, and this package ships no service provider. None is needed for it to run - the core needs no framework - so a service provider of the application builds the registry once, by hand, as in plain PHP.

A service provider

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Omnibank\Files\FilesGatewayFactory;
use Omnibank\GatewayInterface;
use Omnibank\Powens\PowensGatewayFactory;
use Omnibank\Qonto\QontoGatewayFactory;
use Omnibank\Registry;
use Symfony\Component\HttpClient\HttpClient;

final class BankServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Registry::class, function () {
            $http = HttpClient::create();

            return new Registry(
                [new FilesGatewayFactory(), new QontoGatewayFactory($http), new PowensGatewayFactory($http)],
                [
                    'statements' => ['factory' => 'files', 'options' => ['debtor_name' => config('bank.debtor_name')]],
                    'treasury' => ['factory' => 'qonto', 'options' => ['login' => config('services.qonto.login'), 'secret_key' => config('services.qonto.secret_key')]],
                    'banks' => ['factory' => 'powens', 'options' => [
                        'domain' => config('services.powens.domain'),
                        'client_id' => config('services.powens.client_id'),
                        'client_secret' => config('services.powens.client_secret'),
                        'webhook_secret' => config('services.powens.webhook_secret'),
                        'webhook_url' => route('bank.webhook'),
                    ]],
                ],
            );
        });

        // One gateway where a class asks for a GatewayInterface
        $this->app->when(\App\Http\Controllers\TreasuryController::class)
            ->needs(GatewayInterface::class)
            ->give(fn ($app) => $app->make(Registry::class)->get('treasury'));
    }
}

The registry builds each gateway the first time it is asked for, so a missing option shows then (InvalidConfigException), not at boot.

A webhook route

// routes/api.php: no session, no CSRF check on a provider's POST
use Illuminate\Http\Request;
use Omnibank\Exception\InvalidNotificationException;
use Omnibank\Registry;

Route::post('/bank/webhook', function (Request $request, Registry $registry) {
    try {
        $notification = $registry->get('banks')->notify($request->getContent(), $request->headers->all());
    } catch (InvalidNotificationException) {
        return response()->noContent(400);
    }
    // $notification->connectionId, $notification->consent

    return response()->noContent();
})->name('bank.webhook');

The body must be the raw one (getContent()): the signature is computed over its bytes. $request->headers->all() gives each header as a list, which Request\Notify::header() reads whatever its case.

Proposer une modification docs/laravel.md

Concepts

Gateways, factories, the registry

GatewayInterface

Omnibank\GatewayInterface is one bank access, configured: statement files, a bank's own API or an aggregator, the same questions for all of them.

Method Answers
getName(): string the factory's name (files, qonto...)
getTitle(): string the factory's title (Statement files, Qonto...)
supports(string $request): bool whether an action answers that request class (Request\Transfer::class...)
execute(Request $request): Request the request, answered by the first action that supports it
connect(Connection $connection, string $returnUrl): ConnectResult opens or renews the access: the page to send the user to, the connection to keep
accounts(Connection $connection): array list<Account>
balances(Connection $connection, Account $account): array list<Balance>
transactions(Connection $connection, Account $account, ?DateTimeInterface $since = null, ?DateTimeInterface $until = null): array list<BankTransaction>, oldest first, both dates included
transfer(Connection $connection, Transfer $transfer): TransferResult
notify(string $body, array $headers = []): Notification a webhook, its signature checked

Each typed method is execute() with its request. A request no action supports is a RequestNotSupportedException (The "qonto" gateway does not support Transfer.); an action that leaves its request unanswered is a ProviderException.

Gateway

Omnibank\Gateway (final) is the implementation every factory builds: a name, a title and a list of actions. supports() asks each action with a probe of the request class made without its constructor; execute() runs the first action that supports the request.

GatewayFactoryInterface and GatewayFactory

Omnibank\GatewayFactoryInterface: getName(): string (what factory: names in a configuration) and create(array $options = []): GatewayInterface.

Omnibank\GatewayFactory is the abstract base every provider extends. Its constructor takes an optional Symfony\Contracts\HttpClient\HttpClientInterface ($this->http), the client the provider calls with; a provider that calls an API makes its own (HttpClient::create()) when given none. create():

  1. fills a Config with the options given, then the factory's defaults (populateConfig()) under them;
  2. checks the keys of omnibank.required_options: one missing or empty (null, '', []) is an InvalidConfigException naming them all (The "powens" gateway needs: domain, client_id.);
  3. builds the API client from omnibank.api (a closure of the Config is called);
  4. collects every omnibank.action.<name> (a closure of the Config is called), hands the API to the actions that implement ApiAwareInterface, and returns a Gateway.

Building a gateway calls nothing over the network.

Config

Omnibank\Config (final, an ArrayObject) is a gateway's configuration while its factory builds it: defaults(array) sets the keys not set yet, validateNotEmpty(array $keys), get($key, $default). The keys of the family:

Key
omnibank.factory_name, omnibank.factory_title the gateway's name and title
omnibank.required_options the option names that must not be empty
omnibank.api the API client, or a closure of the Config that makes it
omnibank.action.<name> an action, or a closure of the Config that makes it

Everything else is a provider's options (Configuration).

Registry

Omnibank\Registry (final): the application's gateways by name.

new Registry(iterable $factories, array $config);
// $config: ['<name>' => ['factory' => '<factory name>', 'options' => [...]], ...]
Method
get(string $name): GatewayInterface built on first use, then kept. No "x" gateway; configured: a, b. and No "y" factory for the "x" gateway; installed: files, qonto. as InvalidConfigException
has(string $name): bool configured or not
options(string $name): array the options a gateway is configured with
create(string $name, array $overrides = []): GatewayInterface a gateway built afresh, $overrides over its options (keys typed in a back office); not kept
names(): array the configured names
all(): array every gateway, built, by name
factories(): array the factory names installed

Requests and actions

Omnibank\Request\Request (abstract) carries a question and its result: setResult(), getResult(), isAnswered(). Every request is final with public readonly properties:

Request Properties Result
Connect connection, returnUrl getConnectResult(): ?ConnectResult
FetchAccounts connection getAccounts(): list<Account>
FetchBalances connection, account getBalances(): list<Balance>
FetchTransactions connection, account, since, until getTransactions(): list<BankTransaction>; covers(DateTimeInterface $bookedOn): bool compares days (Y-m-d), both ends included
Transfer connection, transfer getTransferResult(): ?TransferResult
Notify body, headers getNotification(): ?Notification; header(string $name): ?string reads a header whatever its case (the first value of a list)

Omnibank\Action\ActionInterface: supports(Request): bool, execute(Request): void (sets the result or throws). ApiAwareInterface::setApi(object $api), and ApiAwareTrait, which checks the API's class ($this->apiClass, set in the action's constructor) and throws an InvalidConfigException for another one.

The models

Every model is a final readonly class: built once, never changed.

Money

An amount in a currency's minor units, signed: -1250 EUR is 12.50 euros gone out of the account.

Member
amount (int), currency (string, upper-cased)
Money::of(int $amount, string $currency)
Money::fromDecimal(string|int|float $decimal, string $currency) a string is read digit by digit, never through a float ('1234.56' is exactly 123456), rounded half up on what lies beyond the currency's digits; a number is multiplied and rounded; anything else is an \InvalidArgumentException
decimal(): string "-12.50"; no decimal point for a currency without minor units
isNegative(), isZero(), negate(), abs(), equals(Money)
Money::minorDigits(string $currency): int 0 for BIF, CLP, DJF, GNF, ISK, JPY, KMF, KRW, MGA, PYG, RWF, UGX, UYI, VND, VUV, XAF, XOF, XPF; 3 for BHD, IQD, JOD, KWD, LYD, OMR, TND; 2 otherwise
__toString() "-12.50 EUR"

Account

Property Type
id string the provider's: what balances() and transactions() are asked with (files: the IBAN, else the statement's account number or the file's name)
iban, bic ?string
name string
currency string ISO 4217
type ?string the provider's word: checking, savings, card, external...
raw array the provider's whole answer, for what the model leaves out

Balance

amount (Money), at (DateTimeImmutable), type (string, default booked). The types used: booked (what has been entered), available (what can be spent), expected (with what is coming), and each provider's own (opening, instant, interim_booked... - see the providers).

BankTransaction

Property Type
id string stable: the bank's reference, or a fingerprint of the line, so the same line read twice is the same transaction
bookedOn DateTimeImmutable the booking date
valueOn ?DateTimeImmutable the value date, when given
amount Money negative when money leaves the account
label string the bank's label, as on the statement
counterpartyName, counterpartyIban ?string
reference ?string what the payer wrote (remittance information), or an end-to-end id
raw array the provider's line, whole

isDebit() is amount->isNegative().

Connection is what the application keeps for one bank access and hands back on every call: state (array: the provider's user, its tokens, the statement files) and consent (Consent, NONE by default). withState(array) replaces the state, withConsent(Consent) the consent, get($key, $default) reads a state key; toArray() / Connection::fromArray() keep it as JSON ({"state": ..., "consent": {"status": ..., "expires_at": ...}}), and it serializes. It holds credentials: store it as secrets are stored.

Consent: status (ConsentStatus), expiresAt (?DateTimeImmutable). Consent::none(), active(?expiresAt), needsRenewal(?expiresAt), revoked(); isActive(?$now) is true when ACTIVE and not past expiresAt; toArray() (expires_at in DATE_ATOM), fromArray() (an unknown status reads as NONE).

ConsentStatus Value
ACTIVE active the accounts can be read
NEEDS_RENEWAL needs_renewal the user must go through the provider's page again (expired consent, SCA, new password)
REVOKED revoked the user or the bank withdrew it
NONE none never given

ConnectResult

url (?string: the page to send the user to, null when there is nothing to do) and connection (the connection to keep: its state may have changed). isRedirect() is url !== null.

Transfer and TransferResult

Transfer, a SEPA credit transfer to make:

Property Type
debtorIban string the account the money leaves
creditorName, creditorIban string
creditorBic ?string
amount Money positive
reference string the remittance information the creditor sees, 140 characters at most
endToEndId ?string travels with the money to the creditor's bank
executionDate ?DateTimeImmutable today when null

TransferResult: id (the provider's, or the file's message id), status (generated for a file to upload, or the provider's word), file and fileName (?string: the pain.001 to hand the bank).

Notification

type (the provider's name for the event: CONNECTION_SYNCED, item.refreshed...), connectionId (?string: the provider's connection, a Powens connection or a Bridge item), consent (Consent: what the event means for the user's consent; NONE when it says nothing of it), raw (the event, decoded).

Proposer une modification docs/concepts.md

Connecting and consent

Every call takes a Connection: what the application keeps for one bank access. connect() opens the access or renews it, and gives the connection back with its new state.

$result = $gateway->connect($connection, 'https://app.example/bank/back');
$store($result->connection->toArray());     // keep it, as secrets are kept
if ($result->isRedirect()) {
    return redirect($result->url);          // the provider's page: a Webview, a Connect session
}

Three kinds of access

Provider What connect() does What the connection holds
files nothing: no page, the consent set ACTIVE the statement files themselves (state.statements)
qonto nothing: no page, the consent set ACTIVE (the API key is the access) nothing
powens creates the Powens user the first time, then gives the Webview's page auth_token (the user's permanent token), user_id, and connection_id once the application puts it there
bridge creates the Bridge user the first time, asks a token, opens a Connect session user_uuid, external_user_id, access_token, expires_at, and item_id, user_email when the application puts them there

For files and qonto a new Connection() (or the files in its state) is enough, and connect() can be skipped altogether: no call depends on the consent.

An aggregator's flow

On a mock client answering as Powens' API does, a whole first connection and a renewal:

<?php // connect.php

require __DIR__.'/vendor/autoload.php';

use Omnibank\Model\Connection;
use Omnibank\Model\Consent;
use Omnibank\Powens\PowensGatewayFactory;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

// Powens' answers, as its API gives them (the user's creation, then a temporary code)
$http = new MockHttpClient(function (string $method, string $url) {
    echo '  ', $method, ' ', $url, "\n";

    return new MockResponse(match (true) {
        str_ends_with($url, '/auth/init') => '{"auth_token": "pwns_test_not_a_real_token", "type": "permanent", "id_user": 1987}',
        str_ends_with($url, '/auth/token/code') => '{"code": "tmp_8fR2mWq1ZpL0", "type": "temporary", "access": "single", "expires_in": 1800}',
    });
});
$gateway = (new PowensGatewayFactory($http))->create(['domain' => 'example-sandbox.biapi.pro', 'client_id' => '12345678', 'client_secret' => 'secret', 'language' => 'fr']);

echo "first connect:\n";
$result = $gateway->connect(new Connection(), 'https://app.example/bank/back');
echo '  redirect to ', $result->url, "\n";
echo '  keep ', json_encode($result->connection->toArray()), "\n";

// Back from the Webview with ?connection_id=77, later the consent needs renewal
$kept = $result->connection->withState(['connection_id' => '77'] + $result->connection->state)->withConsent(Consent::needsRenewal());
echo "renewal:\n";
$url = $gateway->connect($kept, 'https://app.example/bank/back')->url;
echo '  redirect to ', $url, "\n";
$ php connect.php
first connect:
  POST https://example-sandbox.biapi.pro/2.0/auth/init
  GET https://example-sandbox.biapi.pro/2.0/auth/token/code
  redirect to https://webview.powens.com/fr/connect?domain=example-sandbox.biapi.pro&client_id=12345678&redirect_uri=https%3A%2F%2Fapp.example%2Fbank%2Fback&code=tmp_8fR2mWq1ZpL0
  keep {"state":{"auth_token":"pwns_test_not_a_real_token","user_id":1987},"consent":{"status":"none","expires_at":null}}
renewal:
  GET https://example-sandbox.biapi.pro/2.0/auth/token/code
  redirect to https://webview.powens.com/fr/reconnect?domain=example-sandbox.biapi.pro&client_id=12345678&redirect_uri=https%3A%2F%2Fapp.example%2Fbank%2Fback&code=tmp_8fR2mWq1ZpL0&connection_id=77

(run on 2026-10-26, PHP 8.4.27, no network: the answers are written as Powens documents them, the token and the code are made up)

The steps the application owns:

  1. Keep the connection connect() returns, before redirecting: it holds the user's token.
  2. On the return URL, Powens' Webview adds the new bank connection's connection_id to the query; Bridge's item id comes with its item.* webhooks (Notification::$connectionId). Put it in the state and mark the consent active: $connection->withState(['connection_id' => $id] + $connection->state)->withConsent(Consent::active()). No provider's connect() sets the consent of an aggregator: it stays as it was until the application or a webhook says.
  3. When the consent needs renewal (a webhook said so, or a call was refused), set Consent::needsRenewal() and call connect() again: with connection_id (Powens) or item_id (Bridge) in the state, the page is the one that renews that bank connection; without it, a new connection is opened.

The return URL is connect()'s second argument; with an empty string, Powens uses its redirect_uri option and Bridge its callback_url option, and an InvalidConfigException says so when neither is set. The URL must be whitelisted in the provider's console.

Connection::$consent is the application's record of where the user's consent stands:

  • ConsentStatus::ACTIVE, with an expiresAt when the provider says (isActive() checks it);
  • NEEDS_RENEWAL: the user must go through the provider's page again;
  • REVOKED: withdrawn (a Bridge item deleted);
  • NONE: never given.

Webhooks are where an aggregator says the consent changed (Webhooks): each Notification carries the Consent it implies and the provider's connectionId, to find the application's connection and update it.

Several accesses

One Connection per bank access and per user: a Powens user with three banks is one connection (its accounts() lists the accounts of every bank connected); two Qonto organizations are two gateways (two API keys), each with an empty connection.

Proposer une modification docs/usage-connecting.md

Accounts, balances and transactions

foreach ($gateway->accounts($connection) as $account) {
    foreach ($gateway->balances($connection, $account) as $balance) {
        // $balance->type: booked, available, expected...; $balance->amount, $balance->at
    }
    foreach ($gateway->transactions($connection, $account, $since, $until) as $transaction) {
        // oldest first; $transaction->amount negative when money left the account
    }
}

Accounts

accounts() lists what the connection reaches: the organization's bank accounts (Qonto), every account of every bank the user connected (Powens, Bridge; deleted ones left out), one account per IBAN or account number the statement files are about (files). Account::$id is what balances() and transactions() are asked with; keep the Account (or its id and IBAN) rather than rebuilding one by hand.

Balances

balances() gives what the provider knows of an account now, one Balance per type:

Provider Types
files the latest of each type the statements give: CAMT.053 booked (CLBD), available (CLAV), opening (OPBD), interim_booked, interim_available, forward_available, previously_closed, opening_available, expected; OFX booked (LEDGERBAL), available (AVAILBAL); CSV booked, the balance column after the latest line
qonto booked (balance_cents), available (authorized_balance_cents), at the account's updated_at
powens booked (balance), and expected (balance + coming) when something is coming, at last_update
bridge booked (balance), instant (instant_balance), at updated_at

A balance is as fresh as the provider's last synchronisation with the bank: an aggregator's at says when that was.

Transactions

transactions($connection, $account, $since = null, $until = null) gives the account's booked lines, oldest first:

  • $since and $until are days, both included (FetchTransactions::covers() compares Y-m-d); either can be null. Providers with an API pass them on (Qonto's settled_at_from / settled_at_to, Powens' min_date / max_date, Bridge's since / until) and filter the answer again.
  • Every page is read: Qonto by meta.next_page, Powens by _links.next.href (else the next offset while pages come full), Bridge by pagination.next_uri, up to 1000 pages.
  • Pending lines are left out: CAMT entries not BOOK, Qonto transactions not completed, Powens' coming and deleted, Bridge's future and deleted.
  • amount is signed: negative when money left the account (CAMT DBIT, Qonto side: debit, the sign of OFX's TRNAMT, Powens' value and Bridge's amount; a CSV debit column made negative).
  • id is stable: the provider's id, the bank's reference in a file (AcctSvcrRef, FITID), or a fingerprint of the line, so a line read twice is one transaction.

Statement files

The files gateway reads the files a connection carries in state.statements:

<?php // reading.php

require __DIR__.'/vendor/autoload.php';

use Omnibank\Files\FilesGatewayFactory;
use Omnibank\Model\Connection;

$gateway = (new FilesGatewayFactory())->create();

// Two exports of the same account that overlap on 2026-09-05, and two identical coffees that day
$august = "Date;Libellé;Montant\n28/08/2026;CB CAFE DU COIN;-2,40\n05/09/2026;CB CAFE DU COIN;-2,40\n05/09/2026;CB CAFE DU COIN;-2,40\n";
$september = "Date;Libellé;Montant\n05/09/2026;CB CAFE DU COIN;-2,40\n05/09/2026;CB CAFE DU COIN;-2,40\n12/09/2026;VIR SEPA RECU /DE CAMILLE DURAND;1 250,00\n";
$connection = new Connection(['statements' => [
    ['name' => 'august.csv', 'content' => $august, 'account' => ['iban' => 'FR76 3000 6000 0112 3456 7890 189', 'name' => 'Compte courant']],
    ['name' => 'september.csv', 'content' => $september, 'account' => ['iban' => 'FR7630006000011234567890189']],
]]);

[$account] = $gateway->accounts($connection);
echo $account->id, ' ', $account->name, ' ', $account->currency, "\n";
foreach ($gateway->transactions($connection, $account) as $t) {
    printf("%s %8s %-5s %-34s %s\n", $t->bookedOn->format('Y-m-d'), $t->amount->decimal(), $t->isDebit() ? 'debit' : 'credit', $t->label, substr($t->id, 0, 12).(str_contains($t->id, '#') ? strstr($t->id, '#') : ''));
}
echo count($gateway->transactions($connection, $account, new DateTimeImmutable('2026-09-01'), new DateTimeImmutable('2026-09-05'))), " between 2026-09-01 and 2026-09-05\n";
$ php reading.php
FR7630006000011234567890189 Compte courant EUR
2026-08-28    -2.40 debit CB CAFE DU COIN                    23d6b7cf4b67
2026-09-05    -2.40 debit CB CAFE DU COIN                    e4e723b08fb4
2026-09-05    -2.40 debit CB CAFE DU COIN                    e4e723b08fb4#2
2026-09-12  1250.00 credit VIR SEPA RECU /DE CAMILLE DURAND   4bfcaaeca4ea
2 between 2026-09-01 and 2026-09-05

(run on 2026-10-26, PHP 8.4.27, no network; the ids are cut to 12 characters by the script)

The two exports are one account (the IBAN, written with or without spaces, is its id), the lines of the overlap once each, the two coffees of 2026-09-05 two transactions (<hash> and <hash>#2, the same in both files). The formats, the CSV column map and every option are on omnibank/files.

A provider that cannot answer

An UnavailableException (a network failure, a timeout, a 5xx, a 429) is never "no data": nothing is known. Do not read it as an empty list of accounts or transactions; keep what was read before and try again later (Errors).

Proposer une modification docs/usage-reading.md

Transfers

transfer($connection, $transfer) sends money: a SEPA credit transfer. Of the providers today only files does it, by writing the pain.001.001.03 file a bank's website takes: the application hands it to the user (a download) or uploads it where the bank allows. Qonto, Powens and Bridge do not support Request\Transfer in this version: supports(Request\Transfer::class) is false, and transfer() is a RequestNotSupportedException.

<?php // transfer.php

require __DIR__.'/vendor/autoload.php';

use Omnibank\Exception\ProviderException;
use Omnibank\Files\FilesGatewayFactory;
use Omnibank\Model\Connection;
use Omnibank\Model\Money;
use Omnibank\Model\Transfer;

$gateway = (new FilesGatewayFactory())->create(['debtor_name' => 'Nakaya SAS', 'debtor_bic' => 'AGRIFRPP882']);

$result = $gateway->transfer(new Connection(), new Transfer(
    debtorIban: 'FR76 3000 6000 0112 3456 7890 189',
    creditorName: 'Société Générale d\'Électricité',
    creditorIban: 'FR1420041010050500013M02606',
    creditorBic: null,
    amount: Money::fromDecimal('1250.00', 'EUR'),
    reference: 'Facture F-2026-042 — septembre',
    executionDate: new DateTimeImmutable('2026-10-30'),
));
echo $result->status, ' ', $result->fileName, "\n";
echo preg_replace('/OMNIBANK-\d{14}-[0-9A-F]{6}/', 'OMNIBANK-…', $result->file);

try {
    $gateway->transfer(new Connection(), new Transfer('FR7630006000011234567890189', 'X', 'FR0000000000000000000000000', null, Money::of(100, 'EUR'), 'test'));
} catch (ProviderException $e) {
    echo $e::class, ': ', $e->getMessage(), "\n";
}
$ php transfer.php
generated OMNIBANK-20261026092827-081B1D.xml
<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03">
  <CstmrCdtTrfInitn>
    <GrpHdr>
      <MsgId>OMNIBANK-…</MsgId>
      <CreDtTm>2026-10-26T09:28:27</CreDtTm>
      <NbOfTxs>1</NbOfTxs>
      <CtrlSum>1250.00</CtrlSum>
      <InitgPty>
        <Nm>Nakaya SAS</Nm>
      </InitgPty>
    </GrpHdr>
    <PmtInf>
      <PmtInfId>OMNIBANK-…-1</PmtInfId>
      <PmtMtd>TRF</PmtMtd>
      <BtchBookg>false</BtchBookg>
      <NbOfTxs>1</NbOfTxs>
      <CtrlSum>1250.00</CtrlSum>
      <PmtTpInf>
        <SvcLvl>
          <Cd>SEPA</Cd>
        </SvcLvl>
      </PmtTpInf>
      <ReqdExctnDt>2026-10-30</ReqdExctnDt>
      <Dbtr>
        <Nm>Nakaya SAS</Nm>
      </Dbtr>
      <DbtrAcct>
        <Id>
          <IBAN>FR7630006000011234567890189</IBAN>
        </Id>
      </DbtrAcct>
      <DbtrAgt>
        <FinInstnId>
          <BIC>AGRIFRPP882</BIC>
        </FinInstnId>
      </DbtrAgt>
      <ChrgBr>SLEV</ChrgBr>
      <CdtTrfTxInf>
        <PmtId>
          <EndToEndId>OMNIBANK-…</EndToEndId>
        </PmtId>
        <Amt>
          <InstdAmt Ccy="EUR">1250.00</InstdAmt>
        </Amt>
        <Cdtr>
          <Nm>Societe Generale d'Electricite</Nm>
        </Cdtr>
        <CdtrAcct>
          <Id>
            <IBAN>FR1420041010050500013M02606</IBAN>
          </Id>
        </CdtrAcct>
        <RmtInf>
          <Ustrd>Facture F-2026-042 - septembre</Ustrd>
        </RmtInf>
      </CdtTrfTxInf>
    </PmtInf>
  </CstmrCdtTrfInitn>
</Document>
Omnibank\Exception\ProviderException: [files] The creditor's IBAN "FR0000000000000000000000000" does not hold.

(run on 2026-10-26, PHP 8.4.27 with intl; the script masks the message id, which changes on each run; the IBANs are examples that pass the check)

What the file holds

  • One GrpHdr: the message id (OMNIBANK-<YmdHis>-<6 hex>), the creation time, the number of transactions and their sum, the initiating party (initiating_party, else the debtor's name).
  • One PmtInf per debtor account and execution date: TRF, BtchBookg (batch_booking), service level SEPA, the requested execution date (executionDate, today when null), the debtor's name and IBAN, the debtor's bank (debtor_bic, else Othr/Id NOTPROVIDED), charges SLEV.
  • One CdtTrfTxInf per transfer: the end-to-end id (endToEndId, else the message id), the amount in euros, the creditor's bank when creditorBic is given, the creditor's name and IBAN, the remittance information (reference).
  • Names (70 characters) and the reference (140) are put in the SEPA character set (a-z A-Z 0-9 / - ? : ( ) . , ' + and space): accents transliterated, dashes and quotes made plain, anything else a space.

TransferResult: status generated, file (the XML), fileName (<message id>.xml), id the message id. With no endToEndId given, the message id is the end-to-end id too, so the bank's statement brings it back: a CAMT.053 entry's reference (or raw['endToEndId']) matches it.

What is refused before any file

Case Exception
no debtor_name option InvalidConfigException (The "files" gateway writes transfers with the debtor's name: set its "debtor_name" option.)
a currency other than EUR ProviderException (A SEPA credit transfer is in euros, not USD.)
an amount of 0 or less ProviderException
a debtor or creditor IBAN whose check digits fail ProviderException (The creditor's IBAN "..." does not hold.)
a malformed BIC (the option's or the transfer's) ProviderException
a creditor name empty once in the SEPA character set ProviderException

Several transfers in one file

transfer() writes one file per call. Omnibank\Files\Pain001Writer writes several transfers in one file, one payment block per debtor account and date:

use Omnibank\Files\Pain001Writer;

$xml = (new Pain001Writer('Nakaya SAS', 'AGRIFRPP882'))->write([$rent, $electricity, $salary], 'PAYROLL-2026-10');

Each transfer then gets the end-to-end id <message id, 29 characters>-<block>-<rank> unless it gives its own. The package's tests validate such files against the ISO 20022 schema (Tests/Fixtures/pain.001.001.03.xsd).

Proposer une modification docs/usage-transfers.md

Webhooks

An aggregator calls the application back when it has synchronised a bank connection, or when the user must act. notify($body, $headers) checks the call's signature and reads it as one Notification: the provider's event type, the connectionId it is about, and the Consent it implies. Powens and Bridge support it; files and Qonto have no webhooks (RequestNotSupportedException).

#[Route('/bank/webhook', methods: ['POST'])]
public function __invoke(Request $request, GatewayInterface $banks): Response
{
    try {
        $notification = $banks->notify($request->getContent(), $request->headers->all());
    } catch (InvalidNotificationException) {
        return new Response(status: 400);
    }
    $connection = $this->connections->findByProviderId($notification->connectionId);  // the application's
    $this->connections->save($connection->withConsent($notification->consent));
    // NEEDS_RENEWAL: ask the user to go through connect() again; ACTIVE: read the fresh data

    return new Response(status: 204);
}

Give the raw body (the signature is over its bytes, before any JSON decoding) and the request's headers (any case; a list of values is read by its first).

Both providers, end to end

<?php // webhooks.php

require __DIR__.'/vendor/autoload.php';

use Omnibank\Bridge\Api as BridgeApi;
use Omnibank\Bridge\BridgeGatewayFactory;
use Omnibank\Exception\InvalidNotificationException;
use Omnibank\Powens\Api as PowensApi;
use Omnibank\Powens\PowensGatewayFactory;
use Symfony\Component\HttpClient\MockHttpClient;

$http = new MockHttpClient([]);   // notify() calls nothing

// Bridge: BridgeApi-Signature "v1=<HMAC-SHA256 of the body, hex>"
$bridge = (new BridgeGatewayFactory($http))->create(['client_id' => 'id', 'client_secret' => 'secret', 'webhook_secret' => 'whsec_bridge']);
$body = json_encode(['content' => ['item_id' => 8675309, 'status' => 1010, 'status_code_info' => 'SCA_REQUIRED'], 'timestamp' => time(), 'type' => 'item.refreshed']);
$notification = $bridge->notify($body, ['BridgeApi-Signature' => 'v1='.BridgeApi::sign($body, 'whsec_bridge')]);
printf("bridge: %s, item %s, consent %s\n", $notification->type, $notification->connectionId, $notification->consent->status->value);

// Powens: BI-Signature, base64 of the HMAC-SHA256 of "POST.<path>.<BI-Signature-Date>.<body>"
$powens = (new PowensGatewayFactory($http))->create(['domain' => 'example-sandbox.biapi.pro', 'client_id' => 'id', 'client_secret' => 'secret', 'webhook_secret' => 'whsec_powens', 'webhook_url' => 'https://app.example/bank/webhook']);
$body = '{"connection":{"id":77,"state":"SCARequired","expire":"2026-12-01 00:00:00"}}';
$date = gmdate(DATE_RFC7231);   // the time Powens signed it: now
$signature = PowensApi::signatures($body, 'whsec_powens', $date, 'https://app.example/bank/webhook')[0];
$notification = $powens->notify($body, ['BI-Signature' => $signature, 'BI-Signature-Date' => $date]);
printf("powens: %s, connection %s, consent %s until %s\n", $notification->type, $notification->connectionId, $notification->consent->status->value, $notification->consent->expiresAt->format('Y-m-d'));

try {
    $bridge->notify($body, ['BridgeApi-Signature' => 'v1=00ff']);
} catch (InvalidNotificationException $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}
$ php webhooks.php
bridge: item.refreshed, item 8675309, consent needs_renewal
powens: CONNECTION_SYNCED, connection 77, consent needs_renewal until 2026-12-01
Omnibank\Exception\InvalidNotificationException: [bridge] The notification is unsigned, or its signature does not match.

(run on 2026-10-26, PHP 8.4.27, no network; the bodies follow each provider's documentation, the secrets are made up)

What each provider reads

Powens Bridge
Signature header BI-Signature, with BI-Signature-Date BridgeApi-Signature
Accepted base64(HMAC-SHA256(secret, POST.<webhook_url's path>.<date>.<body>)), compared exactly; webhook_url and the date required v1=<hex HMAC-SHA256 of the body>, several comma-separated (a secret being rotated), the hex in either case
Age BI-Signature-Date no further from now than webhook_tolerance the body's timestamp (Unix seconds) no further from now than webhook_tolerance; none: refused
Options webhook_secret, webhook_url, webhook_tolerance (300 s) webhook_secret, webhook_tolerance (300 s)
type the body's type or event, else CONNECTION_SYNCED when the body has a connection, else UNKNOWN the event's type (item.created, item.refreshed...)
connectionId connection.id content.item_id
consent from connection.state (or error): SCARequired, webauthRequired, decoupled, additionalInformationNeeded, actionNeeded, wrongpass, passwordExpired are NEEDS_RENEWAL, any other ACTIVE; connection.expire is expiresAt. No connection: NONE item.* events only: a status 402, 429, 430, 1010 or 1100 is NEEDS_RENEWAL (without a numeric status, words of status_code_info such as SCA, credential, password); an item.*.deleted (not an account's) REVOKED; any other ACTIVE. Other events: NONE

Both signature readings are written from the providers' documentation and have not been checked against a real webhook (Verified, and not).

A webhook signed more than webhook_tolerance seconds away from now (300 by default) is refused with an InvalidNotificationException ("too old"): one captured and sent again later does not count twice. Keep the servers' clocks set (NTP); raise the tolerance where the provider retries a webhook it could not deliver and the application wants the retries.

Errors

Case Exception
no webhook_secret option InvalidNotificationException (No webhook secret: the notification cannot be checked.)
no signature header, or a signature that does not match InvalidNotificationException
a signed body that is not JSON (Powens), or not a Bridge event (no type) ProviderException

InvalidNotificationException extends ProviderException: catch it first, and answer such a call with a 4xx.

Proposer une modification docs/usage-webhooks.md

Configuration reference

The registry

new Omnibank\Registry(iterable $factories, array $config);
Argument Type
$factories iterable of GatewayFactoryInterface keyed by their getName(); a later factory of the same name replaces an earlier one
$config array<string, array{factory: string, options?: array}> the gateways by name

Each gateway:

Key Type Default
factory string required a factory's getName(); unknown: InvalidConfigException on first use
options array [] handed to the factory's create() on first use; the gateway is then kept

Nothing is checked when the registry is built; Registry::create($name, $overrides) builds a gateway with other options over the configured ones, without keeping it. The Symfony bundle's tree (omnibank.gateways.<name>.factory, .options) is the same (Symfony).

Provider options

Read from each factory's populateConfig() defaults and what its API and actions read. "Required" options are checked by create(): missing or empty, an InvalidConfigException names them.

files

Option Type Default Required
currency string EUR no a CSV's currency when neither the file's account nor a currency column says; also the fallback of CAMT and OFX files that give none
csv array [] no the CSV options for every CSV (a statement's own csv over them): delimiter, enclosure, encoding, decimal, date_format, header, columns (omnibank/files)
debtor_name ?string null for transfer() the account holder, as the bank knows it
debtor_bic ?string null no the debtor's bank's BIC; NOTPROVIDED goes without
initiating_party ?string null no who sends the file, when not the debtor
batch_booking bool false no the file's BtchBookg

The files themselves travel in the connection: state.statements, a list of ['name' => ..., 'content' => ..., 'format' => 'camt053'|'ofx'|'csv' (optional), 'account' => [...] (optional), 'csv' => [...] (optional)].

qonto

Option Type Default Required
login string - yes the organization's login (Settings, Integrations, API key)
secret_key string - yes
sandbox bool false no true: https://thirdparty-sandbox.staging.qonto.co
staging_token ?string null no the sandbox's X-Qonto-Staging-Token
host ?string null no another host, whatever sandbox says (default https://thirdparty.qonto.com)

powens

Option Type Default Required
domain string - yes the client's domain (<x>-sandbox.biapi.pro); a scheme or a path given with it is cut off
client_id string - yes
client_secret string - yes
redirect_uri ?string null no the return URL when connect() is given an empty one
webhook_secret ?string null for notify()
webhook_url ?string null no the webhook's URL as registered: its path is part of the signed string
webview string https://webview.powens.com no
language ?string null no the Webview's language, put in its path (/fr/connect)

bridge

Option Type Default Required
client_id string - yes
client_secret string - yes
version string 2025-01-15 no the Bridge-Version header
webhook_secret ?string null for notify()
callback_url ?string null no the return URL when connect() is given an empty one
country_code ?string FR no the banks a new Connect session lists (not sent for a renewal)
host string https://api.bridgeapi.io no

Timeouts

The API clients set their own timeouts per request: Qonto 20 seconds, Powens and Bridge 30. They are constructor arguments of each Api class, not options of the factories. The HTTP client given to the factory brings everything else (proxy, retries, a MockHttpClient in tests).

Proposer une modification docs/configuration.md

Errors

Omnibank\Exception\OmnibankException (an interface, \Throwable)
├── InvalidConfigException          extends \LogicException      a gateway misconfigured
├── RequestNotSupportedException    extends \LogicException      the provider does not do that
└── ProviderException               extends \RuntimeException    the provider refused
    ├── InvalidNotificationException                             a webhook that is not the provider's
    └── UnavailableException                                     the provider could not be reached or failed

catch (OmnibankException $e) catches all of them. ProviderException has two public readonly properties: provider (the factory's name) and providerCode (the provider's own error code, or the HTTP status, when there was one); its message starts with [<provider>] .

InvalidConfigException

Message Thrown by
The "<factory>" gateway needs: <options>. GatewayFactory::create(), a required option missing or empty
No "x" gateway; configured: a, b. / No "y" factory for the "x" gateway; installed: ... Registry::get(), Registry::create()
<Action> needs a <Api>, got <class>. ApiAwareTrait::setApi(), an action given another provider's API
The "files" gateway writes transfers with the debtor's name: set its "debtor_name" option. files, transfer()
A CSV without a header needs its columns as indexes: ... files, a CSV read with header: false
The "powens" gateway needs a return URL: ..., The "bridge" gateway needs a return URL: ... connect() with an empty return URL and no redirect_uri / callback_url

RequestNotSupportedException

The "qonto" gateway does not support Transfer. - thrown by Gateway::execute() for a request no action supports. supports() says it beforehand:

Transfer Notify
files yes not supported
qonto not supported not supported
powens not supported yes
bridge not supported yes

ProviderException

The provider refused, or a file cannot be read:

  • an API's 4xx: Qonto's errors[0].detail (its code as providerCode), Powens' description, Bridge's errors[0].message;
  • the connection lacks what the provider needs: The connection has no Powens user yet: connect() first. (and Bridge's), Qonto lists transactions by IBAN: the account has none.;
  • an account the provider does not know (No statement for the account "x"., The organization has no account "x".);
  • a link out of the provider's host given in a pagination (Powens, Bridge): it is not followed, so the user's token does not leave;
  • a file that is not what it says (not XML, no statement in it, a CSV line with an amount and a date in no format, no header line found), a transfer the files gateway refuses (not euros, not positive, an IBAN or BIC that does not hold);
  • an action that left its request unanswered (Gateway::execute()).

UnavailableException

The provider could not be reached or failed on its side: a transport error of the HTTP client (network, DNS, timeout), an HTTP 5xx, a 429, or a body that is not JSON. Nothing is known: it is never "no accounts" nor "no transactions". Keep what was read before and try again later.

No secret in a failure

What a provider throws keeps the request's secrets out, for a log that writes the whole chain, arguments included (zend.exception_ignore_args off). The HTTP client's message quotes the URL called: in the UnavailableException's message its key parameters, a password in its user part and every secret of the request (a key, a password, a bearer or basic credential) are masked as *** (Omnibank\Http\Redaction::text()). The previous exception is a copy of the client's (Redaction::copy(): the same class and code, that message, its own previous copied the same way, without the response or request a client's exception holds), and no trace of the chain keeps its arguments, where the options, headers and body stood (Redaction::withoutArguments()). A GatewayFactory::create() that fails (a missing option) throws without arguments in its trace either: they held the options, credentials included. Nor does any failure of an action leaving Gateway::execute(): the request held the connection, its tokens with it.

InvalidNotificationException

A webhook whose signature does not hold, that carries none, or that cannot be checked (no webhook_secret) - Webhooks.

Not an OmnibankException

Money::fromDecimal() throws a plain \InvalidArgumentException for a string that is not a decimal ("12,50", "abc"); the readers turn a bank's amounts into plain decimals before calling it.

What to catch

use Omnibank\Exception\{InvalidNotificationException, ProviderException, RequestNotSupportedException, UnavailableException};

try {
    $transactions = $gateway->transactions($connection, $account, $since);
} catch (UnavailableException) {
    // nothing known: keep the previous data, retry later
} catch (ProviderException $e) {
    // refused: $e->provider, $e->providerCode; a consent to renew, credentials to fix
}

InvalidConfigException and RequestNotSupportedException are errors of the application's code or configuration: test supports() before offering a feature, and let a missing option fail loudly.

Proposer une modification docs/errors.md

Testing

Omnibank has no fake gateway and no conformance suite: an application tests its code against the real gateways, on recorded answers.

The files gateway as a test double

omnibank/files calls nothing: a test hands it a statement (CAMT.053, OFX or CSV) in the connection and gets real Account, Balance and BankTransaction objects back. For code that reconciles payments, imports transactions or shows balances, it is the simplest double:

use Omnibank\Files\FilesGatewayFactory;
use Omnibank\Model\Connection;

$gateway = (new FilesGatewayFactory())->create();
$connection = new Connection(['statements' => [
    ['name' => 'export.csv', 'content' => "Date;Libellé;Montant\n02/09/2026;VIR SEPA RECU /DE CAMILLE DURAND /MOTIF F-2026-042;1 250,00\n", 'account' => ['iban' => 'FR7630006000011234567890189']],
]]);

$reconciler->reconcile($gateway, $connection);    // the application's code under test

In a Symfony test environment, a gateway can be pointed at the files factory under the same name:

when@test:
    omnibank:
        gateways:
            treasury: { factory: files }

An API's answers: MockHttpClient

Every factory takes the HTTP client to call with. A Symfony\Component\HttpClient\MockHttpClient answering with the provider's JSON tests the real gateway without the network:

use Omnibank\Model\Connection;
use Omnibank\Qonto\QontoGatewayFactory;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$http = new MockHttpClient([
    new MockResponse(file_get_contents(__DIR__.'/Fixtures/qonto-organization.json')),
]);
$qonto = (new QontoGatewayFactory($http))->create(['login' => 'example-organization', 'secret_key' => 'qonto_test_not_a_real_key']);

$accounts = $qonto->accounts(new Connection());

The providers' own Tests/Fixtures/ hold answers written from each service's documentation (organization.json and transactions-page-*.json for Qonto, auth-init.json, accounts.json, webhook-connection-synced.json... for Powens, user.json, connect-session.json, webhook-item-refreshed.json... for Bridge); the harness keeps Qonto's and a CAMT.053 statement in docker/harness/recorded/. Copy them as a starting point.

Failures are tested the same way: a MockResponse with ['http_code' => 503] is an UnavailableException, ['http_code' => 401] and a JSON error a ProviderException, ['error' => '...'] (a transport failure) an UnavailableException:

<?php // errors.php

require __DIR__.'/vendor/autoload.php';

use Omnibank\Exception\OmnibankException;
use Omnibank\Model\Connection;
use Omnibank\Qonto\QontoGatewayFactory;
use Omnibank\Request\Transfer;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

$answers = [
    'a 503' => new MockResponse('{"message":"Service Unavailable"}', ['http_code' => 503]),
    'a 401' => new MockResponse('{"errors":[{"code":"unauthorized","detail":"Invalid credentials"}]}', ['http_code' => 401]),
    'no network' => new MockResponse('', ['error' => 'Could not resolve host: thirdparty.qonto.com']),
];
foreach ($answers as $case => $answer) {
    $qonto = (new QontoGatewayFactory(new MockHttpClient($answer)))->create(['login' => 'example-organization', 'secret_key' => 'not-a-real-key']);
    try {
        $qonto->accounts(new Connection());
    } catch (OmnibankException $e) {
        printf("%-10s %s (%s)\n", $case, $e::class, $e->getMessage());
    }
}

$qonto = (new QontoGatewayFactory(new MockHttpClient([])))->create(['login' => 'x', 'secret_key' => 'y']);
var_dump($qonto->supports(Transfer::class));
try {
    (new QontoGatewayFactory())->create(['login' => 'x']);
} catch (OmnibankException $e) {
    echo $e::class, ': ', $e->getMessage(), "\n";
}
$ php errors.php
a 503      Omnibank\Exception\UnavailableException ([qonto] Qonto answered HTTP 503: try again later.)
a 401      Omnibank\Exception\ProviderException ([qonto] Invalid credentials)
no network Omnibank\Exception\UnavailableException ([qonto] Qonto cannot be reached: Could not resolve host: thirdparty.qonto.com)
bool(false)
Omnibank\Exception\InvalidConfigException: The "qonto" gateway needs: secret_key.

(run on 2026-10-26, PHP 8.4.27, no network)

Webhooks in a test

Omnibank\Bridge\Api::sign($body, $secret) and Omnibank\Powens\Api::signatures($body, $secret, $date, $webhookUrl) compute the signature each gateway accepts: a test signs a body with the secret of its configuration and posts it to the application's webhook route (Webhooks).

A gateway of the test's own

A class implementing GatewayInterface (or a GatewayFactory whose actions answer from arrays) can stand in for any provider; the core's own tests do it with Tests/StubFactory.php. Registered in a Registry under the name the application uses, it replaces the real one.

Proposer une modification docs/testing.md

The Docker harness

docker/ runs this package with every omnibank/* provider installed - from GitHub (branch 1.x), or from the checkouts beside this one when OMNIBANK_PLUGINS=../.. is set in docker/.env - and a console that exercises them with the keys in docker/.env. Connections (the provider's user, its tokens) are kept between runs in the harness volume.

cd docker && cp .env.dist .env       # your sandbox keys, when you have them
docker compose run --rm omnibank gateways
Command
gateways the providers installed and configured, what each does, whether it is connected
connect <gateway> the page to send the user to (--return-url, --set key=value, --renew, --forget); the connection kept
accounts <gateway> the accounts the connection reaches (JSON)
balances <gateway> an account's balances (--account)
transactions <gateway> an account's booked transactions, oldest first (--account, --since, --until)
bare plain PHP: the registry built by hand, a statement read, Qonto asked, what PHP loaded
test every package's tests

accounts, balances and transactions take --path for the files gateway: a statement file (CAMT.053, OFX, CSV) mounted in the container.

docker compose run --rm omnibank connect powens                       # prints the Webview to open
docker compose run --rm omnibank accounts powens
docker compose run --rm omnibank transactions qonto --since 2026-09-01
docker compose run --rm -v ~/Downloads:/statements omnibank transactions files --path /statements/releve.xml

Bare: no bundle, no container

The console above is a symfony/console application over a registry built by hand; bare is less still - one PHP script, docker/harness/bin/bare, that requires the autoloader and nothing else. It builds the Registry from the provider packages installed, asks each gateway that can be built what it does, reads the CAMT.053 statement kept in docker/harness/recorded/ through omnibank/files, asks Qonto for the organization's accounts, the first one's balances and its transactions of September 2026 - the real API when QONTO_LOGIN and QONTO_SECRET_KEY are set, or with --recorded the answers kept in docker/harness/recorded/ - then lists what PHP loaded and exits 1 if a class of a framework is among it (Symfony\Component\DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle, Doctrine, Twig):

$ docker compose run --rm omnibank bare --recorded
Omnibank in bare PHP: the registry built by hand, no bundle, no container.

  files      connect accounts balances transactions transfer
  qonto      connect accounts balances transactions

omnibank/files, the CAMT.053 statement kept in recorded/ (no network):
  Compte courant, FR7630006000011234567890189 (AGRIFRPP882), EUR - 1 account
  balances: opening 10000.00, booked 5926.60, available 5926.60
  6 transactions, the first on 2026-09-02: 1250.00 EUR, Camille Durand, Facture F-2026-042

omnibank/qonto, from the answers kept in recorded/, September 2026:
  Compte principal, FR5116958000011234567890142 (QNTOFRP1XXX), EUR - 2 accounts
  balances: booked 18432.17, available 18102.17
  3 transactions, the first on 2026-09-02: 1250.00 EUR, Camille Durand, Facture F-2026-042

Loaded from Symfony: Symfony\Component\HttpClient, Symfony\Contracts\HttpClient, Symfony\Contracts\Service
Classes of a framework (DependencyInjection, Config, HttpKernel, HttpFoundation, a bundle, Doctrine, Twig): none

The answers in recorded/ were not taken from a bank: they are omnibank/qonto's fixtures, written from Qonto's published documentation, and omnibank/files' sample statement - the organization, the IBANs, the names and the amounts are made up. bare --recorded --json prints the same whole, every class and file loaded, without a call: Tests/BareTest.php runs it in a process of its own and checks the list.

(the same output on 2026-10-26, run with PHP 8.4.27 on the workspace's checkouts and no network)

Environment

docker/.env (a copy of .env.dist, git-ignored) holds the keys; a gateway is configured once every key of its needs (in docker/harness/config/gateways.php) is set, and gateways lists the others as "needs ...".

Variable Gateway
OMNIBANK_PLUGINS - the providers' checkouts (../.. in the workspace); unset, they are cloned from GitHub, branch 1.x
HARNESS_RETURN_URL powens, bridge the return URL (redirect_uri, callback_url); https://example.org/bank/back by default
FILES_DEBTOR_NAME, FILES_DEBTOR_BIC files debtor_name, debtor_bic
QONTO_LOGIN, QONTO_SECRET_KEY (needed), QONTO_SANDBOX (1 for the sandbox), QONTO_STAGING_TOKEN qonto
POWENS_DOMAIN, POWENS_CLIENT_ID, POWENS_CLIENT_SECRET (needed), POWENS_WEBHOOK_SECRET, POWENS_WEBHOOK_URL, POWENS_LANGUAGE powens
BRIDGE_CLIENT_ID, BRIDGE_CLIENT_SECRET (needed), BRIDGE_VERSION, BRIDGE_WEBHOOK_SECRET, BRIDGE_USER_EMAIL bridge

The console's errors: an InvalidConfigException exits 2 (INVALID), another OmnibankException 1, each with its message.

What the entrypoint does

docker/entrypoint.sh runs docker/harness/setup.php, which writes /harness/composer.json and /harness/phpunit.xml: this core from its checkout (a path repository), each provider of docker/harness/plugins.php from its checkout under /omnibank/plugins/<slug> when its composer.json names omnibank/<slug>, from https://github.com/glitchr-omnisense/omnibank-<slug>.git otherwise, with symfony/console, symfony/http-client, symfony/var-dumper, and PHPUnit 11 and Symfony's config, dependency-injection and http-kernel for the tests; one PHPUnit suite per package. Composer installs when there is no vendor/ and updates when composer.json is newer than its lock. Then: test [arguments] (PHPUnit), bare [...], composer ..., sh, php ... as they are, anything else the console (docker/harness/bin/omnibank).

The image (omnisense/omnibank-harness) is php:8.4-cli-alpine with the bcmath, intl and zip extensions and Composer; its default command is gateways; the harness's packages, Composer's cache and the kept connections live in the harness volume of the omnibank project (docker compose down -v removes it).

Proposer une modification docs/harness.md

Writing a provider

A provider is a package omnibank/<name>, namespace Omnibank\<Name>\, that requires omnisense/omnibank (and symfony/http-client when it calls an API). It holds:

  • a factory, extending Omnibank\GatewayFactory, that fills the Config;
  • an API client (Api), the one class that talks to the service: its host, its authentication, its errors turned into the family's exceptions;
  • one action per request it answers, Action\<Request>Action, implementing ActionInterface (and ApiAwareInterface with ApiAwareTrait when it needs the API);
  • a mapper from the service's answers to the models, and tests on recorded answers.

The factory

namespace Omnibank\Acme;

use Omnibank\Acme\Action\AccountsAction;
use Omnibank\Acme\Action\ConnectAction;
use Omnibank\Config;
use Omnibank\GatewayFactory;
use Symfony\Component\HttpClient\HttpClient;

final class AcmeGatewayFactory extends GatewayFactory
{
    protected function populateConfig(Config $config): void
    {
        $config->defaults([
            'omnibank.factory_name' => 'acme',
            'omnibank.factory_title' => 'Acme Bank',
            'omnibank.required_options' => ['api_key'],
            'sandbox' => false,
            'omnibank.api' => fn (Config $c) => new Api($this->http ?? HttpClient::create(), (string) $c['api_key'], $c['sandbox'] ? Api::SANDBOX : Api::HOST),
            'omnibank.action.connect' => new ConnectAction(),
            'omnibank.action.accounts' => new AccountsAction(),
        ]);
    }
}

defaults() sets only the keys the application did not give: an option the application sets wins, omnibank.* keys included. An action that needs an option is a closure of the Config (static fn (Config $c) => new ConnectAction($c['redirect_uri'] ?: null)).

An action

namespace Omnibank\Acme\Action;

use Omnibank\Acme\Api;
use Omnibank\Action\{ActionInterface, ApiAwareInterface, ApiAwareTrait};
use Omnibank\Model\Account;
use Omnibank\Request\{FetchAccounts, Request};

final class AccountsAction implements ActionInterface, ApiAwareInterface
{
    /** @use ApiAwareTrait<Api> */
    use ApiAwareTrait;

    public function __construct()
    {
        $this->apiClass = Api::class;
    }

    public function supports(Request $request): bool
    {
        return $request instanceof FetchAccounts;
    }

    public function execute(Request $request): void
    {
        \assert($request instanceof FetchAccounts);
        $request->setResult(array_map(
            static fn (array $a) => new Account((string) $a['id'], $a['iban'] ?? null, $a['bic'] ?? null, (string) $a['name'], strtoupper((string) $a['currency']), $a['type'] ?? null, $a),
            $this->api->get('/accounts')['accounts'] ?? [],
        ));
    }
}

supports() must answer from the request's class alone: Gateway::supports() asks it with a probe made without the constructor, whose properties are not set.

The rules every provider follows

  • Signed amounts. BankTransaction::$amount is negative when money leaves the account, in minor units (Money::fromDecimal() from a decimal string, never through a float when the service gives a string).
  • Booked lines only, oldest first, filtered by FetchTransactions::covers(), every page read.
  • Stable ids. The service's id, or a fingerprint of the line (Omnibank\Files\Fingerprints is one way).
  • raw keeps the service's whole answer for the account and each line.
  • Errors. A 4xx is a ProviderException (the service's message, its code as providerCode); a transport error, a 5xx, a 429 or a body that is not JSON is an UnavailableException, never an empty list; a missing option an InvalidConfigException (omnibank.required_options).
  • Webhooks. notify() checks the signature with hash_equals() before reading anything, and throws InvalidNotificationException without a secret or a signature.
  • Tokens stay home. A pagination link to another host is not followed (Powens' and Bridge's Api::call() refuse it); what the application must keep goes in the returned Connection's state, nowhere else.

Registering it

  • In the harness: a line in docker/harness/plugins.php (slug, tests' namespace, factory class) and its options in docker/harness/config/gateways.php.
  • In Symfony: OmnibankBundle::FACTORIES lists the four known factories; another package's (or the application's) factory is registered when it is a service, autoconfigured with the tag omnibank.gateway_factory.
  • Its README: what the service is, its options, its credentials, what is verified and not, and a Responsibility section naming the service's terms with a link.

There is no conformance suite in the family yet: a provider's tests check each request on recorded answers, an outage as UnavailableException, and webhooks signed and forged (the four providers' Tests/*GatewayTest.php are the models).

Proposer une modification docs/providers.md

Verified, and not

What the tests prove

Run on 2026-10-26 on the workspace's checkouts (the core and its four providers), PHP 8.4.27, PHPUnit 11.5.57, no network: 63 tests, 410 assertions, green.

  • The core: a gateway gives the page and the connection to keep, transactions kept between the dates asked, an unsupported request named with its gateway, required options checked, the registry building each gateway once by name and naming an unknown factory (GatewayTest); money as signed minor units read from decimals without floats, a connection as an array or serialized, a consent active until it expires (ModelTest); the bundle building the configured gateways, injectable by name, registering every installed factory (Bridge/OmnibankBundleTest).
  • In plain PHP (BareTest, bin/bare --recorded --json): the registry built by hand, a statement read and a provider answering from recorded answers with no class of a framework loaded; the bundle does load one.
  • omnibank/files: CAMT.053 entry by entry, statements that overlap giving each line once, OFX 1 (SGML, Windows-1252) and 2 (XML), French CSV exports, a preamble and thousands separators, a column map, a date in no format an error; pain.001 files valid against the ISO 20022 schema, with and without BICs, several transfers in blocks, refusals before the file.
  • omnibank/qonto, omnibank/powens, omnibank/bridge, on answers written from each service's documentation (Tests/Fixtures): connect (and renewal for the aggregators), accounts and balances, transactions every page, booked only, signed; an expired Bridge token renewed; a webhook checked then read, a forged one refused; an outage never "no data"; no transfers.

Run for this manual

On 2026-10-26, PHP 8.4.27, no network, the workspace's checkouts: the plain-PHP script of Installation, bin/bare --recorded (harness), the bundle's configuration tree and services (Symfony), the examples of connecting, reading, transfers, webhooks and testing, and the supports() probe of the index. Their output is pasted as it came.

What is not verified

  • Qonto, Powens and Bridge against the real services. Each is written from the service's published documentation and recorded answers; none has been run against a real organization, a sandbox or a real webhook (each provider's README says so). The webhook signatures of Powens and Bridge above all are readings of their documentation, to check against a real call.
  • Real bank files beyond the fixtures: the CSV defaults are written for the usual French exports (Crédit Mutuel, CIC, BNP, Société Générale, LCL, La Banque Postale, Boursorama) and tested on samples; another bank's export may need the csv options.
  • A pain.001 file uploaded to a bank: the files validate against the schema; no bank has taken one in a test.
  • Laravel: no bridge; the service provider of Laravel is a sketch, not a tested package.
  • No conformance suite exists for the family yet.

Proposer une modification docs/verified.md