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.
- outerjoyn-2.0.0/CHANGELOG.md +109 -0
- outerjoyn-2.0.0/MANIFEST.in +3 -0
- outerjoyn-2.0.0/PKG-INFO +273 -0
- outerjoyn-2.0.0/README.md +257 -0
- outerjoyn-2.0.0/outerjoyn/__init__.py +24 -0
- outerjoyn-2.0.0/outerjoyn/_generated/__init__.py +1 -0
- outerjoyn-2.0.0/outerjoyn/_generated/models.py +1303 -0
- outerjoyn-2.0.0/outerjoyn/_http.py +119 -0
- outerjoyn-2.0.0/outerjoyn/client.py +154 -0
- outerjoyn-2.0.0/outerjoyn/errors.py +107 -0
- outerjoyn-2.0.0/outerjoyn/models.py +63 -0
- outerjoyn-2.0.0/outerjoyn/py.typed +0 -0
- outerjoyn-2.0.0/outerjoyn/resources/__init__.py +0 -0
- outerjoyn-2.0.0/outerjoyn/resources/accounts.py +71 -0
- outerjoyn-2.0.0/outerjoyn/resources/api_keys.py +63 -0
- outerjoyn-2.0.0/outerjoyn/resources/approvals.py +65 -0
- outerjoyn-2.0.0/outerjoyn/resources/balance.py +24 -0
- outerjoyn-2.0.0/outerjoyn/resources/batch.py +42 -0
- outerjoyn-2.0.0/outerjoyn/resources/catalog.py +44 -0
- outerjoyn-2.0.0/outerjoyn/resources/changes.py +41 -0
- outerjoyn-2.0.0/outerjoyn/resources/contacts.py +31 -0
- outerjoyn-2.0.0/outerjoyn/resources/contracts.py +57 -0
- outerjoyn-2.0.0/outerjoyn/resources/earn.py +63 -0
- outerjoyn-2.0.0/outerjoyn/resources/exchange.py +88 -0
- outerjoyn-2.0.0/outerjoyn/resources/exports.py +50 -0
- outerjoyn-2.0.0/outerjoyn/resources/fraud_rules.py +181 -0
- outerjoyn-2.0.0/outerjoyn/resources/ingest.py +40 -0
- outerjoyn-2.0.0/outerjoyn/resources/integrations.py +139 -0
- outerjoyn-2.0.0/outerjoyn/resources/members.py +55 -0
- outerjoyn-2.0.0/outerjoyn/resources/objects.py +96 -0
- outerjoyn-2.0.0/outerjoyn/resources/partnerships.py +326 -0
- outerjoyn-2.0.0/outerjoyn/resources/pay.py +199 -0
- outerjoyn-2.0.0/outerjoyn/resources/promotions.py +42 -0
- outerjoyn-2.0.0/outerjoyn/resources/redeem.py +39 -0
- outerjoyn-2.0.0/outerjoyn/resources/sessions.py +43 -0
- outerjoyn-2.0.0/outerjoyn/resources/subscriptions.py +92 -0
- outerjoyn-2.0.0/outerjoyn/resources/tasks.py +156 -0
- outerjoyn-2.0.0/outerjoyn/webhooks.py +186 -0
- outerjoyn-2.0.0/outerjoyn.egg-info/PKG-INFO +273 -0
- outerjoyn-2.0.0/outerjoyn.egg-info/SOURCES.txt +43 -0
- outerjoyn-2.0.0/outerjoyn.egg-info/dependency_links.txt +1 -0
- outerjoyn-2.0.0/outerjoyn.egg-info/requires.txt +7 -0
- outerjoyn-2.0.0/outerjoyn.egg-info/top_level.txt +1 -0
- outerjoyn-2.0.0/pyproject.toml +38 -0
- 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`.
|
outerjoyn-2.0.0/PKG-INFO
ADDED
|
@@ -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."""
|