GoCardlessClient

Hex.pmDocumentationCILicense: MIT

Production-ready Elixir client for the GoCardless API. Complete coverage of all 139 endpoints across 46 resource modules — payments, mandates, billing requests, subscriptions, outbound payments, webhooks, OAuth2, and more.


Features

CapabilityDetail
Complete API coverageAll 139 GoCardless API endpoints across 46 resource modules
Billing RequestsFull Open Banking flow — mandate setup, instant payments, fallback to DD
Outbound PaymentsSend money with ECDSA P-256 / RSA request signing
OAuth2Partner platform auth URL, token exchange, lookup, disconnect
ResilienceExponential backoff + full jitter, honours Retry-After header
PaginationLazy Stream and eager collect_all — zero memory pressure on large datasets
WebhooksHMAC-SHA256 constant-time verification, Phoenix Plug middleware, IP allowlist
Telemetry[:gocardless, :request, :start/stop/exception] events
Rate limitsX-RateLimit-* header tracking, accessible at runtime
ConfigNimbleOptions-validated schema — catches misconfiguration at startup
OTPFinch connection pools, supervised under GoCardlessClient.Supervisor

Installation

# mix.exs
def deps do
[{:gocardless_client, "~> 2.0"}]
end

Configuration

# config/config.exs
config :gocardless_client,
access_token: System.get_env("GOCARDLESS_ACCESS_TOKEN"),
environment: :sandbox, # or :live
timeout: 30_000,
max_retries: 3

Runtime client

# Build a client at runtime (overrides application config)
client = GoCardlessClient.client!(access_token: token, environment: :live)

Quick Start

alias GoCardlessClient.Resources.{
CustomerBankAccounts,
Customers,
Mandates,
Payments
}
client = GoCardlessClient.client!()
# 1. Create a customer
{:ok, customer} = Customers.create(client, %{
email: "alice@example.com",
given_name: "Alice",
family_name: "Smith",
country_code: "GB"
})
# 2. Add their bank account
{:ok, bank_account} = CustomerBankAccounts.create(client, %{
account_holder_name: "Alice Smith",
account_number: "55779911",
branch_code: "200000",
country_code: "GB",
links: %{customer: customer["id"]}
})
# 3. Create a mandate
{:ok, mandate} = Mandates.create(client, %{
scheme: "bacs",
links: %{customer_bank_account: bank_account["id"]}
})
# 4. Charge the customer
{:ok, payment} = Payments.create(client, %{
amount: 1500,
currency: "GBP",
description: "Monthly subscription",
links: %{mandate: mandate["id"]}
}, idempotency_key: GoCardlessClient.new_idempotency_key())

Billing Requests (Open Banking / Pay by Bank)

The modern flow for collecting both mandates and instant bank payments, with built-in Open Banking support and fallback to Direct Debit.

Hosted flow (simplest)

alias GoCardlessClient.Resources.{BillingRequestFlows, BillingRequests}
# Mandate + optional instant payment in one flow
{:ok, br} = BillingRequests.create(client, %{
mandate_request: %{currency: "GBP", scheme: "bacs"},
payment_request: %{amount: 5000, currency: "GBP", description: "Setup fee"}
})
{:ok, flow} = BillingRequestFlows.create(client, %{
redirect_uri: "https://myapp.com/complete",
exit_uri: "https://myapp.com/cancel",
links: %{billing_request: br["id"]}
})
# Redirect customer to flow["authorisation_url"]

Server-side flow (single call)

alias GoCardlessClient.Resources.BillingRequestWithActions
{:ok, result} = BillingRequestWithActions.create(client, %{
mandate_request: %{currency: "GBP"},
actions: [
%{
type: "collect_customer_details",
collect_customer_details: %{
customer: %{given_name: "Alice", family_name: "Smith", email: "alice@example.com"},
customer_billing_detail: %{address_line1: "1 Example St", city: "London",
postal_code: "EC1A 1BB", country_code: "GB"}
}
},
%{
type: "collect_bank_account",
collect_bank_account: %{
account_holder_name: "Alice Smith",
account_number: "55779911",
branch_code: "200000",
country_code: "GB"
}
},
%{type: "confirm_payer_details", confirm_payer_details: %{}},
%{type: "fulfil", fulfil: %{}}
]
})

Subscriptions

