# Partner API (machine-to-machine)

**Last updated:** 2026-09-03

Formal partner surface for SIS, ERP, treasury systems, and **OpenPayGB merchant charges**. Keys are issued from the **Developer dashboard** (`/developers/dashboard`) or **Master → OPGB console / Partner API**.

**Commands + logins:** [PLATFORM_UPDATE_2026-09.md](./PLATFORM_UPDATE_2026-09.md) · [LOCAL_DEV_AND_CREDENTIALS.md](./LOCAL_DEV_AND_CREDENTIALS.md)  
**Provider guide:** [OPENPAYGB_PAYMENT_PROVIDER.md](./OPENPAYGB_PAYMENT_PROVIDER.md)

## Authentication

Send the API key on every request:

```http
Authorization: Bearer odelhub_live_<secret>
```

Or:

```http
X-Api-Key: odelhub_live_<secret>
```

Keys are stored as SHA-256 hashes; the plain value is shown **once** at creation.

## Scopes

| Scope | Access |
|-------|--------|
| `payments:read` | `GET /api/partner/v1/payments`, `GET /api/partner/v1/payments/:id` |
| `payments:create` | `POST /api/payments` (same body as admin; requires auth) |
| `organizations:read` | `GET /api/partner/v1/organizations` |
| `students:read` | Reserved for future endpoints |
| `charges:create` | `POST /api/partner/v1/charges` — create merchant/hosted checkout charges |
| `charges:read` | `GET /api/partner/v1/charges`, `GET /api/partner/v1/charges/:id` |
| `payouts:create` | `POST /api/partner/v1/payouts` — request merchant MoMo cashout |
| `payouts:read` | `GET /api/partner/v1/payouts` — settlement summary + payout history |
| `dex:quote:read` | Dex quote endpoints |
| `dex:intent:create` | Dex payment intents |
| `opgb:balance:read` | OPGB wallet balances |

Org-scoped keys only see data for that `organizationId`. Platform keys (no org) see all active tenants on list endpoints. Merchant **charges** require the API key to be linked to a **Developer App**.

## Merchant charges (payment provider)

Use OpenPayGB as a payment gateway for third-party products (not tuition-bound).

```http
POST /api/partner/v1/charges
Authorization: Bearer odelhub_live_…
Content-Type: application/json

{
  "amountUgx": 25000,
  "description": "Order #1042",
  "redirectUrl": "https://your-app.example/orders/1042/paid",
  "cancelUrl": "https://your-app.example/orders/1042",
  "externalRef": "ord_1042",
  "metadata": { "sku": "PRO-1" }
}
```

**201**

```json
{
  "charge": {
    "id": "…",
    "orderAmountUgx": 25000,
    "amountUgx": 25625,
    "platformFeeUgx": 625,
    "merchantFeeUgx": 0,
    "merchantNetUgx": 25000,
    "status": "pending",
    "checkoutUrl": "http://localhost:3000/opgb/checkout/…",
    "expiresAt": "…"
  }
}
```

`amountUgx` on create is the **order**; response `amountUgx` is the **customer total** after fee rules. Fee payer and surcharges are configured on the Developer App (`/developers/dashboard#fees`).

### Telegram Mini App pay (in-app points, payments, crypto)

```http
POST /api/partner/v1/telegram/pay
Authorization: Bearer odelhub_live_…
Content-Type: application/json

{
  "amountUgx": 5000,
  "description": "100 in-app points",
  "sku": "points_100",
  "purpose": "in_app_points",
  "externalRef": "order_abc",
  "redirectUrl": "https://your-mini-app.example/paid"
}
```

Response adds a `telegram` object with `checkoutUrl`, `osMiniAppUrl` (`/os?start=checkout_<id>`), and `sdkUrl` (`/odei-os/telegram-pay.js`). From the partner Mini App call `Telegram.WebApp.openLink(checkoutUrl)` or `OdeiOsTelegramPay.openCheckout(checkoutUrl)`. Confirmed charges still emit `charge.confirmed` webhooks.

Public ecosystem map: `GET /api/public/telegram/ecosystem`.

### WooCommerce

