outerjoyn 2.0.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.
Files changed (45) hide show
  1. outerjoyn-2.0.0/CHANGELOG.md +109 -0
  2. outerjoyn-2.0.0/MANIFEST.in +3 -0
  3. outerjoyn-2.0.0/PKG-INFO +273 -0
  4. outerjoyn-2.0.0/README.md +257 -0
  5. outerjoyn-2.0.0/outerjoyn/__init__.py +24 -0
  6. outerjoyn-2.0.0/outerjoyn/_generated/__init__.py +1 -0
  7. outerjoyn-2.0.0/outerjoyn/_generated/models.py +1303 -0
  8. outerjoyn-2.0.0/outerjoyn/_http.py +119 -0
  9. outerjoyn-2.0.0/outerjoyn/client.py +154 -0
  10. outerjoyn-2.0.0/outerjoyn/errors.py +107 -0
  11. outerjoyn-2.0.0/outerjoyn/models.py +63 -0
  12. outerjoyn-2.0.0/outerjoyn/py.typed +0 -0
  13. outerjoyn-2.0.0/outerjoyn/resources/__init__.py +0 -0
  14. outerjoyn-2.0.0/outerjoyn/resources/accounts.py +71 -0
  15. outerjoyn-2.0.0/outerjoyn/resources/api_keys.py +63 -0
  16. outerjoyn-2.0.0/outerjoyn/resources/approvals.py +65 -0
  17. outerjoyn-2.0.0/outerjoyn/resources/balance.py +24 -0
  18. outerjoyn-2.0.0/outerjoyn/resources/batch.py +42 -0
  19. outerjoyn-2.0.0/outerjoyn/resources/catalog.py +44 -0
  20. outerjoyn-2.0.0/outerjoyn/resources/changes.py +41 -0
  21. outerjoyn-2.0.0/outerjoyn/resources/contacts.py +31 -0
  22. outerjoyn-2.0.0/outerjoyn/resources/contracts.py +57 -0
  23. outerjoyn-2.0.0/outerjoyn/resources/earn.py +63 -0
  24. outerjoyn-2.0.0/outerjoyn/resources/exchange.py +88 -0
  25. outerjoyn-2.0.0/outerjoyn/resources/exports.py +50 -0
  26. outerjoyn-2.0.0/outerjoyn/resources/fraud_rules.py +181 -0
  27. outerjoyn-2.0.0/outerjoyn/resources/ingest.py +40 -0
  28. outerjoyn-2.0.0/outerjoyn/resources/integrations.py +139 -0
  29. outerjoyn-2.0.0/outerjoyn/resources/members.py +55 -0
  30. outerjoyn-2.0.0/outerjoyn/resources/objects.py +96 -0
  31. outerjoyn-2.0.0/outerjoyn/resources/partnerships.py +326 -0
  32. outerjoyn-2.0.0/outerjoyn/resources/pay.py +199 -0
  33. outerjoyn-2.0.0/outerjoyn/resources/promotions.py +42 -0
  34. outerjoyn-2.0.0/outerjoyn/resources/redeem.py +39 -0
  35. outerjoyn-2.0.0/outerjoyn/resources/sessions.py +43 -0
  36. outerjoyn-2.0.0/outerjoyn/resources/subscriptions.py +92 -0
  37. outerjoyn-2.0.0/outerjoyn/resources/tasks.py +156 -0
  38. outerjoyn-2.0.0/outerjoyn/webhooks.py +186 -0
  39. outerjoyn-2.0.0/outerjoyn.egg-info/PKG-INFO +273 -0
  40. outerjoyn-2.0.0/outerjoyn.egg-info/SOURCES.txt +43 -0
  41. outerjoyn-2.0.0/outerjoyn.egg-info/dependency_links.txt +1 -0
  42. outerjoyn-2.0.0/outerjoyn.egg-info/requires.txt +7 -0
  43. outerjoyn-2.0.0/outerjoyn.egg-info/top_level.txt +1 -0
  44. outerjoyn-2.0.0/pyproject.toml +38 -0
  45. outerjoyn-2.0.0/setup.cfg +4 -0
