Skip to main content

README.md

# livery_stripe

A Stripe API client for Erlang/OTP, built on the [livery](https://github.com/benoitc/livery)
HTTP client. It covers core Stripe resources and subscriptions, and can back the
same billing flow friendpaste uses (Checkout + Billing Portal + webhooks).

## Documentation

New here? Start with [what you can build](docs/overview.md). Then dive into
the task you have:

- [Getting started](docs/guides/getting-started.md) - configure, first call,
  errors, request options
- [Subscription billing](docs/guides/subscriptions.md)
- [One-time payments](docs/guides/payments.md)
- [Saving cards](docs/guides/saving-cards.md)
- [Discounts and promotions](docs/guides/discounts.md)
- [Invoicing](docs/guides/invoicing.md)
- [Webhooks](docs/guides/webhooks.md)

## Feature support

| Resource | Module | Operations |
|---|---|---|
| Customers | `livery_stripe_customer` | create, retrieve, update, delete, list, list_payment_methods, delete_discount |
| Products | `livery_stripe_product` | create, retrieve, update, list |
| Prices | `livery_stripe_price` | create, retrieve, update, list |
| Checkout | `livery_stripe_checkout` | create_session, retrieve_session, expire_session, subscription_session |
| Billing portal | `livery_stripe_portal` | create_session |
| Subscriptions | `livery_stripe_subscription` | create, retrieve, update, cancel, list, pause, resume, delete_discount |
| Payment intents | `livery_stripe_payment_intent` | create, retrieve, update, confirm, capture, cancel, list |
| Payment methods | `livery_stripe_payment_method` | attach, detach, retrieve, update, list |
| Setup intents | `livery_stripe_setup_intent` | create, retrieve, confirm, cancel, list |
| Refunds | `livery_stripe_refund` | create, retrieve, update, cancel, list |
| Invoices | `livery_stripe_invoice` | create, retrieve, list, pay, finalize, void, send, mark_uncollectible, delete, upcoming |
| Coupons | `livery_stripe_coupon` | create, retrieve, update, delete, list |
| Promotion codes | `livery_stripe_promotion_code` | create, retrieve, update, list |
| Events | `livery_stripe_event` | retrieve, list |
| Webhooks | `livery_stripe_webhook`, `livery_stripe_webhook_handler` | signature verification + mountable handler |

Any endpoint without a wrapper is reachable via
`livery_stripe_client:do_request/4,5`.

## Why livery

The client is built on `livery_client` and wired with livery's flow-control
layers so calls retry safely and degrade gracefully under load:

- `timeout` - a hard ceiling over the whole call.
- `retry` - exponential backoff with jitter, honors `Retry-After`. Retries on
  transport errors and on `409/429/5xx`.
- `circuit_breaker` - trips on a failure ratio so a Stripe outage fails fast
  instead of piling up.
- `concurrency` - an in-flight admission gate (a semaphore) that caps real
  connections; excess calls return `{error, overloaded}`.

The client value is built once at app start and cached in `persistent_term`, so
the breaker and gate state is shared across every caller.

### Safe retries

Every mutating request (POST) carries an `Idempotency-Key`. livery's retry
replays the same request map, so the key is identical on every attempt and
Stripe deduplicates instead of, say, creating two subscriptions. Because of
this the retry layer enables `retry_non_idempotent` safely. Supply your own key
for cross-process at-least-once flows:

```erlang
livery_stripe_customer:create(Client, Params, #{idempotency_key => <<"order-42">>}).
```

## Configuration

Configure via the `livery_stripe` application environment (see
`config/sys.config.example`). Secrets are better supplied through the OS
environment, which overrides app env at runtime:

- `STRIPE_SECRET_KEY` -> `secret_key`
- `STRIPE_WEBHOOK_SECRET` -> `webhook_secret`

Price ids map to a plan + billing period under the `prices` key, e.g.
`livery_stripe:price_id(pro, monthly)` looks up `pro_monthly`.

## Usage

```erlang
%% Uses the cached, app-configured client:
{ok, Customer} = livery_stripe:create_customer(#{
    email => <<"a@b.c">>, name => <<"A B">>,
    metadata => #{<<"user_id">> => <<"u1">>}
}),
CustomerId = maps:get(<<"id">>, Customer),

%% End-to-end subscription checkout (the friendpaste flow):
{ok, Session} = livery_stripe:subscription_checkout(#{
    customer => CustomerId,
    plan => pro, billing_period => monthly,
    success_url => <<"https://app/billing?success=1">>,
    cancel_url  => <<"https://app/billing?canceled=1">>,
    metadata => #{<<"user_id">> => <<"u1">>, <<"plan">> => <<"pro">>}
}),
CheckoutUrl = maps:get(<<"url">>, Session),

{ok, Sub} = livery_stripe:get_subscription(<<"sub_123">>),
{ok, Portal} = livery_stripe:create_portal_session(#{
    customer => CustomerId, return_url => <<"https://app/billing">>
}).
```

For an explicit client (multiple accounts, tests), call the domain modules
directly: `livery_stripe_customer`, `livery_stripe_checkout`,
`livery_stripe_subscription` (`create`/retrieve/update/cancel/pause/resume),
`livery_stripe_portal`, `livery_stripe_price`, `livery_stripe_product`,
`livery_stripe_payment_intent`, `livery_stripe_payment_method`
(attach/detach/list), `livery_stripe_setup_intent`, `livery_stripe_refund`,
`livery_stripe_invoice` (create/finalize/void/send/pay/upcoming),
`livery_stripe_event` (retrieve/list), `livery_stripe_coupon`, and
`livery_stripe_promotion_code`. Customers and subscriptions also expose
`delete_discount/2`. The facade exposes
`livery_stripe:create_subscription/1`.

Build an explicit client with `livery_stripe_client:build(Config)`.

Results are `{ok, map()}` (decoded JSON) or `{error, Reason}` where `Reason` is
`{stripe_error, Status, ErrorMap}`, `{decode, Body}`, or a livery client error
(`timeout`, `circuit_open`, `overloaded`, a transport reason).

## Webhooks

Verify and decode events with `livery_stripe_webhook:construct_event/3,4`
(the equivalent of `stripe.Webhook.construct_event`):

```erlang
case livery_stripe_webhook:construct_event(RawBody, SigHeader, Secret) of
    {ok, Event}                  -> handle(Event);
    {error, invalid_signature}   -> reject;
    {error, invalid_payload}     -> reject;
    {error, timestamp_out_of_tolerance} -> reject
end.
```

Pass the RAW request body bytes; any re-encoding breaks the signature.

Or mount the ready-made livery handler, which verifies the signature and
dispatches to your `webhook_callback` (`handle_event(Type, Event)`):

```erlang
Router = livery_router:compile(
    livery_stripe_webhook_handler:routes(<<"/api/billing/webhook">>)
    ++ OtherRoutes
).
```

Persistence (updating a user's subscription, etc.) lives in the callback, so the
client stays storage-agnostic.

## Build and test

`livery` is consumed locally via `_checkouts/livery` (a symlink to a sibling
`livery` checkout) and is declared in `rebar.config`:

```sh
ln -s ../livery _checkouts/livery   # if not already present

rebar3 compile
rebar3 eunit                 # form encoding, webhook verification, util
rebar3 ct                    # see suites below
rebar3 xref
rebar3 dialyzer
rebar3 do eunit, ct, cover   # combined coverage report
rebar3 ex_doc                # generate HTML API docs into doc/
```

Test suites:

- `livery_stripe_form_tests`, `livery_stripe_webhook_tests`,
  `livery_stripe_util_tests` (eunit) - encoding and signature edge cases.
- `livery_stripe_client_SUITE` - resilience over a mock adapter: retry +
  same-key replay, `Retry-After` on 429, no-retry on card errors, transport
  errors, decode/error mapping, query encoding, the concurrency gate, and the
  circuit breaker.
- `livery_stripe_resources_SUITE` - every domain call's method + path.
- `livery_stripe_facade_SUITE` - the facade, `price_id/2`, env override.
- `livery_stripe_billing_SUITE` - end-to-end flow against a live livery mock
  Stripe server + webhook dispatch.
- `livery_stripe_webhook_handler_SUITE` - webhook handler dispatch and the
  200/400 responses.
- `livery_stripe_webhook_e2e_SUITE` - boots a real livery service mounting the
  webhook route and posts signed events over HTTP (200 + dispatch, 400 on a
  bad or missing signature).
- `livery_stripe_live_SUITE` - opt-in, hits the real Stripe API (see below).

Requires Erlang/OTP 27+ (uses the stdlib `json` module).

## Testing against a real Stripe account

Use a TEST-mode key (`sk_test_...`), never a live key. The operations below
do not charge anyone.

### Getting test-mode keys

1. Open the [Stripe Dashboard](https://dashboard.stripe.com) and turn on
   **Test mode** (toggle, top right).
2. Go to **Developers -> API keys** and reveal the **Secret key**. In test
   mode it starts with `sk_test_...` and only ever touches test data.
3. For webhook tests, the signing secret (`whsec_...`) comes from
   `stripe listen` (see below) or **Developers -> Webhooks -> [endpoint] ->
   Signing secret**.

Never use a live key (`sk_live_...`); the suite and examples are test-only.

### Automated live suite

`test/livery_stripe_live_SUITE` is skipped unless `STRIPE_SECRET_KEY` is set.
It exercises the real API and cleans up after itself (deletes customers,
archives products/prices, cancels subscriptions). Coverage: customer
lifecycle, idempotency-key replay, product + recurring price + subscription
Checkout session, a payment-intent lifecycle, a full subscription lifecycle
(attach a test card, create, retrieve, update, cancel), invoice listing, and
the cached-client facade path.

```sh
STRIPE_SECRET_KEY=sk_test_xxx rebar3 ct --suite test/livery_stripe_live_SUITE
```

### Running the live suite in CI

`.github/workflows/live.yml` runs the suite weekly and on manual dispatch,
reading the key from a repo secret. Set it once, then trigger on demand:

```sh
gh secret set STRIPE_SECRET_KEY        # paste the sk_test_... key
gh workflow run live.yml               # gh run watch to follow
```

Without the secret the job auto-skips and stays green.

### Interactive exploration

```sh
STRIPE_SECRET_KEY=sk_test_xxx rebar3 shell
```

```erlang
livery_stripe:configure(),
{ok, Cust} = livery_stripe:create_customer(#{email => <<"you@example.test">>}),
{ok, P}    = livery_stripe_product:create(livery_stripe:client(), #{name => <<"Pro">>}),
{ok, Pr}   = livery_stripe_price:create(livery_stripe:client(),
                #{product => maps:get(<<"id">>, P), unit_amount => 1000,
                  currency => <<"usd">>, recurring => #{interval => <<"month">>}}),
{ok, Sess} = livery_stripe:create_checkout_session(#{
    customer => maps:get(<<"id">>, Cust), mode => <<"subscription">>,
    line_items => [#{<<"price">> => maps:get(<<"id">>, Pr), <<"quantity">> => 1}],
    success_url => <<"https://example.test/ok">>,
    cancel_url  => <<"https://example.test/no">>}),
%% Open maps:get(<<"url">>, Sess) in a browser and pay with card 4242 4242 4242 4242.
```

### Webhooks with the Stripe CLI

Webhook signatures can only be exercised with a real signing secret, which the
Stripe CLI provides:

1. Mount the handler in a livery service and start it:

   ```erlang
   livery:start_service(#{http => #{port => 4000},
       router => livery_router:compile(
           livery_stripe_webhook_handler:routes(<<"/stripe/webhook">>))}).
   ```

   Set `webhook_callback` in config to a `handle_event(Type, Event)` callback,
   and `webhook_secret` to the `whsec_...` that `stripe listen` prints.

2. Forward events and trigger one:

   ```sh
   stripe login
   stripe listen --forward-to localhost:4000/stripe/webhook   # prints whsec_...
   stripe trigger checkout.session.completed
   ```

The handler verifies the signature against the raw body and dispatches the
event to your callback; a verified event returns `200`, a bad signature `400`.

To watch retries and idempotency in action, point `base_url` at a proxy (or
inspect the Stripe dashboard's request logs): a retried create reuses the same
`Idempotency-Key`, so Stripe records one object, not two.