Ship-ready plugin: `integrations/woocommerce/odelhub-openpaygb/` — see [WOOCOMMERCE.md](./WOOCOMMERCE.md). Creates Partner charges, redirects to hosted checkout, completes Woo orders on `charge.confirmed` webhooks.

### P2P escrow (any OpenPayGB card holder)

```http
POST /api/openpay/dex/p2p/escrow
```

Accepts student, admin, staff, or developer sessions (shadow card holders). Legacy `/api/student/dex/p2p/*` routes resolve the same actors.

1. Redirect the payer to `checkoutUrl`.
2. Payer pays with MTN/Airtel MoMo (or sandbox confirm in local/dev when LivePay is unset).
3. Receive webhook `charge.confirmed` (also `charge.created`, `charge.failed`).  
   - **Immediate settle (default):** credits `merchantNetUgx` to settlement balance.  
   - **Hold (events / escrow):** when metadata includes `holdUntilEventDate: true`, `releaseAt`, or `settlementMode: "held"`, HUB emits `escrow.held` and **defers** settlement until release.

### Order escrow (Events · travel · deliveries · …)

Plan splits, then execute hold/release on merchant charges:

```http
POST /api/partner/v1/order-escrow/plan
Content-Type: application/json

{
  "orderRef": "evt_kampala_ga_1",
  "vertical": "events",
  "amountUgx": 50000,
  "holdUntilRelease": true,
  "releaseAt": "2026-10-12T17:00:00+03:00",
  "eventKind": "conference"
}
```

Returns `plan`, `chargeMetadata` (pass through to `POST /api/partner/v1/charges`), and `next` steps.

```http
GET /api/partner/v1/order-escrow
Authorization: Bearer odelhub_live_…
```

```http
POST /api/partner/v1/order-escrow/release
Authorization: Bearer odelhub_live_…
Content-Type: application/json

{ "merchantChargeId": "…", "reason": "check_in_complete" }
```

Also `{ "escrowId": "…" }`. Cron auto-releases when `releaseAt` has passed (`/api/cron/release-order-escrows`). Webhooks: `escrow.held`, `escrow.released`.

**Events metadata contract** (Partner-owned: seat maps, check-in, Zoom, series subscriptions):

```json
{
  "vertical": "events",
  "eventKind": "conference",
  "eventId": "evt_2026_kampala",
  "eventDate": "2026-10-12T17:00:00+03:00",
  "holdUntilEventDate": true,
  "releaseAt": "2026-10-12T17:00:00+03:00",
  "sku": "GA-DAY1",
  "attendeeRef": "reg_8841",
  "leagueId": null,
  "pledgeCampaignId": null,
  "plaqueDedication": null,
  "seatMapPartnerOwned": true,
  "seriesSubscriptionPartnerOwned": true
}
```

`eventKind`: `conference` | `workshop` | `meeting` | `lecture` | `ticket` | `registration` | `pledge` | `league` | `plaque`.

Cookbook: `/docs/public/partner-cookbook/events` · Builder: `/business/events/builder` · Guest pay QR: `/opgb/pay-links`.

### Merchant payouts (cashout)

```http
GET /api/partner/v1/payouts
Authorization: Bearer odelhub_live_…
```

```http
POST /api/partner/v1/payouts
Authorization: Bearer odelhub_live_…
Content-Type: application/json

{ "amountUgx": 10000, "note": "Weekly float" }
```

Pay a user instead of yourself (e.g. a bot buying back its points) — pass the destination and `saveDestination: false` so the app's own payout number is not replaced:

```http
POST /api/partner/v1/payouts
Authorization: Bearer odelhub_live_…
Content-Type: application/json

{ "amountUgx": 20000, "phone": "256772123456", "network": "MTN", "note": "Sell-back #42", "saveDestination": false }
```

```http
GET /api/partner/v1/payouts/{id}
Authorization: Bearer odelhub_live_…
```

Returns `{ payout }` with `status` (`pending` · `processing` · `paid` · `rejected`), `netDisburseUgx` (after the cashout fee) and `rejectionReason`. A rejected payout returns the gross amount to the settlement balance. Complete bot example: `/examples/opgb-points-bot/README.md`.

