# GoCardlessClient
[](https://hex.pm/packages/gocardless_client)
[](https://hexdocs.pm/gocardless_client)
[](https://github.com/iamkanishka/gocardless_client/actions)
[](LICENSE)
Production-ready Elixir client for the [GoCardless API](https://developer.gocardless.com/api-reference/). Complete coverage of all 139 endpoints across 46 resource modules — payments, mandates, billing requests, subscriptions, outbound payments, webhooks, OAuth2, and more.
---
## Features
| Capability | Detail |
| ------------------------- | ------------------------------------------------------------------------------ |
| **Complete API coverage** | All 139 GoCardless API endpoints across 46 resource modules |
| **Billing Requests** | Full Open Banking flow — mandate setup, instant payments, fallback to DD |
| **Outbound Payments** | Send money with ECDSA P-256 / RSA request signing |
| **OAuth2** | Partner platform auth URL, token exchange, lookup, disconnect |
| **Resilience** | Exponential backoff + full jitter, honours `Retry-After` header |
| **Pagination** | Lazy `Stream` and eager `collect_all` — zero memory pressure on large datasets |
| **Webhooks** | HMAC-SHA256 constant-time verification, Phoenix Plug middleware, IP allowlist |
| **Telemetry** | `[:gocardless, :request, :start/stop/exception]` events |
| **Rate limits** | `X-RateLimit-*` header tracking, accessible at runtime |
| **Config** | NimbleOptions-validated schema — catches misconfiguration at startup |
| **OTP** | Finch connection pools, supervised under `GoCardlessClient.Supervisor` |
---
## Installation
```elixir
# mix.exs
def deps do
[{:gocardless_client, "~> 2.0"}]
end
```
---
## Configuration
```elixir
# 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
```elixir
# Build a client at runtime (overrides application config)
client = GoCardlessClient.client!(access_token: token, environment: :live)
```
---
## Quick Start
```elixir
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)
```elixir
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)
```elixir
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
```elixir
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
```elixir
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.
```elixir
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:
```elixir
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
```elixir
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
### Phoenix Plug (recommended)
Add to `endpoint.ex` **before** `Plug.Parsers`:
```elixir
plug Plug.Parsers,
parsers: [:json],
json_decoder: Jason,
body_reader: {GoCardlessClient.Webhooks.Plug, :read_body, []}
```
Add to `router.ex`:
```elixir
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:
```elixir
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
```elixir
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
```elixir
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)
```elixir
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:
```elixir
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
```elixir
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:
```elixir
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
```elixir
: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
```elixir
state = GoCardlessClient.rate_limit_state(client)
# => %{limit: 1000, remaining: 950, reset_at: ~U[2025-01-15 10:30:00Z]}
```
---
## Complete Resource Reference
| Module | Endpoints | Key operations |
| -------------------------------- | --------- | ------------------------------------------------------------------ |
| `BankAuthorisations` | 2 | create, get |
| `BillingRequests` | 14 | create, get, list, update + 9 actions |
| `BillingRequestFlows` | 2 | create, initialise |
| `BillingRequestTemplates` | 4 | create, get, list, update |
| `BillingRequestWithActions` | 1 | create |
| `Institutions` | 2 | list, list_for_billing_request |
| `Balances` | 1 | list |
| `BankAccountDetails` | 1 | get |
| `BankAccountHolderVerifications` | 2 | create, get |
| `BankDetailsLookups` | 1 | lookup |
| `Blocks` | 6 | create, get, list, disable, enable, block_by_reference |
| `Creditors` | 4 | create, get, list, update |
| `CreditorBankAccounts` | 5 | create, get, list, update, disable |
| `CurrencyExchangeRates` | 1 | list |
| `Customers` | 5 | create, get, list, update, remove (GDPR) |
| `CustomerBankAccounts` | 5 | create, get, list, update, disable |
| `CustomerNotifications` | 1 | handle |
| `Events` | 2 | get, list |
| `Exports` | 2 | get, list |
| `FundsAvailabilities` | 1 | check |
| `InstalmentSchedules` | 6 | create_with_dates, create_with_schedule, get, list, update, cancel |
| `Logos` | 1 | create |
| `Mandates` | 6 | create, get, list, update, cancel, reinstate |
| `MandateImports` | 4 | create, get, submit, cancel |
| `MandateImportEntries` | 2 | add, list |
| `MandatePDFs` | 1 | create |
| `NegativeBalanceLimits` | 1 | list |
| `OutboundPayments` | 8 | create, withdrawal, get, list, update, cancel, approve, statistics |
| `OutboundPaymentImports` | 3 | create, get, list |
| `OutboundPaymentImportEntries` | 1 | list |
| `PayerAuthorisations` | 6 | create, get, list, update, submit, confirm |
| `PayerThemes` | 1 | create |
| `Payments` | 6 | create, get, list, update, cancel, retry |
| `PaymentAccounts` | 2 | get, list |
| `PaymentAccountTransactions` | 2 | get, list |
| `Payouts` | 3 | get, list, update |
| `PayoutItems` | 1 | list |
| `RedirectFlows` | 4 | create, get, list, complete |
| `Refunds` | 4 | create, get, list, update |
| `ScenarioSimulators` | 1 | run (19 scenario types) |
| `SchemeIdentifiers` | 4 | create, get, list, update |
| `Subscriptions` | 7 | create, get, list, update, pause, resume, cancel |
| `TaxRates` | 2 | get, list |
| `TransferredMandates` | 1 | get |
| `VerificationDetails` | 3 | create, get, list |
| `Webhooks` (resource) | 3 | get, list, retry |
All list endpoints also expose `stream/3` (lazy `Stream`) and `collect_all/3` (eager list).
---
## Running Tests
```bash
mix deps.get
mix test
mix test --cover
mix credo --strict
mix dialyzer
```
---
## License
MIT — see [LICENSE](LICENSE).