itaja 0.1.0__tar.gz
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.
- itaja-0.1.0/.gitignore +18 -0
- itaja-0.1.0/LICENSE +21 -0
- itaja-0.1.0/PKG-INFO +377 -0
- itaja-0.1.0/README.md +346 -0
- itaja-0.1.0/pyproject.toml +81 -0
- itaja-0.1.0/src/itaja/__init__.py +77 -0
- itaja-0.1.0/src/itaja/_transport.py +144 -0
- itaja-0.1.0/src/itaja/_version.py +1 -0
- itaja-0.1.0/src/itaja/catalog.py +388 -0
- itaja-0.1.0/src/itaja/cli.py +267 -0
- itaja-0.1.0/src/itaja/client.py +459 -0
- itaja-0.1.0/src/itaja/errors.py +108 -0
- itaja-0.1.0/src/itaja/handler.py +160 -0
- itaja-0.1.0/src/itaja/integrations/__init__.py +4 -0
- itaja-0.1.0/src/itaja/integrations/django.py +27 -0
- itaja-0.1.0/src/itaja/integrations/flask.py +22 -0
- itaja-0.1.0/src/itaja/integrations/starlette.py +40 -0
- itaja-0.1.0/src/itaja/py.typed +0 -0
- itaja-0.1.0/src/itaja/webhooks.py +162 -0
itaja-0.1.0/.gitignore
ADDED
itaja-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LIXALI STUDIO
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
itaja-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: itaja
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Itaja for Python servers: declare what you sell in code, push it, mount one webhook route, react to typed events. Django, FastAPI, Flask.
|
|
5
|
+
Project-URL: Homepage, https://github.com/itaja/itaja-sdks/tree/main/packages/python
|
|
6
|
+
Project-URL: Issues, https://github.com/itaja/itaja-sdks/issues
|
|
7
|
+
Project-URL: Source, https://github.com/itaja/itaja-sdks/tree/main/packages/python
|
|
8
|
+
Author: LIXALI STUDIO
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: credits,django,fastapi,flask,itaja,licenses,payments,subscriptions,webhooks
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Framework :: Flask
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: httpx<1,>=0.27
|
|
22
|
+
Provides-Extra: django
|
|
23
|
+
Requires-Dist: django>=4.2; extra == 'django'
|
|
24
|
+
Provides-Extra: fastapi
|
|
25
|
+
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
|
|
26
|
+
Provides-Extra: flask
|
|
27
|
+
Requires-Dist: flask>=3.0; extra == 'flask'
|
|
28
|
+
Provides-Extra: starlette
|
|
29
|
+
Requires-Dist: starlette>=0.37; extra == 'starlette'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# itaja
|
|
33
|
+
|
|
34
|
+
Sell subscriptions, credits and licence keys from your Python server. Declare what
|
|
35
|
+
you sell in code, push it to Itaja, mount one webhook route (Django, FastAPI,
|
|
36
|
+
Starlette, Flask), react to typed events.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install itaja # uv add itaja · poetry add itaja
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires Python 3.10+.
|
|
43
|
+
|
|
44
|
+
> **Server only.** This package holds your secret API key. Never ship it to a
|
|
45
|
+
> browser or a mobile app.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
### 0. Configure
|
|
50
|
+
|
|
51
|
+
Put your keys in `.env` (never commit it) and pass them to `create_itaja`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# .env
|
|
55
|
+
ITAJA_API_KEY=itjs_… # sandbox key; an itjp_… key works on the live catalogue
|
|
56
|
+
ITAJA_WEBHOOK_SECRET=whsec_… # signing secret of your webhook endpoint (dashboard)
|
|
57
|
+
ITAJA_API_URL=https://api.getitaja.com/api/v1 # optional
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import os
|
|
62
|
+
from itaja import create_itaja
|
|
63
|
+
|
|
64
|
+
itaja = create_itaja(
|
|
65
|
+
api_key=os.environ["ITAJA_API_KEY"],
|
|
66
|
+
webhook_secret=os.environ["ITAJA_WEBHOOK_SECRET"],
|
|
67
|
+
base_url=os.environ.get("ITAJA_API_URL"),
|
|
68
|
+
catalog=[...],
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Each argument falls back to its variable when you leave it out. Pass other values
|
|
73
|
+
to use another account, a tenant's key or a staging API. The `itaja` command loads
|
|
74
|
+
`.env` and `.env.local` itself: nothing to export in your shell. In your app, load
|
|
75
|
+
`.env` the way you already do (django-environ, pydantic-settings, python-dotenv…).
|
|
76
|
+
|
|
77
|
+
### 1. Declare what you sell
|
|
78
|
+
|
|
79
|
+
A free plan and a paid plan, with a quota that depends on the plan.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# itaja_config.py
|
|
83
|
+
import os
|
|
84
|
+
from itaja import create_itaja, feature, group, plan
|
|
85
|
+
|
|
86
|
+
# What the customer can do
|
|
87
|
+
messages = feature("messages", "metered")
|
|
88
|
+
export = feature("export", "boolean")
|
|
89
|
+
|
|
90
|
+
# One customer holds one plan of a group
|
|
91
|
+
app = group("app", "My App")
|
|
92
|
+
|
|
93
|
+
free = plan(
|
|
94
|
+
"free", app,
|
|
95
|
+
default=True, # everyone starts here, no payment
|
|
96
|
+
includes=[messages(limit=100, reset="month")],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
pro = plan(
|
|
100
|
+
"pro", app,
|
|
101
|
+
price={"amount": 5000, "currency": "XOF", "interval": "month"},
|
|
102
|
+
trial_days=7,
|
|
103
|
+
includes=[messages(limit=2000, reset="month"), export()],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
itaja = create_itaja(
|
|
107
|
+
api_key=os.environ.get("ITAJA_API_KEY"),
|
|
108
|
+
webhook_secret=os.environ.get("ITAJA_WEBHOOK_SECRET"),
|
|
109
|
+
catalog=[messages, export, free, pro],
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Your ids (`"pro"`, `"messages"`) are yours. Amounts are in **major units**
|
|
114
|
+
(`5000` XOF, `9.99` EUR), as in the API. Mistakes the API would refuse — an
|
|
115
|
+
undeclared feature, two default plans, a paid plan without a price, an invalid
|
|
116
|
+
id — raise `ItajaConfigError` as soon as the module is imported.
|
|
117
|
+
|
|
118
|
+
### 2. Push it
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
itaja push # shows the diff, asks, applies
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`push` uses the `api_key` of the client in `itaja_config.py`. Run it again: nothing
|
|
125
|
+
changes. Change a price and a **new version** of the plan is created; running
|
|
126
|
+
subscriptions keep theirs. Whatever you remove is archived, never deleted, and
|
|
127
|
+
products made in the dashboard are never touched.
|
|
128
|
+
|
|
129
|
+
| Command | Does |
|
|
130
|
+
|---|---|
|
|
131
|
+
| `itaja status` | lists what differs, exits 1 if anything does |
|
|
132
|
+
| `itaja push --yes` | applies without asking (CI, deploy pipeline) |
|
|
133
|
+
| `itaja catalog` | prints the JSON your code declares |
|
|
134
|
+
| `itaja --config myapp.billing:itaja push` | your client lives elsewhere |
|
|
135
|
+
|
|
136
|
+
The key's prefix picks the mode: `itjs_` is sandbox, `itjp_` is live.
|
|
137
|
+
|
|
138
|
+
### 3. Mount the webhook
|
|
139
|
+
|
|
140
|
+
One route receives Itaja's events. It checks the signature on the raw body,
|
|
141
|
+
ignores a delivery it already handled, ignores events of the other mode, and
|
|
142
|
+
answers `500` when your handler raises so Itaja delivers again. It checks
|
|
143
|
+
deliveries with `webhook_secret`.
|
|
144
|
+
|
|
145
|
+
**Django** (`pip install "itaja[django]"`)
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
# urls.py
|
|
149
|
+
from django.urls import path
|
|
150
|
+
from itaja.integrations.django import webhook_view
|
|
151
|
+
from itaja_config import itaja
|
|
152
|
+
|
|
153
|
+
urlpatterns = [path("itaja/webhook", webhook_view(itaja))] # CSRF-exempt, POST only
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**FastAPI** (`pip install "itaja[fastapi]"`)
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from fastapi import FastAPI
|
|
160
|
+
from itaja.integrations.starlette import webhook_router
|
|
161
|
+
from itaja_config import itaja
|
|
162
|
+
|
|
163
|
+
app = FastAPI()
|
|
164
|
+
app.include_router(webhook_router(itaja)) # POST /itaja/webhook
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
**Starlette**: `Route("/itaja/webhook", webhook_endpoint(itaja), methods=["POST"])`
|
|
168
|
+
from `itaja.integrations.starlette`.
|
|
169
|
+
|
|
170
|
+
**Flask** (`pip install "itaja[flask]"`)
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from flask import Flask
|
|
174
|
+
from itaja.integrations.flask import webhook_blueprint
|
|
175
|
+
from itaja_config import itaja
|
|
176
|
+
|
|
177
|
+
app = Flask(__name__)
|
|
178
|
+
app.register_blueprint(webhook_blueprint(itaja)) # POST /itaja/webhook
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Anything else: `itaja.webhooks.process(raw_body, headers)` (or `await
|
|
182
|
+
itaja.webhooks.process_async(...)`) returns the status and body to answer.
|
|
183
|
+
|
|
184
|
+
### 4. React to events
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
@itaja.on("checkout.paid")
|
|
188
|
+
def fulfil(event):
|
|
189
|
+
Order.fulfil(event.data["checkoutId"])
|
|
190
|
+
|
|
191
|
+
@itaja.on("subscription.canceled")
|
|
192
|
+
async def downgrade(event): # async handlers work too
|
|
193
|
+
await accounts.downgrade(event.data["customerId"])
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Or pass them at creation: `create_itaja(..., on={"checkout.paid": fulfil})`.
|
|
197
|
+
`event.data` holds the fields Itaja documents for that type. The `"*"` handler
|
|
198
|
+
receives every delivery, including types this version does not know yet.
|
|
199
|
+
|
|
200
|
+
Test a handler locally with a signed sample:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
itaja trigger checkout.paid --url http://localhost:8000/itaja/webhook # signs with ITAJA_WEBHOOK_SECRET from .env
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## What you can sell
|
|
207
|
+
|
|
208
|
+
| Mode | Declare with | Good for |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| [Subscriptions](#subscriptions) | `feature` · `group` · `plan` | SaaS, memberships |
|
|
211
|
+
| [Credits](#credits) | `credits` · `credit_pack` | usage, AI tokens, coins |
|
|
212
|
+
| [Licence keys](#licence-keys) | `license` | desktop or mobile software |
|
|
213
|
+
| [One-off payments](#one-off-payments) | `itaja.checkout.create` | a physical or digital order |
|
|
214
|
+
|
|
215
|
+
Each one can live in the same `catalog` list.
|
|
216
|
+
|
|
217
|
+
### Subscriptions
|
|
218
|
+
|
|
219
|
+
The example above is the whole declaration. Around it:
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
team = plan(
|
|
223
|
+
"team", app,
|
|
224
|
+
price={"amount": 15000, "currency": "XOF", "interval": "month"},
|
|
225
|
+
includes=[messages(limit=20000, reset="month"), export()],
|
|
226
|
+
)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
- `interval`: `week` · `month` · `quarter` · `year`. `reset` on a quota: `day` · `week` · `month` · `year`.
|
|
230
|
+
- A plan without a `price` must be `default=True`; a group has at most one default.
|
|
231
|
+
- `trial_days` gives a free period before the first charge.
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
itaja.subscriptions.list(customerId=customer_id)
|
|
235
|
+
itaja.subscriptions.cancel(subscription_id) # at period end
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Events: `invoice.paid` · `invoice.payment_failed` · `subscription.past_due` · `subscription.canceled`.
|
|
239
|
+
|
|
240
|
+
> **Coming with the API:** `itaja.subscribe()` (new, upgrade, downgrade,
|
|
241
|
+
> cancel-to-free in one call), `check()` and `report()` for entitlements exist and
|
|
242
|
+
> raise `NOT_YET_AVAILABLE` until the API serves them
|
|
243
|
+
> ([itaja-services #465, #466](https://github.com/itaja/itaja-services/issues/465)).
|
|
244
|
+
|
|
245
|
+
### Credits
|
|
246
|
+
|
|
247
|
+
A currency your app spends as the customer uses it, sold in packs.
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
from itaja import credit_pack, credits
|
|
251
|
+
|
|
252
|
+
tokens = credits("tokens", "AI tokens", coin_name="Tokens")
|
|
253
|
+
|
|
254
|
+
small = credit_pack("small", tokens, 100, {"amount": 1000, "currency": "XOF"})
|
|
255
|
+
large = credit_pack("large", tokens, 1000, {"amount": 8000, "currency": "XOF"})
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
checkout = itaja.credits.buy(pack="small", customer_id=customer_id, home_url="https://shop.example/thanks", product="tokens")
|
|
260
|
+
# redirect the buyer to checkout["checkoutUrl"]
|
|
261
|
+
|
|
262
|
+
itaja.credits.consume(customer_id=customer_id, amount=3, product="tokens")
|
|
263
|
+
itaja.credits.balance(customer_id=customer_id, product="tokens")
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Events: `credits.granted` · `credits.consumed` · `credits.expired` · `credits.low` · `credits.clawed_back`.
|
|
267
|
+
|
|
268
|
+
### Licence keys
|
|
269
|
+
|
|
270
|
+
Software that unlocks on a number of devices.
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
from itaja import license
|
|
274
|
+
|
|
275
|
+
desktop = license(
|
|
276
|
+
"desktop", "Desktop app",
|
|
277
|
+
price={"amount": 25000, "currency": "XOF"},
|
|
278
|
+
devices=3, # None = unlimited
|
|
279
|
+
duration="days-365", # "lifetime" · "days-N" · "from-first-activation-days-N"
|
|
280
|
+
)
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
itaja.licenses.issue(product="desktop", customer_id=customer_id)
|
|
285
|
+
itaja.licenses.revoke(key_id, product="desktop", reason="chargeback")
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Events: `license.issued` · `activated` · `deactivated` · `revoked` · `expired` · `rotated` · `suspended` · `resumed` · `updated`.
|
|
289
|
+
|
|
290
|
+
### One-off payments
|
|
291
|
+
|
|
292
|
+
Nothing to declare: create a checkout for an amount, send the customer to
|
|
293
|
+
`checkoutUrl`, and fulfil on `checkout.paid`.
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
checkout = itaja.checkout.create(
|
|
297
|
+
homeUrl="https://shop.example/thanks",
|
|
298
|
+
currency="XOF",
|
|
299
|
+
lineItems=[{"name": "Sandals", "quantity": 1, "unitPrice": 5000}],
|
|
300
|
+
)
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Payment links are a dashboard feature: no builder or endpoint here creates one.
|
|
304
|
+
|
|
305
|
+
## Also in the SDK
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
itaja.customers.get("user_42") # your own customer id works too
|
|
309
|
+
itaja.request("GET", "/me") # any endpoint the helpers do not cover
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
A product made in the dashboard is named by its Itaja id: `product_id="prd_…"`.
|
|
313
|
+
|
|
314
|
+
Verify a delivery yourself, without the route:
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
from itaja import construct_event
|
|
318
|
+
|
|
319
|
+
event = construct_event(raw_body, headers, secret=os.environ["ITAJA_WEBHOOK_SECRET"])
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Behind several processes or servers, share the processed-delivery store (it is in
|
|
323
|
+
memory by default) with any object that has `has(event_id)` and `add(event_id)`:
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
class RedisDedupe:
|
|
327
|
+
def has(self, event_id): return bool(redis.exists(f"itaja:{event_id}"))
|
|
328
|
+
def add(self, event_id): redis.set(f"itaja:{event_id}", 1, ex=86400)
|
|
329
|
+
|
|
330
|
+
itaja = create_itaja(..., dedupe=RedisDedupe())
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## Errors and retries
|
|
334
|
+
|
|
335
|
+
Every failure is an `ItajaError` with a stable `code`, the HTTP `status` and the
|
|
336
|
+
API's `details`: `ItajaValidationError` (400/422), `ItajaAuthenticationError`
|
|
337
|
+
(401), `ItajaPermissionError` (403), `ItajaNotFoundError` (404),
|
|
338
|
+
`ItajaConflictError` (409), `ItajaRateLimitError` (429), `ItajaServerError`
|
|
339
|
+
(5xx), `ItajaConnectionError`, `ItajaSignatureError`, `ItajaConfigError`. No error
|
|
340
|
+
ever contains your key.
|
|
341
|
+
|
|
342
|
+
Every write carries an `Idempotency-Key`, one per call and reused on its retries,
|
|
343
|
+
so a retried write never runs twice. Network errors, `5xx`, `429` and
|
|
344
|
+
`409 IDEMPOTENCY_KEY_IN_PROGRESS` are retried twice, honouring `Retry-After`. Pass
|
|
345
|
+
`idempotency_key=` (an order id, say) to make **your own** retries safe too.
|
|
346
|
+
|
|
347
|
+
## Configuration
|
|
348
|
+
|
|
349
|
+
Every argument of `create_itaja`, and the variable it falls back to:
|
|
350
|
+
|
|
351
|
+
| Argument | Environment | Default |
|
|
352
|
+
|---|---|---|
|
|
353
|
+
| `api_key` | `ITAJA_API_KEY` | — |
|
|
354
|
+
| `webhook_secret` | `ITAJA_WEBHOOK_SECRET` | — (only the webhook route needs it) |
|
|
355
|
+
| `base_url` | `ITAJA_API_URL` | `https://api.getitaja.com/api/v1` |
|
|
356
|
+
| `timeout` / `max_retries` | — | `30` / `2` |
|
|
357
|
+
| `dedupe` | — | in memory, 24 h |
|
|
358
|
+
|
|
359
|
+
There is no `environment` argument: the key's prefix decides sandbox or live.
|
|
360
|
+
|
|
361
|
+
## Development
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
uv sync
|
|
365
|
+
uv run pytest
|
|
366
|
+
uv run mypy
|
|
367
|
+
uv run ruff check
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
`spec/catalog.schema.json` and `spec/signature-vectors.json` (repository root) are
|
|
371
|
+
shared with every Itaja SDK: this package builds, field for field, the catalogue
|
|
372
|
+
the TypeScript SDK builds (`spec/conformance/reference-catalog.json`), and accepts
|
|
373
|
+
every webhook the backend's own signer produced.
|
|
374
|
+
|
|
375
|
+
## License
|
|
376
|
+
|
|
377
|
+
MIT
|