Skip to content

benoitc/livery_stripe

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

livery_stripe

A Stripe API client for Erlang/OTP, built on the 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. Then dive into the task you have:

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:

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

%% 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):

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)):

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:

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 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.

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:

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

STRIPE_SECRET_KEY=sk_test_xxx rebar3 shell
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:

    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:

    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.

About

Stripe API client for Erlang/OTP, built on the livery HTTP client

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages