Skip to content

fintech-sdk/gocardless_client

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

160 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

GoCardlessClient

Hex.pm Documentation CI License: 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

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

# 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

Phoenix Plug (recommended)

Add to endpoint.ex before Plug.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

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

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

License

MIT — see LICENSE.

About

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.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages