Metadata-Version: 2.4
Name: outerjoyn
Version: 6.0.0
Summary: OuterJoyn Python SDK for loyalty point payments and rewards
License: MIT
Project-URL: Homepage, https://docs.outerjoyn.com
Project-URL: Documentation, https://docs.outerjoyn.com/sdk
Project-URL: Repository, https://github.com/outerjoyn/outerjoyn-python
Keywords: outerjoyn,loyalty,payments,points,sdk
Requires-Python: >=3.8
Description-Content-Type: text/markdown
Requires-Dist: httpx>=0.24.0
Requires-Dist: typing_extensions>=4.0.0; python_version < "3.11"
Provides-Extra: dev
Requires-Dist: datamodel-code-generator==0.64.1; extra == "dev"

# outerjoyn

OuterJoyn Python SDK for loyalty point payments and rewards.

> **PARKED — not published to PyPI (internal only).** This package is
> code-complete and version-matched to `@outerjoyn/sdk` (node), but it is
> **not** published: `pip install outerjoyn` does **not** work (the project
> does not exist on PyPI). It ships no customer-facing surface — no PyPI page,
> no docs, no nav entry — so customers cannot see it. It lives here for OuterJoyn
> devs and is kept building by the `python-sdk` CI drift-gate (`sdk-ci.yml`).
>
> **To publish it** (when there are Python integrators): mint an account-scoped
> PyPI API token, add it as the `PYPI_TOKEN` repo secret, then run the
> **Publish SDK packages** workflow with `publish_python=true` (`dry_run=true`
> first to validate, then `dry_run=false`). The publish job is
> `publish-python` in `.github/workflows/publish-sdk-packages.yml`. Once
> published, delete this banner and the `pip install outerjoyn` line below works.

## Installation

_Not yet on PyPI (see the parked note above). Once published:_

```bash
pip install outerjoyn
```

## Quick Start

```python
import outerjoyn

client = outerjoyn.Client(api_key="oj_test_...")

# Confirm who your key is first — business unit, scopes, test-vs-live realm.
# (GET /api/v1/me — the natural first call for a new credential.)
me = client.me()
print(me["business_unit_id"], me["is_test"], me["scopes"]["effective"])

# Create a session (server-side)
session = client.sessions.create(
    partner_member_id="user_123",
    scopes=["balance:read", "pay", "earn"],
    ttl_minutes=30,
)
print(session["token"])  # ojsdk_test_...

# Check balance
balance = client.pay.get_balances(session["token"])
print(balance["balances"])

# Two-phase pay flow
reservation = client.pay.checkout(
    session_token=session["token"],
    merchant_order_id="order_456",
    order_total_cents=5000,
    currency_code="USD",
    reservations=[{
        "memberCurrencyId": 1,
        "currencyId": 1,
        "pointsAmount": 5000,
        "pointValueAmount": 50,
        "platformFeeCents": 0,
    }],
)

# Confirm after merchant processes the order
client.pay.confirm(
    session_token=session["token"],
    redemption_token=reservation["reservations"][0]["redemptionToken"],
)
```

## Webhook Verification

The platform signs every outbound webhook with **HMAC-SHA256 over the raw request
body** and sends the hex digest in a `sha256=<hex>`-prefixed header — **no
timestamp**, in `X-OuterJoyn-Signature` on every delivery (record changes, `cdc.*`,
included).

The `secret` is the `signing_secret` returned **once** from
`subscriptions.create` / `rotate_secret` — an opaque token (no `whsec_` prefix on
this platform). Verify the **raw bytes** (`request.get_data(as_text=True)`), never
a re-serialized JSON string — key reordering would break the HMAC.

```python
from flask import Flask, request
import outerjoyn

app = Flask(__name__)
webhook = outerjoyn.Webhook()

@app.route("/webhooks/outerjoyn", methods=["POST"])
def handle_webhook():
    sig = request.headers.get("X-OuterJoyn-Signature")
    try:
        event = webhook.construct_event(
            payload=request.get_data(as_text=True),
            signature_header=sig,
            secret=signing_secret,  # from subscriptions.create / rotate_secret
        )
    except ValueError as e:
        return str(e), 400

    if event["type"] == "member.created":
        print("New member:", event["data"])
    elif event["type"] == "transaction.created":
        print("New transaction:", event["data"])

    return "", 200
```

## Error Handling

```python
import outerjoyn

client = outerjoyn.Client(api_key="oj_test_...")

try:
    client.pay.checkout(...)
except outerjoyn.AuthenticationError as e:
    print(f"Auth failed: {e} (code: {e.code})")
except outerjoyn.RateLimitError as e:
    print(f"Rate limited. Retry after {e.retry_after}s")
except outerjoyn.InvalidRequestError as e:
    print(f"Bad request: {e} (param: {e.param}, request_id: {e.request_id})")
except outerjoyn.APIError as e:
    print(f"Server error: {e}")
```

## Resources

