Skip to main content

README.md

# Rapyd

[![Hex.pm](https://img.shields.io/hexpm/v/rapyd.svg)](https://hex.pm/packages/rapyd)
[![CI](https://github.com/iamkanishka/rapyd/actions/workflows/ci.yml/badge.svg)](https://github.com/iamkanishka/rapyd/actions)
[![Coverage](https://coveralls.io/repos/github/iamkanishka/rapyd/badge.svg?branch=main)](https://coveralls.io/github/iamkanishka/rapyd)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)

Production-grade Elixir SDK for the [Rapyd](https://www.rapyd.net/) fintech-as-a-service platform.

## Features

- **Full API surface** — Collect, Disburse, Wallet, Issuing, Partner, Webhook, Resource
- **178+ service methods** across 7 domain-aligned service modules
- **HMAC-SHA256 request signing** — every request automatically authenticated
- **Webhook verification** — constant-time HMAC comparison, typed event structs, dispatch router
- **Full-jitter exponential backoff** — configurable retries on 429/5xx
- **Structured errors** — 17 semantic error types, retryability flag, operation ID
- **Swappable HTTP client** — inject a `Mox` mock for zero-network tests
- **Full typespec coverage**`@spec` and `@type` on all public APIs
- **Zero business-logic dependencies** — only `req` and `jason`

## Installation

```elixir
def deps do
  [
    {:rapyd, "~> 1.0"}
  ]
end
```

## Quick Start

```elixir
# Build a client (do this once at application start, store in your context/config)
client = Rapyd.new!(
  access_key: System.fetch_env!("RAPYD_ACCESS_KEY"),
  secret_key:  System.fetch_env!("RAPYD_SECRET_KEY"),
  sandbox:     true   # false for production
)

# Accept a payment
{:ok, payment} = Rapyd.Services.Collect.create_payment(client, %{
  amount:   100.00,
  currency: "USD",
  payment_method: %{
    type:   "us_visa_card",
    fields: %{
      number:           "4111111111111111",
      expiration_month: "12",
      expiration_year:  "2026",
      cvv:              "123"
    }
  }
})

IO.puts("Payment created: #{payment["id"]} status=#{payment["status"]}")
```

## Configuration

```elixir
client = Rapyd.new!(
  access_key:  "your_access_key",     # required
  secret_key:  "your_secret_key",     # required
  sandbox:     true,                  # default: true
  max_retries: 4,                     # default: 4 (1 = no retries)
  timeout:     30_000,                # default: 30 000 ms
  http_client: Rapyd.HTTP.Client    # default; swap for tests
)
```

## Services

### Collect

```elixir
alias Rapyd.Services.Collect

# Payments
{:ok, payment}      = Collect.create_payment(client, params)
{:ok, payment}      = Collect.get_payment(client, "pay_id")
{:ok, payments}     = Collect.list_payments(client, %{currency: "USD"})
{:ok, payment}      = Collect.capture_payment(client, "pay_id")
{:ok, _}            = Collect.cancel_payment(client, "pay_id")

# Hosted checkout
{:ok, page}         = Collect.create_checkout_page(client, params)

# Refunds
{:ok, refund}       = Collect.create_refund(client, %{payment: "pay_id", amount: 25})

# Customers & subscriptions
{:ok, customer}     = Collect.create_customer(client, %{email: "user@example.com"})
{:ok, plan}         = Collect.create_plan(client, params)
{:ok, sub}          = Collect.create_subscription(client, %{customer: "cus_id", plan: "plan_id"})
```

### Disburse

```elixir
alias Rapyd.Services.Disburse

{:ok, beneficiary}  = Disburse.create_beneficiary(client, params)
{:ok, payout}       = Disburse.create_payout(client, params)
{:ok, payout}       = Disburse.confirm_payout(client, "payout_id")
{:ok, methods}      = Disburse.list_payout_method_types(client, %{sender_country: "US"})
```

### Wallet

```elixir
alias Rapyd.Services.Wallet

{:ok, wallet}       = Wallet.create_wallet(client, %{type: "person", first_name: "Ada"})
{:ok, _}            = Wallet.change_wallet_status(client, "ew_id", "disable")
{:ok, contact}      = Wallet.create_contact(client, "ew_id", params)
{:ok, txns}         = Wallet.list_wallet_transactions(client, "ew_id", %{currency: "USD"})
{:ok, va}           = Wallet.create_virtual_account(client, %{ewallet: "ew_id", country: "US"})
{:ok, ident}        = Wallet.create_identity_verification(client, params)
```

### Issuing

```elixir
alias Rapyd.Services.Issuing

{:ok, card}         = Issuing.issue_card(client, %{card_program: "cp_id", ewallet_contact: "con_id"})
{:ok, card}         = Issuing.activate_card(client, "card_id")
{:ok, card}         = Issuing.block_card(client, "card_id")
{:ok, details}      = Issuing.get_card_details(client, "card_id")   # PCI scope
{:ok, txns}         = Issuing.list_card_transactions(client, "card_id")
```

### Partner

```elixir
alias Rapyd.Services.Partner

{:ok, org}          = Partner.create_organization(client, params)
{:ok, app}          = Partner.create_application(client, params)
{:ok, app}          = Partner.submit_application(client, "app_id")
{:ok, account}      = Partner.create_settlement_bank_account(client, params)
```

### Webhook Handling

Always verify the signature before trusting event data:

```elixir
defmodule MyApp.WebhookController do
  alias Rapyd.{Services.Webhook, Types.WebhookEvent}

  def handle(conn) do
    raw_body = conn.assigns[:raw_body]

    headers = %{
      salt:      get_req_header(conn, "salt")      |> List.first(),
      timestamp: get_req_header(conn, "timestamp") |> List.first(),
      signature: get_req_header(conn, "signature") |> List.first()
    }

    case Webhook.parse_and_verify(client, raw_body, headers) do
      {:ok, event} ->
        dispatch(event)
        send_resp(conn, 200, "ok")

      {:error, %Rapyd.Error{type: :webhook_signature}} ->
        send_resp(conn, 401, "invalid signature")
    end
  end

  defp dispatch(event) do
    WebhookEvent.dispatch(event, %{
      "PAYMENT_SUCCEEDED" => &handle_payment/1,
      "PAYOUT_COMPLETED"  => &handle_payout/1,
      default:              &log_event/1
    })
  end
end
```

## Error Handling

```elixir
case Rapyd.Services.Collect.create_payment(client, params) do
  {:ok, payment} ->
    {:ok, payment}

  {:error, %Rapyd.Error{type: :insufficient_funds}} ->
    {:error, :payment_method_declined}

  {:error, %Rapyd.Error{type: :rate_limit, retryable?: true}} ->
    Process.sleep(2_000)
    retry(params)

  {:error, %Rapyd.Error{type: :unauthorized}} ->
    raise "Invalid Rapyd credentials — check RAPYD_ACCESS_KEY / RAPYD_SECRET_KEY"

  {:error, %Rapyd.Error{} = err} ->
    Logger.error("Rapyd API error",
      type:      err.type,
      code:      err.error_code,
      http:      err.status_code,
      operation: err.operation_id
    )
    {:error, :rapyd_error}
end
```

### Error types

| Type | HTTP | Retryable |
|---|---|---|
| `:payment_not_found` | 404 ||
| `:payment_failed` | 400 ||
| `:payment_canceled` | 400 ||
| `:insufficient_funds` | 400 ||
| `:card_declined` | 400 ||
| `:expired_card` | 400 ||
| `:invalid_card` | 400 ||
| `:do_not_honor` | 400 ||
| `:fraud` | 400 ||
| `:rate_limit` | 429 ||
| `:unauthorized` | 401 ||
| `:forbidden` | 403 ||
| `:not_found` | 404 ||
| `:validation` |||
| `:webhook_signature` |||
| `:network` |||
| `:timeout` |||
| `:api_error` / `:unknown` | 5xx ||

## Testing

Inject a `Mox` mock to test without network calls:

```elixir
# test/test_helper.exs
Mox.defmock(MyApp.MockHTTP, for: Rapyd.HTTP.Behaviour)

# in your test
client = Rapyd.new!(
  access_key:  "key",
  secret_key:  "secret",
  http_client: MyApp.MockHTTP
)

MyApp.MockHTTP
|> expect(:request, fn _client, :post, "/v1/payments", _body, [] ->
  {:ok, %{"id" => "pay_test", "status" => "ACT"}}
end)
```

## License

MIT — see [LICENSE](LICENSE).