_Docs/
Get StartedModulesPlatformDeployCookbookChangelogReference
_Stack
_Modules
  • Ledger
  • Numscript
  • Connectivity
    • Capabilities
    • Operations
    • Accounts
    • Payments
    • Orders
    • Conversions
    • Payment Initiation
    • Account Pools
    • Payment Service Users
    • Connectors
      • Generic Connector
        • Getting Started
        • How it Works
      • PSP Connectors
        • Adyen
        • Atlar
        • Banking Circle
        • Column
        • Currencycloud
        • Increase
        • Mangopay
        • Modulr
        • Moneycorp
        • Qonto
        • Stripe
        • Wise
        • Banking BridgeEE
        • RoutableEE
      • Exchange Connectors
        • Coinbase PrimeEE
        • FireblocksEE
        • BitstampEE
      • Open Banking
        • Getting Started with Open Banking
        • Plaid
        • Tink
        • Powens
      • Build a connector
  • WalletsEE
  • FlowsEE
  • ReconciliationEE
  1. Modules
  2. Connectivity
  3. Connectors
  4. Build a connector
Connectivity

Build a connector

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 Config struct describing the install-time fields readers fill in.
  • A workflow declaration listing the periodic fetch tasks.
  • A []models.Capability slice declaring what the plugin supports.
  • One Go file per capability (accounts.go, payments.go, webhooks.go, …) implementing the PSPPlugin methods you opted into.
  • Table-driven unit tests next to each file.
  • A MAPPING.md cataloguing 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_* constants

Every 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:

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:

Go
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 tests

plugin.go — entry point and registration#

Go
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.

Go
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:

Go
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#

Go
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:

Go
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:

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 as NewState. Crash mid-cycle and the worker resumes from the last persisted checkpoint.
  • HasMore=true tells the platform to call you again immediately for the next page; HasMore=false ends the cycle and the schedule re-triggers after PollingPeriod.
  • 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 .Raw so consumers needing fidelity beyond the typed surface can reach for them. Available on every PSP* 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 in CreateWebhooksResponse.Configs so the platform can delete them on uninstall.
  • TrimWebhook — split an upstream batched-events payload into one PSPWebhook per 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 a WebhookResponse carrying the affected PSPAccount / 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.

PowensWallets
On This Page
  • Goal
  • Scope
  • What you'll add to the repo
  • Where the code lives
  • The Plugin interface
  • Anatomy of a connector folder
  • plugin.go — entry point and registration
  • config.go — typed config and validation
  • workflow.go — task tree
  • capabilities.go — declared capability set
  • Implementing a capability
  • Webhooks
  • Configuration in the OpenAPI spec
  • Testing
  • Mapping documentation
  • CE references to read alongside this guide