alias GoCardlessClient.Resources.Subscriptions
{:ok, sub} = Subscriptions.create(client, %{
amount: 2500,
currency: "GBP",
name: "Premium Monthly",
interval_unit: "monthly",
interval: 1,
day_of_month: 1,
links: %{mandate: mandate_id}
})
{:ok, _} = Subscriptions.pause(client, sub["id"], %{pause_cycles: 2})
{:ok, _} = Subscriptions.resume(client, sub["id"])
{:ok, _} = Subscriptions.cancel(client, sub["id"])

Instalment Schedules

alias GoCardlessClient.Resources.InstalmentSchedules
# Explicit dates
{:ok, schedule} = InstalmentSchedules.create_with_dates(client, %{
name: "3-month plan",
currency: "GBP",
instalments: [
%{charge_date: "2025-02-01", amount: 5000},
%{charge_date: "2025-03-01", amount: 5000},
%{charge_date: "2025-04-01", amount: 5000}
],
links: %{mandate: mandate_id}
})
# Interval-based
{:ok, schedule} = InstalmentSchedules.create_with_schedule(client, %{
name: "6-month plan",
currency: "GBP",
amount: 3000,
start_date: "2025-02-01",
count: 6,
interval_unit: "monthly",
interval: 1,
links: %{mandate: mandate_id}
})

Outbound Payments

Requires an ECDSA P-256 private key registered in your GoCardless dashboard.

alias GoCardlessClient.Resources.OutboundPayments
alias GoCardlessClient.Signing
signer = Signing.new!(
key_id: System.get_env("GC_SIGNING_KEY_ID"),
pem: File.read!("private_key.pem"),
algorithm: :ecdsa
)
# Send to a recipient
{:ok, payment} = OutboundPayments.create(client, %{
amount: 50_000,
currency: "GBP",
description: "Supplier invoice #1234",
links: %{payment_account: "PA123", creditor: "CR456"},
recipient_bank_account: %{
account_holder_name: "Acme Ltd",
account_number: "12345678",
branch_code: "204514",
country_code: "GB"
}
}, signer: signer, idempotency_key: GoCardlessClient.new_idempotency_key())
# Withdraw funds to your own bank account
{:ok, _} = OutboundPayments.withdrawal(client, %{
amount: 100_000,
currency: "GBP",
links: %{payment_account: "PA123", creditor_bank_account: "BA456"}
}, signer: signer, idempotency_key: GoCardlessClient.new_idempotency_key())
# Check available funds first
{:ok, avail} = GoCardlessClient.Resources.FundsAvailabilities.check(client, %{
amount: 50_000, currency: "GBP"
})

Pagination

All list endpoints support lazy streaming and eager collection:

alias GoCardlessClient.Resources.Payments
# Lazy stream — fetches pages on demand, no memory pressure
Payments.stream(client, %{status: "paid_out"})
|> Stream.filter(&(&1["amount"] > 1000))
|> Stream.each(&reconcile/1)
|> Stream.run()
# Collect all pages eagerly
{:ok, all_payments} = Payments.collect_all(client, %{mandate: mandate_id})
# Single page with cursor
{:ok, %{items: payments, meta: meta}} = Payments.list(client, %{limit: 50, after: cursor})
next_cursor = get_in(meta, ["cursors", "after"])

Error Handling

case GoCardlessClient.Resources.Payments.create(client, params) do
{:ok, payment} ->
process(payment)
{:error, %GoCardlessClient.APIError{} = err} ->
cond do
GoCardlessClient.APIError.validation_failed?(err) ->
Enum.each(err.errors, &Logger.error("#{&1.field}: #{&1.message}"))
GoCardlessClient.APIError.rate_limited?(err) ->
Logger.warning("Rate limited — request_id: #{err.request_id}")
GoCardlessClient.APIError.invalid_state?(err) ->
Logger.warning("Invalid state: #{err.message}")
GoCardlessClient.APIError.not_found?(err) ->
Logger.warning("Not found")
GoCardlessClient.APIError.server_error?(err) ->
Logger.error("GoCardless internal error — request_id: #{err.request_id}")
end
{:error, %GoCardlessClient.Error{reason: :timeout}} ->
Logger.error("Request timed out")
end

Webhooks

Add to endpoint.exbeforePlug.Parsers:

plug Plug.Parsers,
parsers: [:json],
json_decoder: Jason,
body_reader: {GoCardlessClient.Webhooks.Plug, :read_body, []}