Requires a payout MoMo number on the developer app (or `phone` in the request). Minimum 1,000 UGX. Hosted page: `/opgb/checkout/{id}` · Provider lobby: `/opgb` · Dashboard: `/developers/dashboard`

## Endpoints

### List payments

```http
GET /api/partner/v1/payments?status=confirmed&since=2026-05-01T00:00:00Z&limit=50
Authorization: Bearer odelhub_live_…
```

**200**

```json
{
  "payments": [
    {
      "id": "665f1a2b3c4d5e6f7a8b9c0d",
      "organizationId": "665f00000000000000000001",
      "organizationSlug": "kampala-campus",
      "studentId": "665f10000000000000000002",
      "programmeCode": "BSC-CS",
      "year": 1,
      "semester": 1,
      "totalUgx": 1250000,
      "tonAmount": 12.45,
      "rail": "mbiyo",
      "status": "confirmed",
      "memo": "",
      "confirmedAt": "2026-05-18T10:22:00.000Z",
      "createdAt": "2026-05-18T10:15:00.000Z"
    }
  ]
}
```

### Get payment

```http
GET /api/partner/v1/payments/665f1a2b3c4d5e6f7a8b9c0d
```

### List organizations

```http
GET /api/partner/v1/organizations
```

## Outbound webhooks (payment events)

Configure HTTPS endpoints in **Master Admin → Partner API → Outbound webhooks**.

When a payment is first confirmed, ODELHUB POSTs:

```http
POST https://your-sis.example/webhooks/odelhub
Content-Type: application/json
X-Odelhub-Event: payment.confirmed
X-Odelhub-Signature: <hex hmac-sha256 of raw body>
```

**Body**

```json
{
  "id": "evt_1716124920000",
  "type": "payment.confirmed",
  "createdAt": "2026-05-18T10:22:00.123Z",
  "data": {
    "payment": {
      "id": "665f1a2b3c4d5e6f7a8b9c0d",
      "organizationSlug": "kampala-campus",
      "totalUgx": 1250000,
      "status": "confirmed"
    }
  }
}
```

**Verify signature (Node.js)**

```js
const crypto = require("crypto");
function verify(secret, rawBody, signatureHex) {
  const expected = crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signatureHex, "hex"));
}
```

Deliveries are logged in `partner_webhook_deliveries`. **Tuition payment** webhooks (`payment.*`) and **merchant charge** webhooks (`charge.*`) both retry up to **3 times** with short backoff.

## Points API (community points programmes)

Separate from Partner API keys: each Community points programme can create its own key on `/opgb/points` → **Points API** (format `opk_<8 hex>_<secret>`, stored as a SHA-256 hash, shown once; rotate or revoke any time). Use it when a game or bot generates the points itself (for example ALM in @AfroLumens_bot).

| Method | Path | Body / query |
|--------|------|--------------|
| `GET` | `/api/public/points/v1/program` | — (name, symbol, prices) |
| `GET` | `/api/public/points/v1/balance` | `?telegramId=` |
| `POST` | `/api/public/points/v1/grant` | `{ telegramId, points, note?, name? }` — creates the member's wallet from the Telegram id if needed |
| `POST` | `/api/public/points/v1/deduct` | `{ telegramId, points, note? }` |

Auth: `Authorization: Bearer opk_…` (or `x-points-key`). Limit: 600 requests a minute per key. Missing or wrong key → 401.

**Events:** save an https URL on the programme. ODELPay POSTs `points.bought` and `points.sold` with `{ type, programId, slug, symbol, orderId, telegramId, points, amountUgx, balance, createdAt }` and headers `X-ODELPay-Event` and `X-ODELPay-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`. The signing secret is shown once when the URL is saved.

## Mobile money (inbound)

Custom PSPs: register `https://<domain>/api/webhooks/provider/<code>` (see Master Admin → Mobile money).

Built-in: `/api/webhooks/momo`, `/api/webhooks/mbiyo`.

## Related docs

- [SIS Integration Cookbook](./SIS_INTEGRATION_COOKBOOK.md)
- [Integration Hardening](../operations/INTEGRATION_HARDENING.md)
- [OpenAPI (tuition subset)](../api-reference/openapi.yaml)
