lipwa 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/lib/lipwa/version.rb +1 -1
- metadata +1 -14
- data/CODE_OF_CONDUCT.md +0 -10
- data/Rakefile +0 -23
- data/docs/README.md +0 -15
- data/docs/_config.yml +0 -11
- data/docs/_layouts/default.html +0 -37
- data/docs/assets/css/style.css +0 -8
- data/docs/capabilities.md +0 -116
- data/docs/gateways.md +0 -158
- data/docs/getting-started.md +0 -64
- data/docs/index.md +0 -46
- data/docs/reliability.md +0 -50
- data/docs/webhooks.md +0 -72
- data/sig/lipwa.rbs +0 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 184f658687e8cc70f00f9053c7ee5eddf4a3d19679843dd74604abcba4806183
|
|
4
|
+
data.tar.gz: 7c3d66d4138ceaf01fb053d5cbb11d499321016f0e0078e63fbab24252a9e68a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d6a8dd5390049cc230bca9cfffe383eaa511b112e038e1ae66aec4d87548e77714b210e53e58f29bf4be95ab830c77be8a75fd31236f7fa50018261d482c947
|
|
7
|
+
data.tar.gz: ce26c22b739fcdc0c27e5c97a8379722db6b8a8ddd87c2d7e744817ab3f2f162b149e5c47734c10a93582fd9aee463b73e2efc0a7aeef774ce7dec1344034eb3
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.1.4] - 2026-09-07
|
|
2
|
+
|
|
3
|
+
- Use an explicit package allowlist to exclude documentation site sources,
|
|
4
|
+
development tasks, contributor guidance, and placeholder RBS signatures from
|
|
5
|
+
the installed gem while retaining runtime files, the license, README, and changelog
|
|
6
|
+
|
|
1
7
|
## [0.1.3] - 2026-09-06
|
|
2
8
|
|
|
3
9
|
- Add Co-op Bank support for bank transfers, balance and statement inquiries,
|
data/lib/lipwa/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lipwa
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Paul Oguda
|
|
@@ -147,20 +147,8 @@ extensions: []
|
|
|
147
147
|
extra_rdoc_files: []
|
|
148
148
|
files:
|
|
149
149
|
- CHANGELOG.md
|
|
150
|
-
- CODE_OF_CONDUCT.md
|
|
151
150
|
- LICENSE.txt
|
|
152
151
|
- README.md
|
|
153
|
-
- Rakefile
|
|
154
|
-
- docs/README.md
|
|
155
|
-
- docs/_config.yml
|
|
156
|
-
- docs/_layouts/default.html
|
|
157
|
-
- docs/assets/css/style.css
|
|
158
|
-
- docs/capabilities.md
|
|
159
|
-
- docs/gateways.md
|
|
160
|
-
- docs/getting-started.md
|
|
161
|
-
- docs/index.md
|
|
162
|
-
- docs/reliability.md
|
|
163
|
-
- docs/webhooks.md
|
|
164
152
|
- lib/lipwa.rb
|
|
165
153
|
- lib/lipwa/auth_strategies.rb
|
|
166
154
|
- lib/lipwa/auth_strategies/api_key.rb
|
|
@@ -223,7 +211,6 @@ files:
|
|
|
223
211
|
- lib/lipwa/webhooks/jenga.rb
|
|
224
212
|
- lib/lipwa/webhooks/lnbits.rb
|
|
225
213
|
- lib/lipwa/webhooks/mpesa.rb
|
|
226
|
-
- sig/lipwa.rbs
|
|
227
214
|
homepage: https://github.com/kamalogudah/lipwa
|
|
228
215
|
licenses:
|
|
229
216
|
- MIT
|
data/CODE_OF_CONDUCT.md
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# Code of Conduct
|
|
2
|
-
|
|
3
|
-
"lipwa" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
-
|
|
5
|
-
* Participants will be tolerant of opposing views.
|
|
6
|
-
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
-
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
-
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
-
|
|
10
|
-
If you have any concerns about behaviour within this project, please contact us at ["mcpaul2058@gmail.com"](mailto:"mcpaul2058@gmail.com").
|
data/Rakefile
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "bundler/gem_tasks"
|
|
4
|
-
require "minitest/test_task"
|
|
5
|
-
|
|
6
|
-
Minitest::TestTask.create
|
|
7
|
-
|
|
8
|
-
require "rubocop/rake_task"
|
|
9
|
-
|
|
10
|
-
RuboCop::RakeTask.new
|
|
11
|
-
|
|
12
|
-
namespace :docs do
|
|
13
|
-
desc "Build the documentation site"
|
|
14
|
-
task :build do
|
|
15
|
-
require "jekyll"
|
|
16
|
-
Jekyll::Commands::Build.process(
|
|
17
|
-
source: File.expand_path("docs", __dir__),
|
|
18
|
-
destination: File.expand_path("_site", __dir__)
|
|
19
|
-
)
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
task default: %i[test rubocop]
|
data/docs/README.md
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# Lipwa documentation site
|
|
2
|
-
|
|
3
|
-
This directory is the source for the Lipwa documentation site. It uses Jekyll
|
|
4
|
-
with a custom layout and has no theme or plugin dependency.
|
|
5
|
-
|
|
6
|
-
From the repository root:
|
|
7
|
-
|
|
8
|
-
```bash
|
|
9
|
-
bundle install
|
|
10
|
-
bundle exec rake docs:build
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
The generated site is written to `_site/`. GitHub Pages can publish directly
|
|
14
|
-
from the `docs/` directory on the main branch; `baseurl` in `_config.yml` is
|
|
15
|
-
already set for the `/lipwa` project path.
|
data/docs/_config.yml
DELETED
data/docs/_layouts/default.html
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<meta name="theme-color" content="#102a25">
|
|
7
|
-
<meta name="description" content="{{ page.description | default: site.description }}">
|
|
8
|
-
<title>{% if page.title %}{{ page.title }} · {% endif %}{{ site.title }}</title>
|
|
9
|
-
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
|
|
10
|
-
</head>
|
|
11
|
-
<body>
|
|
12
|
-
<a class="skip-link" href="#content">Skip to content</a>
|
|
13
|
-
<header class="site-header">
|
|
14
|
-
<a class="brand" href="{{ '/' | relative_url }}"><span class="brand-mark">L</span> Lipwa</a>
|
|
15
|
-
<button class="nav-toggle" type="button" aria-expanded="false" aria-controls="site-nav">Menu</button>
|
|
16
|
-
<nav id="site-nav" class="site-nav" aria-label="Primary">
|
|
17
|
-
<a href="{{ '/getting-started/' | relative_url }}">Start</a>
|
|
18
|
-
<a href="{{ '/capabilities/' | relative_url }}">Capabilities</a>
|
|
19
|
-
<a href="{{ '/gateways/' | relative_url }}">Gateways</a>
|
|
20
|
-
<a href="{{ '/webhooks/' | relative_url }}">Webhooks</a>
|
|
21
|
-
<a href="{{ '/reliability/' | relative_url }}">Reliability</a>
|
|
22
|
-
<a href="https://github.com/kamalogudah/lipwa">GitHub ↗</a>
|
|
23
|
-
</nav>
|
|
24
|
-
</header>
|
|
25
|
-
<main id="content" class="page-shell">{{ content }}</main>
|
|
26
|
-
<footer class="site-footer"><span>Lipwa · payment flows, honestly modeled</span><a href="https://github.com/kamalogudah/lipwa/blob/master/LICENSE.txt">MIT licensed</a></footer>
|
|
27
|
-
<script>
|
|
28
|
-
const button = document.querySelector(".nav-toggle");
|
|
29
|
-
const nav = document.querySelector(".site-nav");
|
|
30
|
-
button.addEventListener("click", () => {
|
|
31
|
-
const open = button.getAttribute("aria-expanded") === "true";
|
|
32
|
-
button.setAttribute("aria-expanded", String(!open));
|
|
33
|
-
nav.classList.toggle("is-open", !open);
|
|
34
|
-
});
|
|
35
|
-
</script>
|
|
36
|
-
</body>
|
|
37
|
-
</html>
|
data/docs/assets/css/style.css
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
:root{--ink:#15231f;--muted:#586b65;--paper:#fbfaf5;--panel:#f0f4ed;--line:#d7dfd7;--green:#16735d;--dark:#102a25;--lime:#d8f06d;--code:#11231f;--max:1120px}
|
|
2
|
-
*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:6rem}body{margin:0;color:var(--ink);background:radial-gradient(circle at 88% 2%,rgba(216,240,109,.24),transparent 24rem),var(--paper);font:17px/1.7 Inter,ui-sans-serif,system-ui,-apple-system,sans-serif}a{color:var(--green);text-underline-offset:.2em}.skip-link{position:fixed;top:-4rem;left:1rem;z-index:10;padding:.65rem 1rem;color:white;background:var(--dark)}.skip-link:focus{top:1rem}
|
|
3
|
-
.site-header{position:sticky;top:0;z-index:5;display:flex;align-items:center;justify-content:space-between;min-height:72px;padding:0 max(24px,calc((100vw - var(--max))/2));border-bottom:1px solid rgba(21,35,31,.12);background:rgba(251,250,245,.94);backdrop-filter:blur(16px)}.brand{display:flex;align-items:center;gap:.65rem;color:var(--dark);font-weight:800;text-decoration:none}.brand-mark{display:grid;width:34px;height:34px;place-items:center;border-radius:9px;background:var(--lime);font-family:ui-monospace,monospace}.site-nav{display:flex;gap:1.35rem}.site-nav a{color:var(--ink);font-size:.9rem;font-weight:650;text-decoration:none}.site-nav a:hover{color:var(--green)}.nav-toggle{display:none}
|
|
4
|
-
.page-shell{width:min(calc(100% - 48px),var(--max));min-height:calc(100vh - 160px);margin:auto;padding:4.5rem 0 6rem}.hero{display:grid;grid-template-columns:1.15fr .85fr;gap:clamp(2rem,6vw,6rem);align-items:center;min-height:560px;padding:2rem 0 5rem}.eyebrow{margin:0 0 .7rem;color:var(--green);font-size:.78rem;font-weight:800;letter-spacing:.13em;text-transform:uppercase}h1,h2,h3{color:var(--dark);font-weight:800;line-height:1.12;letter-spacing:-.035em}h1{max-width:850px;margin:0 0 1.25rem;font-size:clamp(2.65rem,7vw,5.6rem)}h2{margin:3.5rem 0 1rem;font-size:clamp(1.65rem,4vw,2.6rem)}h3{margin:2.2rem 0 .5rem;font-size:1.25rem}p,ul,ol{max-width:780px}.lead{color:var(--muted);font-size:clamp(1.08rem,2vw,1.35rem)}
|
|
5
|
-
.actions{display:flex;gap:.75rem;margin-top:2rem}.button{padding:.72rem 1.05rem;border:1px solid var(--dark);border-radius:8px;color:white;background:var(--dark);font-weight:750;text-decoration:none}.button.secondary{color:var(--dark);background:transparent}.terminal{overflow:hidden;border:1px solid #29443c;border-radius:14px;box-shadow:0 24px 80px rgba(16,42,37,.18);background:var(--code)}.terminal-bar{display:flex;gap:6px;padding:14px 16px;border-bottom:1px solid #29443c}.terminal-dot{width:9px;height:9px;border-radius:50%;background:#547269}.terminal-dot:last-child{background:var(--lime)}
|
|
6
|
-
pre{overflow-x:auto;padding:1.25rem 1.35rem;border-radius:10px;color:#e7f1ec;background:var(--code);font:.87rem/1.65 ui-monospace,monospace}.terminal pre{margin:0;border-radius:0}code{padding:.12em .35em;border-radius:4px;color:#165b4c;background:#e7eee7;font-size:.88em}pre code{padding:0;color:inherit;background:transparent;font-size:inherit}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin:2rem 0}.card{padding:1.5rem;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.6)}.card h3{margin-top:0}.card p{margin-bottom:0;color:var(--muted);font-size:.95rem}.number{color:var(--green);font:700 .8rem ui-monospace,monospace}.callout{margin:2rem 0;padding:1rem 1.25rem;border-left:4px solid var(--green);background:var(--panel)}
|
|
7
|
-
table{width:100%;margin:1.5rem 0 2.5rem;border-collapse:collapse;font-size:.94rem}th,td{padding:.8rem .9rem;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}th{color:var(--dark);background:var(--panel);font-size:.78rem;text-transform:uppercase}.page-title{padding-bottom:2rem;border-bottom:1px solid var(--line)}.page-title h1{font-size:clamp(2.5rem,6vw,4.5rem)}.doc-nav{display:flex;justify-content:space-between;margin-top:4rem;padding-top:1.5rem;border-top:1px solid var(--line)}.site-footer{display:flex;justify-content:space-between;padding:2rem max(24px,calc((100vw - var(--max))/2));color:var(--muted);border-top:1px solid var(--line);font-size:.82rem}
|
|
8
|
-
@media(max-width:800px){.hero{grid-template-columns:1fr;min-height:auto}.grid{grid-template-columns:1fr}.nav-toggle{display:block;padding:.45rem .7rem;border:1px solid var(--line);border-radius:6px;background:transparent}.site-nav{position:absolute;top:71px;right:0;left:0;display:none;flex-direction:column;padding:.5rem 24px 1rem;background:var(--paper)}.site-nav.is-open{display:flex}.site-nav a{padding:.6rem 0}.page-shell{width:min(calc(100% - 32px),var(--max));padding-top:3rem}.site-footer{flex-direction:column}table{display:block;overflow-x:auto}}
|
data/docs/capabilities.md
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Capabilities
|
|
3
|
-
description: Reference for Lipwa payment operations.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
<header class="page-title"><p class="eyebrow">Guide 02</p><h1>Capabilities</h1><p class="lead">A capability describes a payment flow. Gateways include only the modules they support.</p></header>
|
|
7
|
-
|
|
8
|
-
{% highlight ruby %}gateway = Lipwa.gateway(:jenga)
|
|
9
|
-
gateway.capability?(:bank_transfer) # => true{% endhighlight %}
|
|
10
|
-
|
|
11
|
-
| Capability | Methods | Gateways |
|
|
12
|
-
| --- | --- | --- |
|
|
13
|
-
| `stk_push` | `stk_push` | M-Pesa |
|
|
14
|
-
| `c2b` | `register_urls`, `simulate` | M-Pesa |
|
|
15
|
-
| `disbursement` | `disburse` | M-Pesa, Jenga |
|
|
16
|
-
| `status_query` | `status` | M-Pesa |
|
|
17
|
-
| `refund` | `refund` | M-Pesa |
|
|
18
|
-
| `bank_transfer` | `transfer`, `balance`, `statement` | Co-op, Jenga |
|
|
19
|
-
| `lightning_invoice` | `create_invoice`, `check_invoice` | LNbits-backed gateways |
|
|
20
|
-
| `lightning_payment` | `pay_invoice`, `check_payment` | LNbits-backed gateways |
|
|
21
|
-
|
|
22
|
-
Lightning invoice amounts are positive integer satoshis (`amount_sats`), not
|
|
23
|
-
`Lipwa::Money` values. A created invoice's BOLT11 string is stored in
|
|
24
|
-
`response.raw["payment_request"]`; its payment hash is
|
|
25
|
-
`response.provider_reference`. Checking an unpaid invoice still returns
|
|
26
|
-
`Success(Response)`, with `response.success? == false`.
|
|
27
|
-
|
|
28
|
-
Outbound Lightning payments use the separate `lightning_payment` capability
|
|
29
|
-
and require an explicitly configured wallet admin key. That key can spend the
|
|
30
|
-
wallet balance; it never replaces the receive-only invoice key. A payment may
|
|
31
|
-
remain `pending`, and a transport timeout leaves its outcome unknown. Inspect
|
|
32
|
-
`response.raw["status"]` and call `check_payment` to reconcile it; never retry
|
|
33
|
-
an indeterminate payment automatically.
|
|
34
|
-
|
|
35
|
-
{% highlight ruby %}lnbits.pay_invoice(bolt11: "lnbc...")
|
|
36
|
-
lnbits.check_payment(payment_hash: "..."){% endhighlight %}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
## Collection
|
|
40
|
-
|
|
41
|
-
{% highlight ruby %}mpesa = Lipwa.gateway(:mpesa)
|
|
42
|
-
|
|
43
|
-
mpesa.stk_push(
|
|
44
|
-
amount: Lipwa::Money.new(amount: 100, currency: "KES"),
|
|
45
|
-
phone_number: "254712345678",
|
|
46
|
-
account_reference: "ORDER-123",
|
|
47
|
-
callback_url: "https://example.com/webhooks/mpesa"
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
mpesa.register_urls(
|
|
51
|
-
validation_url: "https://example.com/mpesa/validation",
|
|
52
|
-
confirmation_url: "https://example.com/mpesa/confirmation"
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
mpesa.simulate(
|
|
56
|
-
amount: Lipwa::Money.new(amount: 100, currency: "KES"),
|
|
57
|
-
phone_number: "254712345678",
|
|
58
|
-
bill_ref_number: "ORDER-123"
|
|
59
|
-
){% endhighlight %}
|
|
60
|
-
|
|
61
|
-
`simulate` is for M-Pesa sandbox testing.
|
|
62
|
-
|
|
63
|
-
## Disbursement, status, and refund
|
|
64
|
-
|
|
65
|
-
{% highlight ruby %}mpesa.disburse(
|
|
66
|
-
command_id: "BusinessPayment",
|
|
67
|
-
amount: Lipwa::Money.new(amount: 500, currency: "KES"),
|
|
68
|
-
party_b: "254712345678",
|
|
69
|
-
remarks: "Supplier payout",
|
|
70
|
-
result_url: "https://example.com/mpesa/payout/result",
|
|
71
|
-
queue_timeout_url: "https://example.com/mpesa/payout/timeout"
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
mpesa.status(
|
|
75
|
-
transaction_id: "OEI2AK4Q16",
|
|
76
|
-
remarks: "Reconcile order",
|
|
77
|
-
result_url: "https://example.com/mpesa/status/result",
|
|
78
|
-
queue_timeout_url: "https://example.com/mpesa/status/timeout"
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
mpesa.refund(
|
|
82
|
-
transaction_id: "OEI2AK4Q16",
|
|
83
|
-
amount: Lipwa::Money.new(amount: 100, currency: "KES"),
|
|
84
|
-
remarks: "Customer refund",
|
|
85
|
-
result_url: "https://example.com/mpesa/refund/result",
|
|
86
|
-
queue_timeout_url: "https://example.com/mpesa/refund/timeout"
|
|
87
|
-
){% endhighlight %}
|
|
88
|
-
|
|
89
|
-
M-Pesa B2C command IDs are `SalaryPayment`, `BusinessPayment`, and
|
|
90
|
-
`PromotionPayment`. B2B supports `BusinessPayBill`, `BusinessBuyGoods`, and
|
|
91
|
-
`MerchantToMerchantTransfer` and requires `account_reference`.
|
|
92
|
-
|
|
93
|
-
## Bank operations
|
|
94
|
-
|
|
95
|
-
{% highlight ruby %}bank = Lipwa.gateway(:jenga)
|
|
96
|
-
|
|
97
|
-
bank.transfer(
|
|
98
|
-
rail: :pesalink,
|
|
99
|
-
amount: Lipwa::Money.new(amount: "2500.00", currency: "KES"),
|
|
100
|
-
source_account: "00123456789",
|
|
101
|
-
destination_account: "00987654321",
|
|
102
|
-
destination_name: "Amina N.",
|
|
103
|
-
reference: "INV-1042"
|
|
104
|
-
)
|
|
105
|
-
|
|
106
|
-
bank.balance(account_number: "00123456789")
|
|
107
|
-
bank.statement(account_number: "00123456789",
|
|
108
|
-
from_date: Date.new(2026, 8, 1),
|
|
109
|
-
to_date: Date.new(2026, 8, 31)){% endhighlight %}
|
|
110
|
-
|
|
111
|
-
Rails are `internal`, `rtgs`, `pesalink`, `swift`, and `bill_payment`,
|
|
112
|
-
subject to provider availability. RTGS/SWIFT require
|
|
113
|
-
`destination_bank_code`; bill payment requires `biller_code`. Jenga also
|
|
114
|
-
exposes `forex_rates`.
|
|
115
|
-
|
|
116
|
-
<nav class="doc-nav"><a href="{{ '/getting-started/' | relative_url }}">← Getting started</a><a href="{{ '/gateways/' | relative_url }}">Gateways →</a></nav>
|
data/docs/gateways.md
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Gateways
|
|
3
|
-
description: Configure M-Pesa, Co-op Bank, and Jenga HQ.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
<header class="page-title"><p class="eyebrow">Guide 03</p><h1>Gateways</h1><p class="lead">Configure process-wide defaults or isolate each tenant's credentials and HTTP settings in a context.</p></header>
|
|
7
|
-
|
|
8
|
-
## Process-wide defaults
|
|
9
|
-
|
|
10
|
-
`Lipwa.configure` sets shared `logger`, `default_timeout`, and Faraday `adapter`
|
|
11
|
-
defaults. Gateway-class `.configure` sets provider credentials, environment, and
|
|
12
|
-
HTTP overrides, as shown below. These APIs and `Lipwa.gateway(:mpesa)` remain
|
|
13
|
-
supported and backward compatible; existing single-tenant applications do not
|
|
14
|
-
need to adopt contexts.
|
|
15
|
-
|
|
16
|
-
Configure defaults at boot before resolving gateways or constructing contexts.
|
|
17
|
-
`Lipwa.gateway(:mpesa)` lazily constructs and memoizes one process-wide gateway.
|
|
18
|
-
Its provider and shared settings are captured on the first lookup; later
|
|
19
|
-
configuration changes do not update that instance. Direct construction with
|
|
20
|
-
`Lipwa::Gateways::Mpesa.new` captures defaults at construction time instead.
|
|
21
|
-
|
|
22
|
-
## M-Pesa
|
|
23
|
-
|
|
24
|
-
{% highlight ruby %}Lipwa::Gateways::Mpesa.configure do |c|
|
|
25
|
-
c.env = :sandbox
|
|
26
|
-
c.consumer_key = ENV.fetch("MPESA_CONSUMER_KEY")
|
|
27
|
-
c.consumer_secret = ENV.fetch("MPESA_CONSUMER_SECRET")
|
|
28
|
-
c.shortcode = ENV.fetch("MPESA_SHORTCODE")
|
|
29
|
-
c.passkey = ENV.fetch("MPESA_PASSKEY")
|
|
30
|
-
end{% endhighlight %}
|
|
31
|
-
|
|
32
|
-
Lipwa chooses the Daraja host and manages OAuth tokens.
|
|
33
|
-
|
|
34
|
-
## Co-op Bank
|
|
35
|
-
|
|
36
|
-
{% highlight ruby %}Lipwa::Gateways::CoopBank.configure do |c|
|
|
37
|
-
c.env = :sandbox
|
|
38
|
-
c.api_key = ENV.fetch("COOP_API_KEY")
|
|
39
|
-
c.api_secret = ENV.fetch("COOP_API_SECRET")
|
|
40
|
-
c.base_url = ENV["COOP_BASE_URL"] # optional override
|
|
41
|
-
c.token_url = ENV["COOP_TOKEN_URL"] # optional override
|
|
42
|
-
end{% endhighlight %}
|
|
43
|
-
|
|
44
|
-
`client_id` and `client_secret` are aliases for `api_key` and `api_secret`.
|
|
45
|
-
|
|
46
|
-
## Jenga HQ
|
|
47
|
-
|
|
48
|
-
{% highlight ruby %}Lipwa::Gateways::Jenga.configure do |c|
|
|
49
|
-
c.env = :sandbox
|
|
50
|
-
c.api_key = ENV.fetch("JENGA_API_KEY")
|
|
51
|
-
c.merchant_code = ENV.fetch("JENGA_MERCHANT_CODE")
|
|
52
|
-
c.consumer_secret = ENV.fetch("JENGA_CONSUMER_SECRET")
|
|
53
|
-
c.private_key = File.read(ENV.fetch("JENGA_PRIVATE_KEY_PATH"))
|
|
54
|
-
c.source_account = ENV.fetch("JENGA_SOURCE_ACCOUNT")
|
|
55
|
-
c.source_name = ENV.fetch("JENGA_SOURCE_NAME")
|
|
56
|
-
c.country_code = "KE"
|
|
57
|
-
c.partner_id = ENV["JENGA_PARTNER_ID"]
|
|
58
|
-
end{% endhighlight %}
|
|
59
|
-
|
|
60
|
-
Lipwa obtains bearer tokens and signs provider requests.
|
|
61
|
-
|
|
62
|
-
## Per-gateway HTTP settings
|
|
63
|
-
|
|
64
|
-
{% highlight ruby %}Lipwa::Gateways::Jenga.configure do |c|
|
|
65
|
-
c.timeout = 20
|
|
66
|
-
c.open_timeout = 5
|
|
67
|
-
c.logger = MyStructuredLogger.new
|
|
68
|
-
end{% endhighlight %}
|
|
69
|
-
|
|
70
|
-
Logs recursively redact authorization, credentials, cookies, passwords,
|
|
71
|
-
tokens, and secret-like fields.
|
|
72
|
-
|
|
73
|
-
## Contexts
|
|
74
|
-
|
|
75
|
-
`Lipwa.context { |config| ... }` (or `Lipwa::Context.new` with the same block)
|
|
76
|
-
captures `Lipwa.config` and every currently registered gateway class's settings
|
|
77
|
-
before running the builder block. Unspecified settings inherit those captured
|
|
78
|
-
defaults. `config.gateway(:mpesa) { |mpesa| ... }` overrides only that context's
|
|
79
|
-
M-Pesa settings. Provider `timeout` and `logger` take precedence over shared
|
|
80
|
-
`default_timeout` and `logger`; otherwise the shared defaults apply.
|
|
81
|
-
|
|
82
|
-
The snapshot happens when the context is constructed, even if its first gateway
|
|
83
|
-
lookup is much later. Subsequent global changes and registrations do not alter
|
|
84
|
-
an existing context. Overrides do not change process-wide defaults or other
|
|
85
|
-
contexts. Typed overrides are validated during construction.
|
|
86
|
-
|
|
87
|
-
When the block finishes, configuration is immutable: configuration strings,
|
|
88
|
-
arrays, and hashes are copied and frozen. Logger and other service objects retain
|
|
89
|
-
their identity rather than being deeply copied or frozen. Gateway runtime state,
|
|
90
|
-
such as HTTP clients and OAuth token caches, can still change.
|
|
91
|
-
|
|
92
|
-
Each context lazily constructs and memoizes its own gateway instances. Repeated
|
|
93
|
-
`tenant_context.gateway(:mpesa)` calls return the same instance, distinct from
|
|
94
|
-
another context's gateway and from `Lipwa.gateway(:mpesa)`. Built-in provider HTTP
|
|
95
|
-
clients, authentication, and token caches belong to those gateway instances.
|
|
96
|
-
Pass contexts explicitly; there is no thread-local current tenant.
|
|
97
|
-
|
|
98
|
-
### Retain one context per tenant
|
|
99
|
-
|
|
100
|
-
Construct one service object per tenant and retain it across payment calls. Here
|
|
101
|
-
`secrets_for` is an application-supplied callable that loads that tenant's secrets
|
|
102
|
-
from your credential store during context construction:
|
|
103
|
-
|
|
104
|
-
{% highlight ruby %}class TenantPayments
|
|
105
|
-
attr_reader :context
|
|
106
|
-
|
|
107
|
-
def initialize(tenant_id:, secrets_for:)
|
|
108
|
-
@context = Lipwa.context do |config|
|
|
109
|
-
secrets = secrets_for.call(tenant_id)
|
|
110
|
-
config.default_timeout = 20
|
|
111
|
-
config.gateway(:mpesa) do |mpesa|
|
|
112
|
-
mpesa.env = :production
|
|
113
|
-
mpesa.consumer_key = secrets.fetch(:consumer_key)
|
|
114
|
-
mpesa.consumer_secret = secrets.fetch(:consumer_secret)
|
|
115
|
-
mpesa.shortcode = secrets.fetch(:shortcode)
|
|
116
|
-
mpesa.passkey = secrets.fetch(:passkey)
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def stk_push(**params)
|
|
122
|
-
context.gateway(:mpesa).stk_push(**params)
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# Retain these services in your application's tenant service registry.
|
|
127
|
-
payments_by_tenant = tenant_ids.to_h do |tenant_id|
|
|
128
|
-
[tenant_id, TenantPayments.new(tenant_id: tenant_id, secrets_for: secrets_for)]
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
tenant_context = payments_by_tenant.fetch(tenant_id).context
|
|
132
|
-
tenant_context.gateway(:mpesa) # This tenant's memoized gateway
|
|
133
|
-
Lipwa.gateway(:mpesa) # Separate process-wide gateway using boot defaults
|
|
134
|
-
{% endhighlight %}
|
|
135
|
-
|
|
136
|
-
Do not repeatedly mutate `Lipwa::Gateways::Mpesa.configure` per request to switch
|
|
137
|
-
tenants: those defaults are shared across requests, and memoized gateways retain
|
|
138
|
-
their earlier snapshots. For credential rotation, construct a replacement service
|
|
139
|
-
and context with fresh secrets and use it for subsequent calls. Existing contexts
|
|
140
|
-
continue using their captured credentials.
|
|
141
|
-
|
|
142
|
-
Tenant secrets loaded during construction remain covered by Lipwa's HTTP log
|
|
143
|
-
redaction for authorization, credentials, tokens, and secret-like fields. This
|
|
144
|
-
does not sanitize arbitrary application logs: do not log raw secret hashes or
|
|
145
|
-
inspect configuration objects containing credentials.
|
|
146
|
-
|
|
147
|
-
### Explicit gateway construction and custom authentication
|
|
148
|
-
|
|
149
|
-
Explicit `config:` and `global_config:` gateway constructor snapshots replace
|
|
150
|
-
their respective defaults and are copied and finalized by the gateway.
|
|
151
|
-
|
|
152
|
-
Custom `auth_strategy` objects are duplicated for each gateway. Stateful custom
|
|
153
|
-
strategies should implement `initialize_copy` to isolate nested mutable state,
|
|
154
|
-
or supply a zero-argument factory that creates a fresh strategy and token source.
|
|
155
|
-
Callable token providers and clocks must not close over shared mutable token
|
|
156
|
-
caches. Built-in OAuth clients reset their caches when copied.
|
|
157
|
-
|
|
158
|
-
<nav class="doc-nav"><a href="{{ '/capabilities/' | relative_url }}">← Capabilities</a><a href="{{ '/webhooks/' | relative_url }}">Webhooks →</a></nav>
|
data/docs/getting-started.md
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Getting started
|
|
3
|
-
description: Install Lipwa, configure a gateway, and make your first request.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
<header class="page-title"><p class="eyebrow">Guide 01</p><h1>Getting started</h1><p class="lead">Install the gem, configure one gateway, and handle the result explicitly.</p></header>
|
|
7
|
-
|
|
8
|
-
## Install and configure
|
|
9
|
-
|
|
10
|
-
{% highlight bash %}bundle add lipwa{% endhighlight %}
|
|
11
|
-
|
|
12
|
-
{% highlight ruby %}require "lipwa"
|
|
13
|
-
|
|
14
|
-
Lipwa.configure do |config|
|
|
15
|
-
config.logger = Rails.logger
|
|
16
|
-
config.default_timeout = 10
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
Lipwa::Gateways::Mpesa.configure do |config|
|
|
20
|
-
config.env = :sandbox
|
|
21
|
-
config.consumer_key = ENV.fetch("MPESA_CONSUMER_KEY")
|
|
22
|
-
config.consumer_secret = ENV.fetch("MPESA_CONSUMER_SECRET")
|
|
23
|
-
config.shortcode = ENV.fetch("MPESA_SHORTCODE")
|
|
24
|
-
config.passkey = ENV.fetch("MPESA_PASSKEY")
|
|
25
|
-
end{% endhighlight %}
|
|
26
|
-
|
|
27
|
-
## Make a request
|
|
28
|
-
|
|
29
|
-
{% highlight ruby %}result = Lipwa.gateway(:mpesa).stk_push(
|
|
30
|
-
amount: Lipwa::Money.new(amount: "100.00", currency: "KES"),
|
|
31
|
-
phone_number: "254712345678",
|
|
32
|
-
account_reference: "ORDER-123",
|
|
33
|
-
callback_url: "https://payments.example.com/webhooks/mpesa",
|
|
34
|
-
idempotency_key: "stk-order-123"
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
result.either(
|
|
38
|
-
->(response) { Payment.update!(provider_reference: response.provider_reference) },
|
|
39
|
-
->(error) { logger.error(error.message) }
|
|
40
|
-
){% endhighlight %}
|
|
41
|
-
|
|
42
|
-
<div class="callout"><strong>Acceptance is not settlement.</strong> STK Push, disbursement, refund, and status calls are asynchronous. Process the later verified callback for the final result.</div>
|
|
43
|
-
|
|
44
|
-
## Initiator credentials
|
|
45
|
-
|
|
46
|
-
Disbursement, status, and refund also require:
|
|
47
|
-
|
|
48
|
-
{% highlight ruby %}Lipwa::Gateways::Mpesa.configure do |config|
|
|
49
|
-
config.initiator_name = ENV.fetch("MPESA_INITIATOR_NAME")
|
|
50
|
-
config.initiator_password = ENV.fetch("MPESA_INITIATOR_PASSWORD")
|
|
51
|
-
config.security_credential_cert =
|
|
52
|
-
File.read(ENV.fetch("MPESA_CERTIFICATE_PATH"))
|
|
53
|
-
end{% endhighlight %}
|
|
54
|
-
|
|
55
|
-
Sandbox and production certificates differ. Keep certificate content outside
|
|
56
|
-
source control.
|
|
57
|
-
|
|
58
|
-
## Idempotency
|
|
59
|
-
|
|
60
|
-
Reuse one stable `idempotency_key` when retrying one logical write. Lipwa
|
|
61
|
-
retries safe reads automatically, but writes only when this key is present.
|
|
62
|
-
The provider controls key retention and duplicate detection.
|
|
63
|
-
|
|
64
|
-
<nav class="doc-nav"><a href="{{ '/' | relative_url }}">← Home</a><a href="{{ '/capabilities/' | relative_url }}">Capabilities →</a></nav>
|
data/docs/index.md
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Home
|
|
3
|
-
description: Unified, capability-based African payment APIs for Ruby.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
<section class="hero"><div>
|
|
7
|
-
<p class="eyebrow">Payment flows, honestly modeled</p>
|
|
8
|
-
<h1>One Ruby API. Only the capabilities you need.</h1>
|
|
9
|
-
<p class="lead">Lipwa unifies mobile money, bank transfers, payouts, and asynchronous callbacks without pretending every provider behaves like a card gateway.</p>
|
|
10
|
-
<div class="actions"><a class="button" href="{{ '/getting-started/' | relative_url }}">Get started</a><a class="button secondary" href="{{ '/capabilities/' | relative_url }}">Explore capabilities</a></div>
|
|
11
|
-
</div><div class="terminal"><div class="terminal-bar"><span class="terminal-dot"></span><span class="terminal-dot"></span><span class="terminal-dot"></span></div>
|
|
12
|
-
{% highlight ruby %}gateway = Lipwa.gateway(:mpesa)
|
|
13
|
-
|
|
14
|
-
result = gateway.stk_push(
|
|
15
|
-
amount: Lipwa::Money.new(
|
|
16
|
-
amount: "100.00",
|
|
17
|
-
currency: "KES"
|
|
18
|
-
),
|
|
19
|
-
phone_number: "254712345678",
|
|
20
|
-
account_reference: "ORDER-123",
|
|
21
|
-
callback_url: callback_url,
|
|
22
|
-
idempotency_key: "stk-order-123"
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
result.fmap(&:provider_reference){% endhighlight %}
|
|
26
|
-
</div></section>
|
|
27
|
-
|
|
28
|
-
## Designed around real payment flows
|
|
29
|
-
|
|
30
|
-
<div class="grid">
|
|
31
|
-
<article class="card"><span class="number">01</span><h3>Capability first</h3><p>Gateways expose operations they truly implement. Inspect support with <code>capability?</code> and route deliberately.</p></article>
|
|
32
|
-
<article class="card"><span class="number">02</span><h3>Failure explicit</h3><p>Validation and provider failures travel through <code>Dry::Monads::Result</code>.</p></article>
|
|
33
|
-
<article class="card"><span class="number">03</span><h3>Async aware</h3><p>Request acknowledgement and final outcome stay distinct. Normalized webhook events complete the flow.</p></article>
|
|
34
|
-
</div>
|
|
35
|
-
|
|
36
|
-
## Supported gateways
|
|
37
|
-
|
|
38
|
-
| Gateway | What it can do |
|
|
39
|
-
| --- | --- |
|
|
40
|
-
| M-Pesa | STK Push, C2B, B2C/B2B, status, refunds |
|
|
41
|
-
| Co-op Bank | Transfers, balances, and statements |
|
|
42
|
-
| Jenga HQ | Bank/mobile transfers, account queries, forex |
|
|
43
|
-
|
|
44
|
-
<div class="callout"><strong>Start with a flow, not a provider.</strong> Choose the capability your product needs, then select a gateway that advertises it.</div>
|
|
45
|
-
|
|
46
|
-
<nav class="doc-nav"><span></span><a href="{{ '/getting-started/' | relative_url }}">Getting started →</a></nav>
|
data/docs/reliability.md
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Reliability
|
|
3
|
-
description: Results, retries, idempotency, and money safety.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
<header class="page-title"><p class="eyebrow">Guide 05</p><h1>Reliability</h1><p class="lead">Expected failures are explicit, and automatic recovery is limited to operations safe to repeat.</p></header>
|
|
7
|
-
|
|
8
|
-
## Results and errors
|
|
9
|
-
|
|
10
|
-
| Type | Delivery | Meaning |
|
|
11
|
-
| --- | --- | --- |
|
|
12
|
-
| `ValidationError` | `Failure` | Input failed a capability contract |
|
|
13
|
-
| `GatewayError` | `Failure` | Network or provider HTTP failure |
|
|
14
|
-
| `WebhookParseError` | `Failure` | Invalid inbound JSON |
|
|
15
|
-
| `ConfigurationError` | raised | Missing or invalid setup |
|
|
16
|
-
| `UnsupportedProviderError` | raised | No webhook parser registered |
|
|
17
|
-
|
|
18
|
-
`Lipwa::Response` exposes `success?`, `provider_reference`, `message`,
|
|
19
|
-
`code`, and `raw`.
|
|
20
|
-
|
|
21
|
-
## Retry policy
|
|
22
|
-
|
|
23
|
-
Lipwa retries transient network exceptions and HTTP 429, 500, 502, 503, and
|
|
24
|
-
504 at most twice. Backoff begins at 0.5 seconds, doubles with up to 50%
|
|
25
|
-
jitter, and caps waits at 5 seconds. Server `Retry-After` and
|
|
26
|
-
`RateLimit-Reset` values are honored within that cap.
|
|
27
|
-
|
|
28
|
-
Safe reads retry automatically. Writes retry only with `Idempotency-Key`.
|
|
29
|
-
|
|
30
|
-
## Decimal money
|
|
31
|
-
|
|
32
|
-
{% highlight ruby %}subtotal = Lipwa::Money.new(amount: "199.95", currency: "KES")
|
|
33
|
-
fee = Lipwa::Money.new(amount: "5.05", currency: "KES")
|
|
34
|
-
|
|
35
|
-
total = subtotal + fee
|
|
36
|
-
total.to_s # => "205.0 KES"
|
|
37
|
-
total > subtotal # => true{% endhighlight %}
|
|
38
|
-
|
|
39
|
-
Amounts and results are non-negative. Addition, subtraction, and comparison
|
|
40
|
-
across currencies raise `ArgumentError`. Currency conversion is never hidden.
|
|
41
|
-
|
|
42
|
-
## Async reconciliation
|
|
43
|
-
|
|
44
|
-
1. Persist a pending operation and idempotency key before calling the API.
|
|
45
|
-
2. Store the synchronous provider reference.
|
|
46
|
-
3. Verify and process callbacks idempotently.
|
|
47
|
-
4. Use status query where supported to reconcile missing callbacks.
|
|
48
|
-
5. Treat acknowledgements and callbacks as repeatable messages.
|
|
49
|
-
|
|
50
|
-
<nav class="doc-nav"><a href="{{ '/webhooks/' | relative_url }}">← Webhooks</a><a href="{{ '/' | relative_url }}">Home →</a></nav>
|
data/docs/webhooks.md
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Webhooks
|
|
3
|
-
description: Parse and verify M-Pesa, Jenga, and LNbits callbacks.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
<header class="page-title"><p class="eyebrow">Guide 04</p><h1>Webhooks</h1><p class="lead">Normalize provider payloads, then perform the provider-specific trust check before changing financial state.</p></header>
|
|
7
|
-
|
|
8
|
-
## Parse
|
|
9
|
-
|
|
10
|
-
{% highlight ruby %}result = Lipwa::Webhook.parse_webhook(
|
|
11
|
-
provider: :mpesa,
|
|
12
|
-
body: request.body.read,
|
|
13
|
-
headers: request.headers.to_h
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
result.either(
|
|
17
|
-
->(event) { process_event(event) },
|
|
18
|
-
->(error) { head :bad_request }
|
|
19
|
-
){% endhighlight %}
|
|
20
|
-
|
|
21
|
-
`WebhookEvent` exposes `provider`, `event_type`, `success?`,
|
|
22
|
-
`provider_reference`, `message`, `raw`, and `verify_signature`.
|
|
23
|
-
|
|
24
|
-
## Verify M-Pesa
|
|
25
|
-
|
|
26
|
-
Daraja does not cryptographically sign callbacks. Lipwa checks the remote
|
|
27
|
-
address against Safaricom's published callback IP list:
|
|
28
|
-
|
|
29
|
-
{% highlight ruby %}return head :forbidden unless event.verify_signature(
|
|
30
|
-
source_ip: request.remote_ip
|
|
31
|
-
){% endhighlight %}
|
|
32
|
-
|
|
33
|
-
Configure trusted proxies carefully so clients cannot spoof forwarded IPs.
|
|
34
|
-
Event types are `stk_callback`, `c2b`, `transaction_status`,
|
|
35
|
-
`disbursement`, and `refund`.
|
|
36
|
-
|
|
37
|
-
## Verify Jenga
|
|
38
|
-
|
|
39
|
-
Jenga receive-payment IPNs normalize to `receive_payment`:
|
|
40
|
-
|
|
41
|
-
{% highlight ruby %}return head :forbidden unless event.verify_signature(
|
|
42
|
-
username: ENV.fetch("JENGA_WEBHOOK_USERNAME"),
|
|
43
|
-
password: ENV.fetch("JENGA_WEBHOOK_PASSWORD")
|
|
44
|
-
){% endhighlight %}
|
|
45
|
-
|
|
46
|
-
## Verify LNbits
|
|
47
|
-
|
|
48
|
-
LNbits does not HMAC-sign webhooks by default. Give each invoice a strong,
|
|
49
|
-
random token in its webhook URL, persist that token alongside the payment hash,
|
|
50
|
-
and compare the URL parameter when the callback arrives:
|
|
51
|
-
|
|
52
|
-
{% highlight ruby %}return head :forbidden unless event.verify_signature(
|
|
53
|
-
expected_token: invoice.webhook_token,
|
|
54
|
-
provided_token: params[:token]
|
|
55
|
-
){% endhighlight %}
|
|
56
|
-
|
|
57
|
-
This is a shared-secret URL check, not proof that the webhook body came from
|
|
58
|
-
LNbits. Anyone who learns the token can forge a payload. Always call
|
|
59
|
-
`check_invoice(payment_hash: event.provider_reference)` and confirm its status
|
|
60
|
-
before crediting funds; use the webhook only as a low-latency trigger.
|
|
61
|
-
|
|
62
|
-
## Processing checklist
|
|
63
|
-
|
|
64
|
-
1. Retain the raw body and parse it through Lipwa.
|
|
65
|
-
2. Verify using provider-specific options.
|
|
66
|
-
3. Correlate by `provider_reference`.
|
|
67
|
-
4. Apply the transition idempotently inside a transaction.
|
|
68
|
-
5. Return quickly and defer slow work to a job.
|
|
69
|
-
|
|
70
|
-
<div class="callout"><strong>Never credit from an unverified callback.</strong> Parsing normalizes data; verification determines trust.</div>
|
|
71
|
-
|
|
72
|
-
<nav class="doc-nav"><a href="{{ '/gateways/' | relative_url }}">← Gateways</a><a href="{{ '/reliability/' | relative_url }}">Reliability →</a></nav>
|
data/sig/lipwa.rbs
DELETED