Add to router.ex:

pipeline :gocardless_webhooks do
plug GoCardlessClient.Webhooks.Plug,
secret: System.get_env("GOCARDLESS_WEBHOOK_SECRET")
end
scope "/webhooks" do
pipe_through :gocardless_webhooks
post "/gocardless", MyApp.WebhookController, :handle
end

In your controller:

def handle(conn, _params) do
conn.private[:gocardless_events]
|> Enum.each(&dispatch/1)
send_resp(conn, 200, "")
end
defp dispatch(%{"resource_type" => "payments", "action" => "paid_out"} = event),
do: Reconciler.payment_paid_out(event)
defp dispatch(%{"resource_type" => "mandates", "action" => "active"} = event),
do: MandateHandler.activated(event)
defp dispatch(%{"resource_type" => "billing_requests", "action" => "fulfilled"} = event),
do: OnboardingFlow.complete(event)
defp dispatch(_event), do: :ok

Manual verification

secret = System.get_env("GOCARDLESS_WEBHOOK_SECRET")
case GoCardlessClient.Webhooks.parse(raw_body, signature_header, secret) do
{:ok, events} -> Enum.each(events, &dispatch/1)
{:error, :invalid_signature} -> Logger.warning("Forged webhook rejected")
{:error, :payload_too_large} -> Logger.warning("Oversized payload rejected")
{:error, :invalid_json} -> Logger.warning("Malformed payload")
end

Event type helpers

alias GoCardlessClient.Webhooks
Webhooks.payment_event?(event) # resource_type == "payments"
Webhooks.mandate_event?(event) # resource_type == "mandates"
Webhooks.subscription_event?(event) # resource_type == "subscriptions"
Webhooks.billing_request_event?(event) # resource_type == "billing_requests"
Webhooks.payout_event?(event) # resource_type == "payouts"
Webhooks.refund_event?(event) # resource_type == "refunds"
Webhooks.outbound_payment_event?(event) # resource_type == "outbound_payments"
Webhooks.instalment_schedule_event?(event) # resource_type == "instalment_schedules"
Webhooks.creditor_event?(event) # resource_type == "creditors"
Webhooks.customer_event?(event) # resource_type == "customers"
Webhooks.export_event?(event) # resource_type == "exports"
Webhooks.scheme_identifier_event?(event) # resource_type == "scheme_identifiers"
Webhooks.payment_account_transaction_event?(event)# resource_type == "payment_account_transactions"
Webhooks.action?(event, "paid_out") # action == "paid_out"

OAuth2 (Partner Platforms)

alias GoCardlessClient.OAuth
config = %{
client_id: System.get_env("GC_CLIENT_ID"),
client_secret: System.get_env("GC_CLIENT_SECRET"),
redirect_uri: "https://yourapp.com/oauth/callback",
environment: :live
}
# 1. Redirect merchant to GoCardless
auth_url = OAuth.authorise_url(config, scope: "read_write", state: csrf_token)
# 2. Exchange code for token
{:ok, token} = OAuth.exchange_code(config, params["code"])
# 3. Build a merchant-scoped client
merchant_client = GoCardlessClient.client!(
access_token: token["access_token"],
environment: :live
)
# Lookup organisation details
{:ok, info} = OAuth.lookup_token(config, token["access_token"])
# Revoke
:ok = OAuth.disconnect(config, token["access_token"])

Mandate Imports (Migrations)

Bulk-import mandates from another payment provider:

alias GoCardlessClient.Resources.{MandateImportEntries, MandateImports}
{:ok, import} = MandateImports.create(client, %{scheme: "bacs"})
{:ok, _} = MandateImportEntries.add(client, %{
record_identifier: "CUST-001",
amendment: %{
original_creditor_id: "OLD-CR-001",
original_creditor_name: "Old Provider Ltd",
original_mandate_reference: "OLD-REF-001"
},
customer: %{given_name: "Alice", family_name: "Smith", email: "alice@example.com",
address_line1: "1 Example St", city: "London",
postal_code: "EC1A 1BB", country_code: "GB"},
bank_account: %{account_holder_name: "Alice Smith",
sort_code: "200000", account_number: "55779911"},
links: %{mandate_import: import["id"]}
})
{:ok, _} = MandateImports.submit(client, import["id"])

Payout Reconciliation

