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
- Installation: requirements, a whole script in plain PHP, a bank's API, an aggregator.
- Symfony and Laravel: wiring the registry in a framework.
- Concepts: gateways, factories, the registry, requests and actions, every model.
- Usage: connecting and consent, accounts, balances and transactions, transfers, webhooks.
- Configuration reference: the registry, the bundle, every provider's options.
- Errors: the exceptions, when each is thrown, what to catch.
- Testing: testing an application that reads its banks with Omnibank.
- The Docker harness: every provider installed, a console to call them.
- Writing a provider.
- Verified, and not.
Propose a change 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/omnibankrequiressymfony/http-client-contracts(^3.0) and nothing else.omnibank/qonto,omnibank/powensandomnibank/bridgerequiresymfony/http-client(^6.4, ^7.0 or ^8.0).omnibank/filesrequires thedomandlibxmlextensions (in most PHP builds);intlis suggested (names put in the SEPA character set;iconvotherwise) andiconv(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, aMockHttpClientin 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']);
An aggregator: a consent to give, a connection to keep
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\OmnibankBundleregisters the factories on the application'shttp_client, builds the registry fromconfig/packages/omnibank.yamland 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
Registryonce, 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.
Propose a change 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);
}
}
Propose a change 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.
Propose a change 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():
- fills a
Configwith the options given, then the factory's defaults (populateConfig()) under them; - checks the keys of
omnibank.required_options: one missing or empty (null,'',[]) is anInvalidConfigExceptionnaming them all (The "powens" gateway needs: domain, client_id.); - builds the API client from
omnibank.api(a closure of theConfigis called); - collects every
omnibank.action.<name>(a closure of theConfigis called), hands the API to the actions that implementApiAwareInterface, and returns aGateway.
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, Consent, ConsentStatus
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).
Propose a change 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:
- Keep the connection
connect()returns, before redirecting: it holds the user's token. - On the return URL, Powens' Webview adds the new bank connection's
connection_idto the query; Bridge's item id comes with itsitem.*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'sconnect()sets the consent of an aggregator: it stays as it was until the application or a webhook says. - When the consent needs renewal (a webhook said so, or a call was refused), set
Consent::needsRenewal()and callconnect()again: withconnection_id(Powens) oritem_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.
Consent
Connection::$consent is the application's record of where the user's consent stands:
ConsentStatus::ACTIVE, with anexpiresAtwhen 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.
Propose a change 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:
$sinceand$untilare days, both included (FetchTransactions::covers()comparesY-m-d); either can be null. Providers with an API pass them on (Qonto'ssettled_at_from/settled_at_to, Powens'min_date/max_date, Bridge'ssince/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 bypagination.next_uri, up to 1000 pages. - Pending lines are left out: CAMT entries not
BOOK, Qonto transactions notcompleted, Powens'cominganddeleted, Bridge'sfutureanddeleted. amountis signed: negative when money left the account (CAMTDBIT, Qontoside: debit, the sign of OFX'sTRNAMT, Powens'valueand Bridge'samount; a CSV debit column made negative).idis 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).
Propose a change 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
PmtInfper debtor account and execution date:TRF,BtchBookg(batch_booking), service levelSEPA, the requested execution date (executionDate, today when null), the debtor's name and IBAN, the debtor's bank (debtor_bic, elseOthr/Id NOTPROVIDED), chargesSLEV. - One
CdtTrfTxInfper transfer: the end-to-end id (endToEndId, else the message id), the amount in euros, the creditor's bank whencreditorBicis 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).
Propose a change 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.
Propose a change 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).
Propose a change 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(itscodeasproviderCode), Powens'description, Bridge'serrors[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.
Propose a change 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.
Propose a change 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).
Propose a change 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 theConfig; - 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, implementingActionInterface(andApiAwareInterfacewithApiAwareTraitwhen 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::$amountis 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\Fingerprintsis one way). rawkeeps the service's whole answer for the account and each line.- Errors. A 4xx is a
ProviderException(the service's message, its code asproviderCode); a transport error, a 5xx, a 429 or a body that is not JSON is anUnavailableException, never an empty list; a missing option anInvalidConfigException(omnibank.required_options). - Webhooks.
notify()checks the signature withhash_equals()before reading anything, and throwsInvalidNotificationExceptionwithout 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 returnedConnection's state, nowhere else.
Registering it
- In the harness: a line in
docker/harness/plugins.php(slug, tests' namespace, factory class) and its options indocker/harness/config/gateways.php. - In Symfony:
OmnibankBundle::FACTORIESlists the four known factories; another package's (or the application's) factory is registered when it is a service, autoconfigured with the tagomnibank.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).
Propose a change 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
csvoptions. - 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.