@@ -0,0 +1,109 @@
1
+ # Changelog
2
+
3
+ ## 2.0.0 — 2026-10-20
4
+
5
+ ### Changed (breaking)
6
+ - **A 403 is a permission error, not an authentication error.** The API now
7
+ labels a refusal of a known caller (a missing scope, another brand's record, a
8
+ rule that refuses the request) with `type: "permission_error"`, and the SDK
9
+ raises `PermissionDeniedError` for it. `401` is still the authentication error.
10
+
11
+ ### Changed
12
+ - **Cleaner package.** Docstrings, comments and type descriptions no longer
13
+ carry internal project references, file paths or server names, and the source
14
+ distribution no longer includes the test suite.
15
+ - **Ingested purchases earn.** `ingest.transactions()` records now run your
16
+ earning workflows once the batch is written, like a live purchase. A record
17
+ that states its `points` is history (already paid, e.g. from a previous
18
+ loyalty system) and earns nothing again.
19
+
20
+ ### Changed (breaking)
21
+ - **`members.list()` answers in the standard list shape**: rows are `data`
22
+ (was `members`), the next-page cursor is `next_cursor` (was `nextCursor`), and
23
+ `has_more` is new. `total`, `limit` and `offset` are unchanged.
24
+ - **Partnership, partnership-member, fraud-rule and ledger lists use the standard
25
+ list shape** `{data, total, limit, offset, has_more}`, like every other list.
26
+ `partnerships.list()` returned `{partnerships, pagination}`,
27
+ `partnerships.members()` returned `{partnershipId, members, pagination}`,
28
+ `fraud_rules.list()` returned `{rules, ...}` and `GET /api/v1/external/ledger`
29
+ returned `{ledger, pagination}`. Read `data` and `has_more` instead.
30
+ (`partnerships.list()` has no `total`.)
31
+
32
+ ### Added
33
+ - **`pay.refund_by_order()`**: reverse an order later (a return, or a
34
+ cancellation of an order you already confirmed) with your API key. `refund()`
35
+ and `cancel()` need the checkout's session token, which a server does not
36
+ have days later. `merchant_refund_id` is required and is the idempotency key;
37
+ `kind` is `"return"` or `"cancellation"`; omit `amount_cents` to refund
38
+ everything still refundable. Matches the Node SDK's `pay.refundByOrder()`.
39
+
40
+ ## 1.8.0 — 2026-08-29
41
+
42
+ ### Added
43
+ - **`pay.checkout()` accepts the order breakdown**: `merchandise_subtotal_cents`,
44
+ `tax_cents`, `shipping_cents`. These were previously impossible to send from
45
+ this SDK — the fields exist in the API and in the generated models, but were
46
+ missing from the method signature, so the body builder dropped them.
47
+
48
+ They determine the **redeemable basis**: how much of an order points may
49
+ cover. Which of them the server *requires* is a per-storefront setting a
50
+ retailer can change with no code change on your side, so send all three on
51
+ every checkout:
52
+
53
+ ```python
54
+ client.pay.checkout(
55
+ session_token=token,
56
+ merchant_order_id="order-789",
57
+ order_total_cents=5000,
58
+ merchandise_subtotal_cents=4000,
59
+ tax_cents=1000,
60
+ shipping_cents=0, # free shipping — a value, not an omission
61
+ currency_code="USD",
62
+ reservations=[...],
63
+ )
64
+ ```
65
+
66
+ **Upgrade note.** If your integration omits these, checkouts against a
67
+ storefront whose basis includes tax or shipping fail with `422`
68
+ (`shipping_amount_required` / `tax_amount_required` /
69
+ `merchandise_subtotal_required`). `0` and *absent* are deliberately different:
70
+ `0` reports "nothing charged for this component", while an omitted field
71
+ reports "this checkout cannot say" and is rejected — so an unreported
72
+ component can never silently under-report the basis. The corollary: do **not**
73
+ pass `0` for a figure you simply don't have.
74
+
75
+ Note these are sent with an `is not None` check, not a truthiness check, so a
76
+ legitimate `0` reaches the wire.
77
+
78
+ ## 1.7.0 — 2026-07-25
79
+
80
+ Cleanup. Removes a request header the server never
81
+ read, and closes SDK parity gaps with the Node SDK.
82
+
83
+ ### Removed
84
+ - **`X-API-Version` request header**: every request previously sent
85
+ `X-API-Version: 2026-03-29`, but no OuterJoyn service read it — API versioning
86
+ is path-based (`/api/v1/...`), not header/date-based. The header is gone. This
87
+ is observable to any integrator that logs outbound request headers, hence the
88
+ minor-version bump. No dated-versioning contract is implied by its prior
89
+ presence; if per-key version pinning is ever introduced it will ship as its own
90
+ documented feature.
91
+
92
+ ### Added
93
+ - **`balance.get_account_overview(session_token)`**: returns the full account
94
+ overview (profile, connected brands, currencies, recent activity) — parity with
95
+ the Node SDK's `balance.getAccountOverview`.
96
+
97
+ ### Fixed
98
+ - **`members.list()` parameters matched the server**: the previous `page`
99
+ parameter was silently ignored (the server reads `limit`, `offset`, `cursor`,
100
+ `email`). `list()` now supports cursor pagination via `cursor` (the `nextCursor`
101
+ from the previous page), with `offset` kept for back-compat.
102
+ - **`members.get_transactions()` uses `offset`**: the previous `page` parameter
103
+ was ignored by the server (which paginates by `limit`/`offset`).
104
+ - **`exchange.execute()` / `exchange.preview()` accept string `member_id`**:
105
+ `member_id` was typed `Optional[int]`; it is now `Optional[Union[str, int]]` so a
106
+ snowflake id can be passed as a string (matching every other id parameter).
107
+ - **Checkout `pointValueCents` → `pointValueAmount`** (README example): the checkout
108
+ reservation field is `pointValueAmount` on the wire; the old `pointValueCents`
109
+ name failed with `400 INVALID_POINT_VALUE`.
@@ -0,0 +1,3 @@
1
+ include README.md CHANGELOG.md
2
+ prune tests
3
+ exclude Makefile
@@ -0,0 +1,273 @@
1
+ Metadata-Version: 2.4
2
+ Name: outerjoyn
3
+ Version: 2.0.0
4
+ Summary: OuterJoyn Python SDK for loyalty point payments and rewards
5
+ License: MIT
6
+ Project-URL: Homepage, https://docs.outerjoyn.com
7
+ Project-URL: Documentation, https://docs.outerjoyn.com/sdk
8
+ Project-URL: Repository, https://github.com/outerjoyn/outerjoyn-python
9
+ Keywords: outerjoyn,loyalty,payments,points,sdk
10
+ Requires-Python: >=3.8
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: httpx>=0.24.0
13
+ Requires-Dist: typing_extensions>=4.0.0; python_version < "3.11"
14
+ Provides-Extra: dev
15
+ Requires-Dist: datamodel-code-generator==0.64.1; extra == "dev"
16
+
17
+ # outerjoyn
18
+
19
+ OuterJoyn Python SDK for loyalty point payments and rewards.
20
+
21
+ > **PARKED — not published to PyPI (internal only).** This package is
22
+ > code-complete and version-matched to `@outerjoyn/sdk` (node), but it is
23
+ > **not** published: `pip install outerjoyn` does **not** work (the project
24
+ > does not exist on PyPI). It ships no customer-facing surface — no PyPI page,
25
+ > no docs, no nav entry — so customers cannot see it. It lives here for OuterJoyn
26
+ > devs and is kept building by the `python-sdk` CI drift-gate (`sdk-ci.yml`).
27
+ >
28
+ > **To publish it** (when there are Python integrators): mint an account-scoped
29
+ > PyPI API token, add it as the `PYPI_TOKEN` repo secret, then run the
30
+ > **Publish SDK packages** workflow with `publish_python=true` (`dry_run=true`
31
+ > first to validate, then `dry_run=false`). The publish job is
32
+ > `publish-python` in `.github/workflows/publish-sdk-packages.yml`. Once
33
+ > published, delete this banner and the `pip install outerjoyn` line below works.
34
+
35
+ ## Installation
36
+
37
+ _Not yet on PyPI (see the parked note above). Once published:_
38
+
39
+ ```bash
40
+ pip install outerjoyn
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```python
46
+ import outerjoyn
47
+
48
+ client = outerjoyn.Client(api_key="oj_test_...")
49
+
50
+ # Confirm who your key is first — business unit, scopes, test-vs-live realm.
51
+ # (GET /api/v1/me — the natural first call for a new credential.)
52
+ me = client.me()
53
+ print(me["business_unit_id"], me["is_test"], me["scopes"]["effective"])
54
+
55
+ # Create a session (server-side)
56
+ session = client.sessions.create(
57
+ partner_member_id="user_123",
58
+ scopes=["balance:read", "pay", "earn"],
59
+ ttl_minutes=30,
60
+ )
61
+ print(session["token"]) # ojsdk_test_...
62
+
63
+ # Check balance
64
+ balance = client.pay.get_balances(session["token"])
65
+ print(balance["balances"])
66
+
67
+ # Two-phase pay flow
68
+ reservation = client.pay.checkout(
69
+ session_token=session["token"],
70
+ merchant_order_id="order_456",
71
+ order_total_cents=5000,
72
+ currency_code="USD",
73
+ reservations=[{
74
+ "memberCurrencyId": 1,
75
+ "currencyId": 1,
76
+ "pointsAmount": 5000,
77
+ "pointValueAmount": 50,
78
+ "platformFeeCents": 0,
79
+ }],
80
+ )
81
+
82
+ # Confirm after merchant processes the order
83
+ client.pay.confirm(
84
+ session_token=session["token"],
85
+ redemption_token=reservation["reservations"][0]["redemptionToken"],
86
+ )
87
+ ```
88
+
89
+ ## Webhook Verification
90
+
91
+ The platform signs every outbound webhook with **HMAC-SHA256 over the raw request
92
+ body** and sends the hex digest in a `sha256=<hex>`-prefixed header — **no
93
+ timestamp**. Event subscriptions send `X-OuterJoyn-Signature`; CDC / change feeds
94
+ send `X-CDC-Signature`. `construct_event` / `verify_signature` accept either.
95
+
96
+ The `secret` is the `signing_secret` returned **once** from
97
+ `subscriptions.create` / `rotate_secret` — an opaque token (no `whsec_` prefix on
98
+ this platform). Verify the **raw bytes** (`request.get_data(as_text=True)`), never
99
+ a re-serialized JSON string — key reordering would break the HMAC.
100
+
101
+ ```python
102
+ from flask import Flask, request
103
+ import outerjoyn
104
+
105
+ app = Flask(__name__)
106
+ webhook = outerjoyn.Webhook()
107
+
108
+ @app.route("/webhooks/outerjoyn", methods=["POST"])
109
+ def handle_webhook():
110
+ # Event deliveries use X-OuterJoyn-Signature; CDC deliveries use X-CDC-Signature.
111
+ sig = request.headers.get("X-OuterJoyn-Signature") or request.headers.get(
112
+ "X-CDC-Signature"
113
+ )
114
+ try:
115
+ event = webhook.construct_event(
116
+ payload=request.get_data(as_text=True),
117
+ signature_header=sig,
118
+ secret=signing_secret, # from subscriptions.create / rotate_secret
119
+ )
120
+ except ValueError as e:
121
+ return str(e), 400
122
+
123
+ if event["type"] == "member.created":
124
+ print("New member:", event["data"])
125
+ elif event["type"] == "transaction.created":
126
+ print("New transaction:", event["data"])
127
+
128
+ return "", 200
129
+ ```
130
+
131
+ ## Error Handling
132
+
133
+ ```python
134
+ import outerjoyn
135
+
136
+ client = outerjoyn.Client(api_key="oj_test_...")
137
+
138
+ try:
139
+ client.pay.checkout(...)
140
+ except outerjoyn.AuthenticationError as e:
141
+ print(f"Auth failed: {e} (code: {e.code})")
142
+ except outerjoyn.RateLimitError as e:
143
+ print(f"Rate limited. Retry after {e.retry_after}s")
144
+ except outerjoyn.InvalidRequestError as e:
145
+ print(f"Bad request: {e} (param: {e.param}, request_id: {e.request_id})")
146
+ except outerjoyn.APIError as e:
147
+ print(f"Server error: {e}")
148
+ ```
149
+
150
+ ## Resources
151
+
152
+ | Resource | Methods |
153
+ |----------|---------|
154
+ | `client.me()` | key introspection — `GET /api/v1/me` (BU, scopes, `is_test`) |
155
+ | `client.sessions` | `create()`, `revoke()`, `get_context()` |
156
+ | `client.pay` | `get_balances()`, `checkout()`, `confirm()`, `cancel()`, `refund()`, `get_session()` |
157
+ | `client.earn` | `submit()`, `get_status()` |
158
+ | `client.balance` | `get()` |
159
+ | `client.catalog` | `list()`, `get()` |
160
+ | `client.redeem` | `create()` |
161
+ | `client.exchange` | `execute()`, `preview()`, `get_rates()`, `get_status()` |
162
+ | `client.members` | `list()`, `get()`, `get_transactions()` |
163
+ | `client.promotions` | `get_array()`, `get()`, `enroll()`, `get_enrollment_status()` |
164
+ | `client.partnerships` | `propose()`, `platform()`, `accept()`, `activate()`, `fund()`, `set_rates()`, `get()`, `list()`, `members()`, `set_match_key()`, `list_tier_mappings()`, `create_tier_mapping()`, `update_tier_mapping()`, `list_benefit_mappings()`, `create_benefit_mapping()`, `update_benefit_mapping()`, `list_liability_deals()`, `create_liability_deal()` |
165
+ | `client.tasks` | `list()`, `complete()`, `set_status()`, `configure_integration()`, `create_landing_page()`, `list_payment_terms()`, `update_payment_term()` |
166
+ | `client.contracts` | `list()`, `create()`, `transition_status()` |
167
+ | `client.api_keys` | `list()`, `get()`, `create()`, `rotate()`, `revoke()` |
168
+ | `client.contacts` | `merge()` |
169
+ | `client.accounts` | `hierarchy()`, `set_parent()`, `clear_parent()`, `get_membership()`, `enroll_membership()`, `assign_contact()`, `unassign_contact()` |
170
+ | `client.fraud_rules` | `list()`, `create()`, `update()`, `remove()` |
171
+ | `client.objects` | `list_objects()`, `describe()`, `list()`, `get()`, `create()`, `update()`, `remove()` |
172
+ | `client.ingest` | `members()`, `transactions()`, `batch()` |
173
+ | `client.approvals` | `approve()`, `reject()` |
174
+ | `client.integrations` | `list_connections()`, `get_connection()`, `create_connection()`, `update_connection()`, `set_connection_secret()`, `delete_connection()`, `list()`, `get()`, `create()`, `update()`, `delete()`, `run()`, `runs()` |
175
+ | `client.subscriptions` | `list()`, `get()`, `create()`, `update()`, `delete()`, `rotate_secret()`, `deliveries()` |
176
+ | `client.exports` | `create()`, `get()` |
177
+ | `client.webhooks` | `construct_event()`, `verify_signature()`, `generate_test_header()` |
178
+
179
+ ### Zero to live: the partnership journey
180
+
181
+ ```python
182
+ import outerjoyn
183
+
184
+ client = outerjoyn.Client(api_key="oj_test_your_api_key")
185
+
186
+ # 1. Propose. partner_brand_id is the brand you're proposing toward. On a
187
+ # multi-brand BU, pass your own brand_id too (else 422 proposer_brand_ambiguous);
188
+ # single-brand BUs may omit it (your brand resolves from the key).
189
+ proposal = client.partnerships.propose(
190
+ brand_id="43595573", # YOUR brand — required on multi-brand BUs
191
+ partner_brand_id="88070021",
192
+ use_case="pay_with_points",
193
+ )
194
+
195
+ # 2. Accept (the counterpart side) → the shared Partnership is created
196
+ partnership = client.partnerships.accept(proposal["partnership_request_id"])
197
+ partnership_id = partnership["partnership_id"]
198
+
199
+ # 3. Activate the use case
200
+ client.partnerships.activate(partnership_id, use_case="pay_with_points")
201
+
202
+ # 4. Fund the pool — idempotency_key is REQUIRED and sent as the canonical
203
+ # Idempotency-Key header, so a retry never double-funds.
204
+ client.partnerships.fund(
205
+ partnership_id,
206
+ points=100_000,
207
+ currency_id="77120034",
208
+ idempotency_key="fund-2026-q3-001",
209
+ )
210
+
211
+ # 5. Set the exchange rate (omit rate to derive from USD pegs)
212
+ client.partnerships.set_rates(
213
+ partnership_id,
214
+ source_currency_id="77120034",
215
+ target_currency_id="77120099",
216
+ rate=0.5,
217
+ )
218
+
219
+ # 6. Confirm readiness — you're live
220
+ detail = client.partnerships.get(partnership_id)
221
+ ```
222
+
223
+ ### Objects: describe + the generic escape hatch
224
+
225
+ The designed resources (`client.members`, `client.partnerships`, …) stay the
226
+ first-class path — they carry ergonomic args + typed responses. `client.objects`
227
+ is the long-tail fallback for any exposable object that has no designed resource.
228
+
229
+ ```python
230
+ # The catalog of exposable objects, and one object's field-level schema
231
+ catalog = client.objects.list_objects()
232
+ schema = client.objects.describe("members")
233
+
234
+ # Generic keyset-paginated read of any exposable object
235
+ page = client.objects.list("brands", limit=50)
236
+ one = client.objects.get("brands", "43595573")
237
+
238
+ # Writes hit writable objects only (others → 403 write_denied). Bodies are sent
239
+ # as-is (wire casing is the caller's responsibility on this un-modeled hatch).
240
+ client.objects.create("brands", {"name": "Acme"})
241
+ ```
242
+
243
+ ### Fraud rules
244
+
245
+ Fraud rule CRUD (`fraud:read` / `fraud:write`). Scope with `brand_ids` only
246
+ (`[]`/omitted = a whole-business-unit rule); the list envelope is offset-paged
247
+ (`{rules, total, ...}`), the write envelope is `{data}`.
248
+
249
+ ```python
250
+ created = client.fraud_rules.create(
251
+ rule_name="velocity-guard",
252
+ rule_type="velocity",
253
+ threshold_count=5,
254
+ time_window_minutes=10,
255
+ action="flag",
256
+ )
257
+ client.fraud_rules.update(created["data"]["fraud_rule_id"], is_enabled=False)
258
+ ```
259
+
260
+ ## Configuration
261
+
262
+ ```python
263
+ client = outerjoyn.Client(
264
+ api_key="oj_test_...", # Required
265
+ base_url="https://...", # Default: https://api.outerjoyn.com
266
+ timeout=30.0, # Seconds (default: 30)
267
+ max_retries=2, # GET retry count (default: 2)
268
+ )
269
+
270
+ # Context manager for automatic cleanup
271
+ with outerjoyn.Client(api_key="oj_test_...") as client:
272
+ session = client.sessions.create(partner_member_id="user_123")
273
+ ```
@@ -0,0 +1,257 @@
1
+ # outerjoyn
2
+
3
+ OuterJoyn Python SDK for loyalty point payments and rewards.
4
+
5
+ > **PARKED — not published to PyPI (internal only).** This package is
6
+ > code-complete and version-matched to `@outerjoyn/sdk` (node), but it is
7
+ > **not** published: `pip install outerjoyn` does **not** work (the project
8
+ > does not exist on PyPI). It ships no customer-facing surface — no PyPI page,
9
+ > no docs, no nav entry — so customers cannot see it. It lives here for OuterJoyn
10
+ > devs and is kept building by the `python-sdk` CI drift-gate (`sdk-ci.yml`).
11
+ >
12
+ > **To publish it** (when there are Python integrators): mint an account-scoped
13
+ > PyPI API token, add it as the `PYPI_TOKEN` repo secret, then run the
14
+ > **Publish SDK packages** workflow with `publish_python=true` (`dry_run=true`
15
+ > first to validate, then `dry_run=false`). The publish job is
16
+ > `publish-python` in `.github/workflows/publish-sdk-packages.yml`. Once
17
+ > published, delete this banner and the `pip install outerjoyn` line below works.
18
+
19
+ ## Installation
20
+
21
+ _Not yet on PyPI (see the parked note above). Once published:_
22
+
23
+ ```bash
24
+ pip install outerjoyn
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```python
30
+ import outerjoyn
31
+
32
+ client = outerjoyn.Client(api_key="oj_test_...")
33
+
34
+ # Confirm who your key is first — business unit, scopes, test-vs-live realm.
35
+ # (GET /api/v1/me — the natural first call for a new credential.)
36
+ me = client.me()
37
+ print(me["business_unit_id"], me["is_test"], me["scopes"]["effective"])
38
+
39
+ # Create a session (server-side)
40
+ session = client.sessions.create(
41
+ partner_member_id="user_123",
42
+ scopes=["balance:read", "pay", "earn"],
43
+ ttl_minutes=30,
44
+ )
45
+ print(session["token"]) # ojsdk_test_...
46
+
47
+ # Check balance
48
+ balance = client.pay.get_balances(session["token"])
49
+ print(balance["balances"])
50
+
51
+ # Two-phase pay flow
52
+ reservation = client.pay.checkout(
53
+ session_token=session["token"],
54
+ merchant_order_id="order_456",
55
+ order_total_cents=5000,
56
+ currency_code="USD",
57
+ reservations=[{
58
+ "memberCurrencyId": 1,
59
+ "currencyId": 1,
60
+ "pointsAmount": 5000,
61
+ "pointValueAmount": 50,
62
+ "platformFeeCents": 0,
63
+ }],
64
+ )
65
+
66
+ # Confirm after merchant processes the order
67
+ client.pay.confirm(
68
+ session_token=session["token"],
69
+ redemption_token=reservation["reservations"][0]["redemptionToken"],
70
+ )
71
+ ```
72
+
73
+ ## Webhook Verification
74
+
75
+ The platform signs every outbound webhook with **HMAC-SHA256 over the raw request
76
+ body** and sends the hex digest in a `sha256=<hex>`-prefixed header — **no
77
+ timestamp**. Event subscriptions send `X-OuterJoyn-Signature`; CDC / change feeds
78
+ send `X-CDC-Signature`. `construct_event` / `verify_signature` accept either.
79
+
80
+ The `secret` is the `signing_secret` returned **once** from
81
+ `subscriptions.create` / `rotate_secret` — an opaque token (no `whsec_` prefix on
82
+ this platform). Verify the **raw bytes** (`request.get_data(as_text=True)`), never
83
+ a re-serialized JSON string — key reordering would break the HMAC.
84
+
85
+ ```python
86
+ from flask import Flask, request
87
+ import outerjoyn
88
+
89
+ app = Flask(__name__)
90
+ webhook = outerjoyn.Webhook()
91
+
92
+ @app.route("/webhooks/outerjoyn", methods=["POST"])
93
+ def handle_webhook():
94
+ # Event deliveries use X-OuterJoyn-Signature; CDC deliveries use X-CDC-Signature.
95
+ sig = request.headers.get("X-OuterJoyn-Signature") or request.headers.get(
96
+ "X-CDC-Signature"
97
+ )
98
+ try:
99
+ event = webhook.construct_event(
100
+ payload=request.get_data(as_text=True),
101
+ signature_header=sig,
102
+ secret=signing_secret, # from subscriptions.create / rotate_secret
103
+ )
104
+ except ValueError as e:
105
+ return str(e), 400
106
+
107
+ if event["type"] == "member.created":
108
+ print("New member:", event["data"])
109
+ elif event["type"] == "transaction.created":
110
+ print("New transaction:", event["data"])
111
+
112
+ return "", 200
113
+ ```
114
+
115
+ ## Error Handling
116
+
117
+ ```python
118
+ import outerjoyn
119
+
120
+ client = outerjoyn.Client(api_key="oj_test_...")
121
+
122
+ try:
123
+ client.pay.checkout(...)
124
+ except outerjoyn.AuthenticationError as e:
125
+ print(f"Auth failed: {e} (code: {e.code})")
126
+ except outerjoyn.RateLimitError as e:
127
+ print(f"Rate limited. Retry after {e.retry_after}s")
128
+ except outerjoyn.InvalidRequestError as e:
129
+ print(f"Bad request: {e} (param: {e.param}, request_id: {e.request_id})")
130
+ except outerjoyn.APIError as e:
131
+ print(f"Server error: {e}")
132
+ ```
133
+
134
+ ## Resources
135
+
136
+ | Resource | Methods |
137
+ |----------|---------|
138
+ | `client.me()` | key introspection — `GET /api/v1/me` (BU, scopes, `is_test`) |
139
+ | `client.sessions` | `create()`, `revoke()`, `get_context()` |
140
+ | `client.pay` | `get_balances()`, `checkout()`, `confirm()`, `cancel()`, `refund()`, `get_session()` |
141
+ | `client.earn` | `submit()`, `get_status()` |
142
+ | `client.balance` | `get()` |
143
+ | `client.catalog` | `list()`, `get()` |
144
+ | `client.redeem` | `create()` |
145
+ | `client.exchange` | `execute()`, `preview()`, `get_rates()`, `get_status()` |
146
+ | `client.members` | `list()`, `get()`, `get_transactions()` |
147
+ | `client.promotions` | `get_array()`, `get()`, `enroll()`, `get_enrollment_status()` |
148
+ | `client.partnerships` | `propose()`, `platform()`, `accept()`, `activate()`, `fund()`, `set_rates()`, `get()`, `list()`, `members()`, `set_match_key()`, `list_tier_mappings()`, `create_tier_mapping()`, `update_tier_mapping()`, `list_benefit_mappings()`, `create_benefit_mapping()`, `update_benefit_mapping()`, `list_liability_deals()`, `create_liability_deal()` |
149
+ | `client.tasks` | `list()`, `complete()`, `set_status()`, `configure_integration()`, `create_landing_page()`, `list_payment_terms()`, `update_payment_term()` |
150
+ | `client.contracts` | `list()`, `create()`, `transition_status()` |
151
+ | `client.api_keys` | `list()`, `get()`, `create()`, `rotate()`, `revoke()` |
152
+ | `client.contacts` | `merge()` |
153
+ | `client.accounts` | `hierarchy()`, `set_parent()`, `clear_parent()`, `get_membership()`, `enroll_membership()`, `assign_contact()`, `unassign_contact()` |
154
+ | `client.fraud_rules` | `list()`, `create()`, `update()`, `remove()` |
155
+ | `client.objects` | `list_objects()`, `describe()`, `list()`, `get()`, `create()`, `update()`, `remove()` |
156
+ | `client.ingest` | `members()`, `transactions()`, `batch()` |
157
+ | `client.approvals` | `approve()`, `reject()` |
158
+ | `client.integrations` | `list_connections()`, `get_connection()`, `create_connection()`, `update_connection()`, `set_connection_secret()`, `delete_connection()`, `list()`, `get()`, `create()`, `update()`, `delete()`, `run()`, `runs()` |
159
+ | `client.subscriptions` | `list()`, `get()`, `create()`, `update()`, `delete()`, `rotate_secret()`, `deliveries()` |
160
+ | `client.exports` | `create()`, `get()` |
161
+ | `client.webhooks` | `construct_event()`, `verify_signature()`, `generate_test_header()` |
162
+
163
+ ### Zero to live: the partnership journey
164
+
165
+ ```python
166
+ import outerjoyn
167
+
168
+ client = outerjoyn.Client(api_key="oj_test_your_api_key")
169
+
170
+ # 1. Propose. partner_brand_id is the brand you're proposing toward. On a
171
+ # multi-brand BU, pass your own brand_id too (else 422 proposer_brand_ambiguous);
172
+ # single-brand BUs may omit it (your brand resolves from the key).
173
+ proposal = client.partnerships.propose(
174
+ brand_id="43595573", # YOUR brand — required on multi-brand BUs
175
+ partner_brand_id="88070021",
176
+ use_case="pay_with_points",
177
+ )
178
+
179
+ # 2. Accept (the counterpart side) → the shared Partnership is created
180
+ partnership = client.partnerships.accept(proposal["partnership_request_id"])
181
+ partnership_id = partnership["partnership_id"]
182
+
183
+ # 3. Activate the use case
184
+ client.partnerships.activate(partnership_id, use_case="pay_with_points")
185
+
186
+ # 4. Fund the pool — idempotency_key is REQUIRED and sent as the canonical
187
+ # Idempotency-Key header, so a retry never double-funds.
188
+ client.partnerships.fund(
189
+ partnership_id,
190
+ points=100_000,
191
+ currency_id="77120034",
192
+ idempotency_key="fund-2026-q3-001",
193
+ )
194
+
195
+ # 5. Set the exchange rate (omit rate to derive from USD pegs)
196
+ client.partnerships.set_rates(
197
+ partnership_id,
198
+ source_currency_id="77120034",
199
+ target_currency_id="77120099",
200
+ rate=0.5,
201
+ )
202
+
203
+ # 6. Confirm readiness — you're live
204
+ detail = client.partnerships.get(partnership_id)
205
+ ```
206
+
207
+ ### Objects: describe + the generic escape hatch
208
+
209
+ The designed resources (`client.members`, `client.partnerships`, …) stay the
210
+ first-class path — they carry ergonomic args + typed responses. `client.objects`
211
+ is the long-tail fallback for any exposable object that has no designed resource.
212
+
213
+ ```python
214
+ # The catalog of exposable objects, and one object's field-level schema
215
+ catalog = client.objects.list_objects()
216
+ schema = client.objects.describe("members")
217
+
218
+ # Generic keyset-paginated read of any exposable object
219
+ page = client.objects.list("brands", limit=50)
220
+ one = client.objects.get("brands", "43595573")
221
+
222
+ # Writes hit writable objects only (others → 403 write_denied). Bodies are sent
223
+ # as-is (wire casing is the caller's responsibility on this un-modeled hatch).
224
+ client.objects.create("brands", {"name": "Acme"})
225
+ ```
226
+
227
+ ### Fraud rules
228
+
229
+ Fraud rule CRUD (`fraud:read` / `fraud:write`). Scope with `brand_ids` only
230
+ (`[]`/omitted = a whole-business-unit rule); the list envelope is offset-paged
231
+ (`{rules, total, ...}`), the write envelope is `{data}`.
232
+
233
+ ```python
234
+ created = client.fraud_rules.create(
235
+ rule_name="velocity-guard",
236
+ rule_type="velocity",
237
+ threshold_count=5,
238
+ time_window_minutes=10,
239
+ action="flag",
240
+ )
241
+ client.fraud_rules.update(created["data"]["fraud_rule_id"], is_enabled=False)
242
+ ```
243
+
244
+ ## Configuration
245
+
246
+ ```python
247
+ client = outerjoyn.Client(
248
+ api_key="oj_test_...", # Required
249
+ base_url="https://...", # Default: https://api.outerjoyn.com
250
+ timeout=30.0, # Seconds (default: 30)
251
+ max_retries=2, # GET retry count (default: 2)
252
+ )
253
+
254
+ # Context manager for automatic cleanup
255
+ with outerjoyn.Client(api_key="oj_test_...") as client:
256
+ session = client.sessions.create(partner_member_id="user_123")
257
+ ```
@@ -0,0 +1,24 @@
1
+ """OuterJoyn Python SDK for loyalty point payments and rewards."""
2
+
3
+ from outerjoyn.client import Client
4
+ from outerjoyn.errors import (
5
+ OuterJoynError,
6
+ InvalidRequestError,
7
+ AuthenticationError,
8
+ PermissionDeniedError,
9
+ RateLimitError,
10
+ APIError,
11
+ )
12
+ from outerjoyn.webhooks import Webhook
13
+
14
+ __version__ = "2.0.0"
15
+ __all__ = [
16
+ "Client",
17
+ "Webhook",
18
+ "OuterJoynError",
19
+ "InvalidRequestError",
20
+ "AuthenticationError",
21
+ "PermissionDeniedError",
22
+ "RateLimitError",
23
+ "APIError",
24
+ ]
@@ -0,0 +1 @@
1
+ """Generated OpenAPI models — do not edit; run `make gen-types`. See ../models.py."""