| Resource | Methods |
|----------|---------|
| `client.me()` | key introspection — `GET /api/v1/me` (BU, scopes, `is_test`) |
| `client.sessions` | `create()`, `revoke()`, `get_context()` |
| `client.pay` | `get_balances()`, `checkout()`, `confirm()`, `cancel()`, `refund()`, `get_session()` |
| `client.earn` | `submit()`, `get_status()` |
| `client.balance` | `get()` |
| `client.catalog` | `list()`, `get()` |
| `client.redeem` | `create()` |
| `client.exchange` | `execute()`, `preview()`, `get_rates()`, `get_status()` |
| `client.members` | `list()`, `get()`, `get_transactions()` |
| `client.promotions` | `get_array()`, `get()`, `enroll()`, `get_enrollment_status()` |
| `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()` |
| `client.tasks` | `list()`, `complete()`, `set_status()`, `configure_integration()`, `create_landing_page()`, `list_payment_terms()`, `update_payment_term()` |
| `client.contracts` | `list()`, `create()`, `transition_status()` |
| `client.api_keys` | `list()`, `get()`, `create()`, `rotate()`, `revoke()` |
| `client.contacts` | `merge()` |
| `client.accounts` | `hierarchy()`, `set_parent()`, `clear_parent()`, `get_membership()`, `enroll_membership()`, `assign_contact()`, `unassign_contact()` |
| `client.fraud_rules` | `list()`, `create()`, `update()`, `remove()` |
| `client.objects` | `list_objects()`, `describe()`, `list()`, `get()`, `create()`, `update()`, `remove()` |
| `client.ingest` | `members()`, `transactions()`, `batch()` |
| `client.approvals` | `approve()`, `reject()` |
| `client.integrations` | `list_connections()`, `get_connection()`, `create_connection()`, `update_connection()`, `set_connection_secret()`, `delete_connection()`, `list()`, `get()`, `create()`, `update()`, `delete()`, `run()`, `runs()` |
| `client.subscriptions` | `list()`, `get()`, `create()`, `update()`, `delete()`, `rotate_secret()`, `deliveries()` |
| `client.exports` | `create()`, `get()` |
| `client.webhooks` | `construct_event()`, `verify_signature()`, `generate_test_header()` |

### Zero to live: the partnership journey

```python
import outerjoyn

client = outerjoyn.Client(api_key="oj_test_your_api_key")

# 1. Propose. partner_brand_id is the brand you're proposing toward. On a
#    multi-brand BU, pass your own brand_id too (else 422 proposer_brand_ambiguous);
#    single-brand BUs may omit it (your brand resolves from the key).
proposal = client.partnerships.propose(
    brand_id="43595573",          # YOUR brand — required on multi-brand BUs
    partner_brand_id="88070021",
    use_case="pay_with_points",
)

# 2. Accept (the counterpart side) → the shared Partnership is created
partnership = client.partnerships.accept(proposal["partnership_request_id"])
partnership_id = partnership["partnership_id"]

# 3. Activate the use case
client.partnerships.activate(partnership_id, use_case="pay_with_points")

# 4. Fund the pool — idempotency_key is REQUIRED and sent as the canonical
#    Idempotency-Key header, so a retry never double-funds.
client.partnerships.fund(
    partnership_id,
    points=100_000,
    currency_id="77120034",
    idempotency_key="fund-2026-q3-001",
)

# 5. Set the exchange rate (omit rate to derive from USD pegs)
client.partnerships.set_rates(
    partnership_id,
    source_currency_id="77120034",
    target_currency_id="77120099",
    rate=0.5,
)

# 6. Confirm readiness — you're live
detail = client.partnerships.get(partnership_id)
```

### Objects: describe + the generic escape hatch

The designed resources (`client.members`, `client.partnerships`, …) stay the
first-class path — they carry ergonomic args + typed responses. `client.objects`
is the long-tail fallback for any exposable object that has no designed resource.

```python
# The catalog of exposable objects, and one object's field-level schema
catalog = client.objects.list_objects()
schema = client.objects.describe("members")

# Generic keyset-paginated read of any exposable object
page = client.objects.list("brands", limit=50)
one = client.objects.get("brands", "43595573")

# Writes hit writable objects only (others → 403 write_denied). Bodies are sent
# as-is (wire casing is the caller's responsibility on this un-modeled hatch).
client.objects.create("brands", {"name": "Acme"})
```

### Fraud rules

Fraud rule CRUD (`fraud:read` / `fraud:write`). Scope with `brand_ids` only
(`[]`/omitted = a whole-business-unit rule); the list envelope is offset-paged
(`{rules, total, ...}`), the write envelope is `{data}`.

```python
created = client.fraud_rules.create(
    rule_name="velocity-guard",
    rule_type="velocity",
    threshold_count=5,
    time_window_minutes=10,
    action="flag",
)
client.fraud_rules.update(created["data"]["fraud_rule_id"], is_enabled=False)
```

## Configuration

```python
client = outerjoyn.Client(
    api_key="oj_test_...",       # Required
    base_url="https://...",       # Default: https://api.outerjoyn.com
    timeout=30.0,                 # Seconds (default: 30)
    max_retries=2,                # GET retry count (default: 2)
)

# Context manager for automatic cleanup
with outerjoyn.Client(api_key="oj_test_...") as client:
    session = client.sessions.create(partner_member_id="user_123")
```
