Erlang_pay - pure-Erlang payment gateway library for Alipay, WeChat Pay v3 and Stripe

erlang_pay is a pure-Erlang payment gateway library providing one unified API over three gateways: Alipay (App pay), WeChat Pay v3 (JSAPI/Native) and Stripe (PaymentIntent). It was extracted from a production project of ours and released as a standalone library under Apache-2.0.

Why

Integrating Chinese payment gateways from Erlang usually means shelling out to a Java/Go SDK or hand-rolling signature verification. erlang_pay implements the protocols natively in Erlang — no ports, no NIFs, no external services.

Features

  • One facade, three gateways — differences are isolated in tagged result maps, so calling code stays gateway-agnostic.
  • Full lifecycle — create payment, refund, webhook verification, query, bill download, close, cancel.
  • Verify-then-parse everywhere — WeChat responses & callbacks, Alipay responses & notifications, Stripe webhooks: a failed signature check never falls through to parsing the payload.
  • Idempotent refunds (Stripe) — a stable refund number is required; without an idempotency key, nothing is sent.
  • Credentials stay out of config — everything is passed in per call via a Cfg map; the library reads no application env.
  • Money is integer-only — amounts are always in the smallest currency unit (fen/cents); no floats anywhere.
  • Tiny dependency footprint — OTP + jsone only. Outbound traffic is https-only with TLS certificate and hostname verification.

Quick start

Alipay App pay:

Cfg = #{app_id => AppId, private_key => MchPriPem, public_key => AlipayPubPem,
        notify_url => <<"https://example.com/pay/callback/alipay">>},
{ok, #{order_str := OrderStr}} =
    erlang_pay:create_payment(alipay, Cfg, #{out_trade_no => <<"R123">>,
                                             amount_fen => 1000}).
%% hand OrderStr to the client-side AlipaySDK

Stripe PaymentIntent:

Cfg = #{secret_key => <<"sk_...">>, webhook_secret => <<"whsec_...">>},
{ok, #{payment_no := Pi, client_secret := Cs}} =
    erlang_pay:create_payment(stripe, Cfg, #{out_trade_no => <<"R123">>,
                                             amount_fen => 1000, currency => <<"usd">>}).

Verifying a webhook (raw bytes are verified first, then parsed):

{ok, Event} = erlang_pay:verify_notify(wechat, Cfg, #{headers => H, body => RawBody}).

Design notes

  • All failures return a uniform {error, {Code, Msg}} with a documented set of codes (bad_signature, timestamp_expired, no_credential, http_error, …).
  • Missing credentials fail closed (no_credential) instead of crashing; secrets and signed payloads never reach the logs.
  • A verified webhook event only proves authenticity — order matching, idempotency and posting decisions (ORDER_MATCHED / IDEMPOTENT / POSTABLE) are deliberately left to the caller.
  • A timed-out request (http_error) is treated as result unknown: query before retrying.

Status & testing

Current release is 0.3.0. It ships with 213 EUnit tests using real RSA key pairs and real signatures (mocking only at the HTTP boundary), a clean Dialyzer run, and a full clean-build gate script. One honest caveat: verification against the live provider sandboxes is still in progress, so please treat it as pre-1.0 — bug reports and feedback are very welcome.

Install with rebar3:

{deps, [{erlang_pay, {git, "https://github.com/imboy-pub/erlang_pay.git", {tag, "0.3.0"}}}]}.

A Gitee mirror is available at https://gitee.com/imboy-pub/erlang_pay. Licensed under Apache-2.0.

10 Likes

@leeyis Thanks for sharing! Could you please get rid of jsone and use Erlang’s built-in JSON module instead?

1 Like

Sure, I’ll release the updated version later.

1 Like

**

[0.3.1] - 2026-09-28**

Changed / 变更

  • 移除 jsone 依赖,JSON 编解码改用 OTP 27+ 内置 json 模块 —— 运行时零第三方依赖。 Dropped the jsone dependency in favour of the OTP 27+ built-in json module — zero third-party runtime dependencies.
  • 最低版本要求提升至 Erlang/OTP 27(json 模块随 27 引入)。 Minimum supported Erlang/OTP raised to 27 (json was introduced in OTP 27).
1 Like

@leeyis that was quick. Thanks.

Thanks for sharing this, and nice work on the library!

Since your library targets OTP 27+, sigils could make the embedded JSON in your tests much easier to read. For example, this:

reencode_node() ->
    <<"{\"trade_status\":\"TRADE_SUCCESS\",\"msg\":\"Success\",\"code\":\"10000\"}">>.

can become this:

reencode_node() ->
    ~B'{"trade_status":"TRADE_SUCCESS","msg":"Success","code":"10000"}'.

It also removes the need for the many /utf8 segments you have in your tests, since sigil binaries are UTF-8.

The triple-quoted form is even more readable for larger payloads:

reencode_node() ->
    ~B"""
    {
      "trade_status": "TRADE_SUCCESS",
      "msg": "Success",
      "code": "10000"
    }
    """.

Moreover, your CLAUDE.md could capture these conventions too, along with the modern Markdown documentation format (-moduledoc and -doc). You might even consider naming it AGENTS.md instead, so it works with other tools as well.

1 Like

@gene

感谢建议——全部接受,都是实打实的改进!

已完成:测试套件里所有手写 JSON 夹具改用 ~B sigil(大写形式,转义保持字面量,正适合 passback_params 的嵌套转义 JSON),/utf8 段全部去除;带插值的动态拼接 body 保持 <<...>> 原样。213 个测试全部通过。

接下来:CLAUDE.md 改名 AGENTS.md、补齐 -moduledoc/-doc。以上改动将随下一个版本发布。再次感谢!

Thanks for the suggestions — all accepted, they’re solid improvements!

Done already: all hand-written JSON fixtures in the test suite now use ~B sigils (uppercase, so escapes stay literal — exactly what passback_params needs for its nested escaped JSON), and the /utf8 segments are gone. Dynamically assembled bodies with interpolated keys/signatures keep their <<...>> form. All 213 tests green.

Next up: renaming CLAUDE.md → AGENTS.md and completing -moduledoc/-doc coverage. Everything will ship in the next release. Thanks again!

1 Like