Goal#
This page is the contributor guide for adding a new connector to
formancehq/payments. If your
team uses a payment service provider Formance does not integrate with,
the project welcomes a pull request that wires it in. Merged connectors
ship in the Community edition binary.
The platform side stays generic: the framework schedules the connector's fetchers and routes initiation workflows. The contributor writes the provider-specific glue — auth, HTTP plumbing, format conversion, and metadata mapping.
Scope#
This guide covers CE PSP connectors — Community edition payment service providers that fetch accounts, balances, payments, and (where the upstream supports it) initiate transfers and payouts. Stripe, Bankingcircle, Mangopay, Wise are the kind of integrations to follow.
Out of scope here:
- Enterprise connectors. EE plugins live under
ee/plugins/and ship in a separate binary; the source isn't open and external contributions don't apply. - Open Banking aggregators. Plaid, Tink, and Powens implement an additional surface for end-user link sessions and consent renewals on top of the PSP plugin contract. Their pattern is involved enough to warrant a dedicated guide and is not covered here.
What you'll add to the repo#
A new folder at internal/connectors/plugins/public/<provider>/ with:
- A plugin entry point that registers the connector at process start.
- A
Configstruct describing the install-time fields readers fill in. - A workflow declaration listing the periodic fetch tasks.
- A
[]models.Capabilityslice declaring what the plugin supports. - One Go file per capability (
accounts.go,payments.go,webhooks.go, …) implementing thePSPPluginmethods you opted into. - Table-driven unit tests next to each file.
- A
MAPPING.mdcataloguing the upstream-to-Formance field mapping.
When the PR merges, the connector appears on the
Capabilities matrix on the next
docs build. Upstream regenerates docs/other/connector-capabilities.json
on every commit, and the docs site reads that file at build time.
Where the code lives#
formancehq/payments
└── internal/
├── connectors/plugins/
│ ├── public/ # CE connectors — your new folder goes here
│ │ ├── stripe/
│ │ ├── bankingcircle/
│ │ ├── mangopay/
│ │ └── …
│ ├── registry/ # Plugin registration
│ ├── sharedconfig/ # Re-used config primitives (PollingPeriod, …)
│ ├── base_plugin.go # Embedded zero-impl base
│ └── plugin.go # Plugin top-level type
└── models/
├── plugin.go # Plugin interface
├── plugin_psp.go # PSPPlugin sub-interface
└── capabilities.go # CAPABILITY_* constantsEvery connector folder is named after the lowercase provider id
(stripe, bankingcircle, mangopay).
The Plugin interface#
The methods you implement are on PSPPlugin plus a small set of
lifecycle hooks. The full surface is in
internal/models/plugin_psp.go:
type PSPPlugin interface {
FetchNextAccounts(ctx, FetchNextAccountsRequest) (FetchNextAccountsResponse, error)
FetchNextPayments(ctx, FetchNextPaymentsRequest) (FetchNextPaymentsResponse, error)
FetchNextBalances(ctx, FetchNextBalancesRequest) (FetchNextBalancesResponse, error)
FetchNextExternalAccounts(ctx, …) (…)
FetchNextOthers(ctx, …) (…)
FetchNextOrders(ctx, …) (…) // Payments 3.3.0+
FetchNextConversions(ctx, …) (…) // Payments 3.3.0+
CreateBankAccount(ctx, …) (…)
CreateTransfer(ctx, …) (…)
ReverseTransfer(ctx, …) (…)
PollTransferStatus(ctx, …) (…)
CreatePayout(ctx, …) (…)
ReversePayout(ctx, …) (…)
PollPayoutStatus(ctx, …) (…)
}Plus the lifecycle methods on the parent Plugin interface:
Name() string
Config() PluginInternalConfig
Install(context.Context, InstallRequest) (InstallResponse, error)
Uninstall(context.Context, UninstallRequest) (UninstallResponse, error)
CreateWebhooks(context.Context, CreateWebhooksRequest) (CreateWebhooksResponse, error)
TrimWebhook(context.Context, TrimWebhookRequest) (TrimWebhookResponse, error)
VerifyWebhook(context.Context, VerifyWebhookRequest) (VerifyWebhookResponse, error)
TranslateWebhook(context.Context, TranslateWebhookRequest) (TranslateWebhookResponse, error)You only implement what your provider supports. The
basePlugin
type returns ErrNotImplemented for every method by default — embed it
in your struct and override the methods that match the capabilities
you'll declare. The platform reads the []models.Capability slice you
register at startup and only routes calls to the methods you opted
into; methods you didn't override are never invoked.
Anatomy of a connector folder#
Stripe is a good reference for a fully-featured CE PSP — accounts, balances, external accounts, payments, transfer + payout initiation, and webhooks. Its folder:
internal/connectors/plugins/public/stripe/
├── plugin.go # struct + New + init() registration
├── config.go # Config struct + JSON unmarshal/validate
├── workflow.go # ConnectorTasksTree declaration
├── capabilities.go # []models.Capability slice
├── accounts.go # FetchNextAccounts implementation
├── balances.go
├── external_accounts.go
├── payments.go
├── webhooks.go # Create/Translate/Trim/Verify
├── client/ # HTTP wrapper around the upstream API
└── *_test.go # Per-file table-driven unit testsplugin.go — entry point and registration#
const ProviderName = "stripe"
func init() {
registry.RegisterPlugin(
ProviderName,
models.PluginTypePSP,
func(_ models.ConnectorID, name string, logger logging.Logger, rm json.RawMessage) (models.Plugin, error) {
return New(name, logger, rm, nil)
},
capabilities,
Config{},
PAGE_SIZE,
)
}
type Plugin struct {
models.Plugin // embed the base for the zero-impl methods
name string
logger logging.Logger
client client.Client
config Config
}
func New(name string, logger logging.Logger, rawConfig json.RawMessage, backend stripe.Backend) (*Plugin, error) {
config, err := unmarshalAndValidateConfig(rawConfig)
if err != nil { return nil, err }
c, err := client.New(ProviderName, logger, backend, config.APIKey)
if err != nil { return nil, err }
return &Plugin{
Plugin: plugins.NewBasePlugin(),
name: name, logger: logger, client: c, config: config,
}, nil
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Config() models.PluginInternalConfig { return p.config }
func (p *Plugin) Install(_ context.Context, _ models.InstallRequest) (models.InstallResponse, error) {
return models.InstallResponse{Workflow: workflow()}, nil
}init() runs at process start. The linker pulls every package under
public/ in via a blanket import in
registry/plugins.go,
so adding a new connector folder is enough — no central list to edit.
Install returns the connector's workflow tree — see below.
config.go — typed config and validation#
Define a Config struct with json tags and validate tags
(go-playground validator). The registry reads the tag set to extract
the field list for the OpenAPI spec, so a new field appears in the
public docs on the next regeneration without a docs-side edit.
type Config struct {
APIKey string `json:"apiKey" validate:"required"`
PollingPeriod sharedconfig.PollingPeriod `json:"pollingPeriod"`
}
const PAGE_SIZE = 100 // upstream cap
func unmarshalAndValidateConfig(payload json.RawMessage) (Config, error) {
// …
}Re-use sharedconfig.PollingPeriod
for the polling cadence so the floor (MinimumPollingPeriod) stays
uniform across connectors. Pick the same minimum every other connector
uses (20m) unless your provider rate-limits at a coarser cadence.
workflow.go — task tree#
The workflow declares which capabilities run periodically at install time and how they nest:
func workflow() models.ConnectorTasksTree {
return []models.ConnectorTaskTree{
{
TaskType: models.TASK_FETCH_ACCOUNTS,
Name: "fetch_accounts",
Periodically: true,
NextTasks: []models.ConnectorTaskTree{
{ TaskType: models.TASK_FETCH_BALANCES, Periodically: false },
{ TaskType: models.TASK_FETCH_PAYMENTS, Periodically: true },
{ TaskType: models.TASK_FETCH_EXTERNAL_ACCOUNTS, Periodically: true },
},
},
{ TaskType: models.TASK_CREATE_WEBHOOKS, Periodically: false },
}
}Nested non-periodic tasks (here fetch_balances) get triggered by
their parent at the same cadence — useful when the parent already
pulled the data the child needs.
capabilities.go — declared capability set#
var capabilities = []models.Capability{
models.CAPABILITY_FETCH_ACCOUNTS,
models.CAPABILITY_FETCH_BALANCES,
models.CAPABILITY_FETCH_EXTERNAL_ACCOUNTS,
models.CAPABILITY_FETCH_PAYMENTS,
models.CAPABILITY_CREATE_TRANSFER,
models.CAPABILITY_CREATE_PAYOUT,
models.CAPABILITY_CREATE_WEBHOOKS,
models.CAPABILITY_TRANSLATE_WEBHOOKS,
}This slice is the source of truth for the
Capabilities matrix on the docs
site — upstream serialises it into
docs/other/connector-capabilities.json
on every commit, and the docs build reads that file. Declare only what
you'll actually implement.
Implementing a capability#
Each FetchNext* method takes a FromPayload, an opaque State, and
a PageSize, and returns a slice of PSP* objects plus the next
State and a HasMore flag. The platform persists State between
cycles, so the connector keeps no in-memory cursors:
func (p *Plugin) FetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) {
if p.client == nil {
return models.FetchNextAccountsResponse{}, plugins.ErrNotYetInstalled
}
return p.fetchNextAccounts(ctx, req)
}The body lives in accounts.go:
type accountsState struct {
LastSeenAt time.Time `json:"lastSeenAt"`
}
func (p *Plugin) fetchNextAccounts(ctx context.Context, req models.FetchNextAccountsRequest) (models.FetchNextAccountsResponse, error) {
var state accountsState
if len(req.State) > 0 {
if err := json.Unmarshal(req.State, &state); err != nil {
return models.FetchNextAccountsResponse{}, err
}
}
page, hasMore, err := p.client.ListAccounts(ctx, state.LastSeenAt, req.PageSize)
if err != nil { return models.FetchNextAccountsResponse{}, err }
out := make([]models.PSPAccount, 0, len(page))
for _, a := range page {
out = append(out, models.PSPAccount{
Reference: a.ID,
CreatedAt: a.CreatedAt,
Name: &a.Name,
DefaultAsset: pointer.For(formatAsset(a.Currency)),
Metadata: map[string]string{
"com.stripe.spec/type": a.Type,
},
Raw: json.RawMessage(a.Raw),
})
}
if len(out) > 0 { state.LastSeenAt = out[len(out)-1].CreatedAt }
nextState, _ := json.Marshal(state)
return models.FetchNextAccountsResponse{Accounts: out, NewState: nextState, HasMore: hasMore}, nil
}Key invariants:
- The cursor lives in
State. The plugin reads it, the platform persists whatever you return asNewState. Crash mid-cycle and the worker resumes from the last persisted checkpoint. HasMore=truetells the platform to call you again immediately for the next page;HasMore=falseends the cycle and the schedule re-triggers afterPollingPeriod.- Metadata goes under a provider-specific namespace —
com.<provider>.spec/<key>— so downstream consumers can find it without colliding with other connectors. - Raw upstream payloads land on
.Rawso consumers needing fidelity beyond the typed surface can reach for them. Available on everyPSP*resource.
CreateTransfer and CreatePayout follow the same request/response
pattern but synchronously return either a Payment (when the upstream
settles immediately) or a PollingTransferID — in the latter case the
platform schedules PollTransferStatus until a terminal Payment
surfaces.
Webhooks#
If the upstream provider supports webhooks, implement the four-method contract:
CreateWebhooks— POST a webhook config upstream pointing at the platform's ingress URL. Persist the upstream config IDs inCreateWebhooksResponse.Configsso the platform can delete them on uninstall.TrimWebhook— split an upstream batched-events payload into onePSPWebhookper event.VerifyWebhook— verify the upstream signature and return the idempotency key the platform should use to dedupe replays.TranslateWebhook— convert a verified upstream payload into aWebhookResponsecarrying the affectedPSPAccount/PSPPayment/PSPBalance.
Declare both CAPABILITY_CREATE_WEBHOOKS and
CAPABILITY_TRANSLATE_WEBHOOKS in capabilities.go so the platform
schedules the create-webhook task at install and routes inbound
webhooks back to your plugin.
Configuration in the OpenAPI spec#
The registry reads the Config struct's reflection metadata and emits
the field list into
openapi/v3/v3-connectors-config.yaml
during the upstream OpenAPI regeneration. The docs build reads that file
via the payments.connectors.<id>.fields data key, and the per-connector
page renders the table from it. A new field upstream surfaces in the
public docs on the next content build with no docs-side edit.
The validate:"required" tag is what the docs surface as the
Required: yes column. Omit it for optional fields.
Testing#
Each connector ships with table-driven unit tests next to each
capability file (accounts_test.go, payments_test.go, …) using
mockgen-generated mocks of the HTTP client. See
stripe/accounts_test.go
for the pattern.
For end-to-end smoke testing against the real upstream API, point the connector workbench at your plugin and a sandbox key — it boots a single-plugin Temporal worker and runs the install workflow without the full platform stack.
Mapping documentation#
Every connector carries a MAPPING.md next to the code that
catalogues:
- Upstream resource → Formance resource field mapping.
- Status enum collapses (upstream values → Connectivity status).
com.<provider>.spec/metadata key inventory.- Known gaps and not-yet-implemented capabilities.
The mapping doc is the reference both for connector reviewers and for
the docs writer who turns it into the per-connector page at
apps/docs/content/pages/modules/connectivity/connectors/psp/<id>.mdx
in the docs repo.
CE references to read alongside this guide#
The shipped CE connectors are the best living references — pick one with a capability set close to your target:
stripe/— full set: accounts, balances, external accounts, payments, transfer- payout initiation, webhooks.
bankingcircle/— bank-rails connector with bank-account creation, transfers, payouts; mTLS + OAuth2 auth.mangopay/— multi-currency e-wallet provider with the full read + write surface and webhook ingest.wise/— multi-profile multi-currency, quote-then-fund payout flow.atlar/— read-only bank-rails connector (smaller surface, useful as a starting point if your provider is observation-only).
When in doubt, copy the closest match into a new folder, rename, and adapt — the framework is consistent across connectors so the boilerplate ports cleanly.