alias GoCardlessClient.Resources.{Events, PayoutItems, Payouts}
{:ok, %{items: payouts}} = Payouts.list(client, %{status: "paid"})
# Get line items for a specific payout
{:ok, %{items: items}} = PayoutItems.list(client, %{payout: "PO123"})
fees = Enum.filter(items, &(&1["type"] == "gocardless_fee"))
payments = Enum.filter(items, &(&1["type"] == "payment_paid_out"))
chargebacks = Enum.filter(items, &(&1["type"] == "payment_charged_back"))
# Get the event log for the payout
{:ok, %{items: events}} = Events.list(client, %{payout: "PO123"})

Scenario Simulators (Sandbox Only)

Trigger payment lifecycle events without real bank interactions:

alias GoCardlessClient.Resources.ScenarioSimulators
# Payment scenarios
{:ok, _} = ScenarioSimulators.run(client, "payment_paid_out", %{links: %{payment: "PM123"}})
{:ok, _} = ScenarioSimulators.run(client, "payment_failed", %{links: %{payment: "PM123"}})
{:ok, _} = ScenarioSimulators.run(client, "payment_charged_back", %{links: %{payment: "PM123"}})
# Mandate scenarios
{:ok, _} = ScenarioSimulators.run(client, "mandate_activated", %{links: %{mandate: "MD456"}})
{:ok, _} = ScenarioSimulators.run(client, "mandate_failed", %{links: %{mandate: "MD456"}})
# Billing request scenarios
{:ok, _} = ScenarioSimulators.run(client, "billing_request_fulfilled",
%{links: %{billing_request: "BRQ789"}})
# List all available scenario types
ScenarioSimulators.valid_scenarios()

Telemetry

:telemetry.attach_many("gocardless-metrics", [
[:gocardless, :request, :start],
[:gocardless, :request, :stop],
[:gocardless, :request, :exception]
], &MyApp.Telemetry.handle_event/4, nil)
# Metadata on :stop event:
# %{method: :post, url: "https://api.gocardless.com/payments",
# attempt: 1, status: 201, duration: 143}

Rate Limit State

state = GoCardlessClient.rate_limit_state(client)
# => %{limit: 1000, remaining: 950, reset_at: ~U[2025-01-15 10:30:00Z]}

Complete Resource Reference

ModuleEndpointsKey operations
BankAuthorisations2create, get
BillingRequests14create, get, list, update + 9 actions
BillingRequestFlows2create, initialise
BillingRequestTemplates4create, get, list, update
BillingRequestWithActions1create
Institutions2list, list_for_billing_request
Balances1list
BankAccountDetails1get
BankAccountHolderVerifications2create, get
BankDetailsLookups1lookup
Blocks6create, get, list, disable, enable, block_by_reference
Creditors4create, get, list, update
CreditorBankAccounts5create, get, list, update, disable
CurrencyExchangeRates1list
Customers5create, get, list, update, remove (GDPR)
CustomerBankAccounts5create, get, list, update, disable
CustomerNotifications1handle
Events2get, list
Exports2get, list
FundsAvailabilities1check
InstalmentSchedules6create_with_dates, create_with_schedule, get, list, update, cancel
Logos1create
Mandates6create, get, list, update, cancel, reinstate
MandateImports4create, get, submit, cancel
MandateImportEntries2add, list
MandatePDFs1create
NegativeBalanceLimits1list
OutboundPayments8create, withdrawal, get, list, update, cancel, approve, statistics
OutboundPaymentImports3create, get, list
OutboundPaymentImportEntries1list
PayerAuthorisations6create, get, list, update, submit, confirm
PayerThemes1create
Payments6create, get, list, update, cancel, retry
PaymentAccounts2get, list
PaymentAccountTransactions2get, list
Payouts3get, list, update
PayoutItems1list
RedirectFlows4create, get, list, complete
Refunds4create, get, list, update
ScenarioSimulators1run (19 scenario types)
SchemeIdentifiers4create, get, list, update
Subscriptions7create, get, list, update, pause, resume, cancel
TaxRates2get, list
TransferredMandates1get
VerificationDetails3create, get, list
Webhooks (resource)3get, list, retry

All list endpoints also expose stream/3 (lazy Stream) and collect_all/3 (eager list).


Running Tests

mix deps.get
mix test
mix test --cover
mix credo --strict
mix dialyzer

License

MIT — see LICENSE.