> For the complete documentation index, see [llms.txt](https://basedapp.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://basedapp.gitbook.io/docs/integrations/mini-apps-platform/17-partner-api.md).

# Partner API reference

The partner API is server-to-server. It lives at `app.based.one` and is what a mini-app's **backend** calls when it wants to push a notification or update a Live Activity for one of its users. The mini-app's JS bundle never sees this API directly — there's no client-side equivalent to most of these endpoints, by design (see [Threat model](#threat-model)).

If you're writing the mini-app's frontend, you want [`03-host-contract.md`](/docs/integrations/mini-apps-platform/03-host-contract.md) instead. This doc is for the partner's server engineer.

## Quick reference

| Endpoint                                    | Purpose                                             |
| ------------------------------------------- | --------------------------------------------------- |
| `POST /api/partners/notifications/push`     | Send a system banner to one or more users           |
| `POST /api/partners/live-activities`        | Start a Live Activity (iOS) / Live Update (Android) |
| `PATCH /api/partners/live-activities/{id}`  | Update an in-flight activity                        |
| `DELETE /api/partners/live-activities/{id}` | End an activity                                     |

## Getting a key

Every partner gets exactly one `mp_<32-hex>` API key per mini-app id. Keys are minted by an internal admin via the [Mini-App Partners admin UI](https://app.based.one/admin?tab=mini-app-partners) (or the underlying `POST /api/admin/mini-app-partners` endpoint).

The plaintext is shown **once** at issuance — the DB stores only the SHA-256 hash. If the partner loses it, mint a new one.

Set it on the partner backend as e.g. `BASED_ONE_PUSH_API_KEY` and sign every request with `Authorization: Bearer mp_<...>`.

## Authentication

```
Authorization: Bearer mp_<32-hex>
```

Missing or malformed → `401 Unauthorized`. Revoked key → also `401`.

The key is bound to a single `MiniAppPartner` row, which carries:

* The associated `miniAppId` (must match the partner's `based.json` manifest id).
* The `displayName` used for brand prefixing (`[<displayName>]` prepended to every notification title — partners can't override).
* The `tenantId` the partner is scoped to. Recipients outside that tenant are silently filtered.
* Per-row rate limit overrides (defaults: 60/min, 10000/day).

## Push notifications

```
POST /api/partners/notifications/push
Authorization: Bearer mp_<...>
Content-Type: application/json
Idempotency-Key: <client-uuid>      # optional, 24h dedupe
```

```json
{
  "recipients": {
    "emails":  ["alice@example.com"],
    "wallets": ["0xabc...123"]
  },
  "title":    "Deposit pending",
  "body":     "Confirmations: 1/6",
  "deepLink": "basedapp://miniapp/<id>/deposits/0xabc",
  "data":     { "txHash": "0x..." }
}
```

Body limits:

* `recipients.emails`: ≤1000
* `recipients.wallets`: ≤1000 (each `0x` + 40 hex)
* `title`: ≤80 chars
* `body`: ≤250 chars

Title is **auto-prefixed**: `"Deposit pending"` arrives on the device as `"[Pull] Deposit pending"`. Icon is taken from the manifest registry — partners can't supply one.

Deep link is gated against the manifest's `permissions.deepLinks`; URLs outside the allowlist are silently dropped.

### Response

```json
{
  "requestId": "...",
  "sent":       2,
  "failed":     0,
  "recipients": 2
}
```

* `requestId` — primary key in `MiniAppPushLog`. Useful for audit-log lookups.
* `sent` / `failed` — Expo dispatch counts.
* `recipients` — distinct user IDs resolved from the inputs.

We deliberately do NOT echo back the list of unresolved emails/wallets — that would let a partner probe which addresses are registered in the tenant.

### Idempotency

Pass `Idempotency-Key: <your-uuid>` to dedupe within 24h. Same key from the same partner re-runs returns the original response with `replay: true` and no fan-out.

```http
Idempotency-Key: deposit:dep_abc123:completed
```

Recommended convention: `<flow>:<id>:<event>`. Cron retries and webhook redelivery are the typical use cases.

## Live Activities

iOS lock-screen cards + Dynamic Island, plus Android Live Updates (progress notifications). Same wire shape on both platforms.

### Start

```
POST /api/partners/live-activities
Authorization: Bearer mp_<...>
```

```json
{
  "recipient": { "wallet": "0xabc..." },
  "template":  "progress",
  "title":     "Deposit confirmation",
  "subtitle":  "USDC on Base",
  "state": {
    "stages":       ["Confirming", "Processing", "Completed"],
    "currentStage": 0,
    "progress":     0.0,
    "statusText":   "Awaiting on-chain transfer"
  },
  "ttlSeconds": 1800
}
```

Returns:

```json
{
  "activityId":  "la_<uuid>",
  "delivered":   true
}
```

`delivered: false` means we accepted and persisted the request, but couldn't dispatch — most often because the user has no registered push-to-LA token yet. The mobile host registers tokens when the user starts a Live Activity locally (via `based.liveActivity.start()` from inside the bundle), so the typical flow is:

1. User performs an action in the mini-app.
2. Mini-app calls `based.liveActivity.start({ ... })`. The host issues a card on the lock screen and registers the iOS push- to-LA token + Android FCM token with the gateway.
3. Partner backend later calls `PATCH /api/partners/live-activities/<id>` to drive remote updates. The gateway routes via APNs (iOS) or FCM data message (Android) — the partner never sees the token.

### Update

```
PATCH /api/partners/live-activities/{activityId}
```

```json
{
  "state": {
    "stages":       ["Confirming", "Processing", "Completed"],
    "currentStage": 2,
    "progress":     1.0,
    "statusText":   "Done"
  },
  "alert": {
    "title": "Deposit complete",
    "body":  "$499998.96 USDC"
  }
}
```

`alert` is optional; when set, the device plays a sound and shows a banner alongside the silent state update. Without it the lock- screen card just refreshes.

### End

```
DELETE /api/partners/live-activities/{activityId}
```

Returns `{ activityId, delivered }`. The card stays visible on the lock screen for \~4s holding the final state, then dismisses.

### Templates and state shapes

The mobile host has a fixed catalogue. Partners cannot ship custom layouts — that would require shipping Swift / Compose code.

| Template    | State shape                                                                                           |
| ----------- | ----------------------------------------------------------------------------------------------------- |
| `progress`  | `{ stages: string[≤6], currentStage: number, progress: 0..1, statusText?: string }`                   |
| `status`    | `{ statusLabel: string, statusVariant: 'pending'\|'success'\|'warning'\|'error', footnote?: string }` |
| `countdown` | `{ deadline: ISO8601, footnote?: string }`                                                            |

State is validated against the template at every start/update; mismatched shapes return `400 Validation failed`.

## Rate limits

| Limit                                   | Default          | Configurable |
| --------------------------------------- | ---------------- | ------------ |
| Push requests / minute / partner        | 60               | Yes (admin)  |
| Push requests / day / partner           | 10,000           | Yes (admin)  |
| Live Activity events / minute / partner | shared with push | Same row     |

Sliding window over `MiniAppPushLog.createdAt`. Exceeding either returns `429`. The check + audit-log INSERT happen in one serializable transaction so concurrent requests can't squeeze past the limit.

To increase a partner's limits, an admin updates `MiniAppPartner.rateLimitPerMinute` / `rateLimitPerDay` directly or via a future tenant-tier API.

## Error codes

| Status | Body                                                           | Cause                                                             |
| ------ | -------------------------------------------------------------- | ----------------------------------------------------------------- |
| `400`  | `{ error, details }`                                           | Body validation failed (`zod.flatten()` shape)                    |
| `400`  | `{ error: "Invalid state for template" }`                      | State doesn't match template's schema                             |
| `400`  | `{ error: "At least one email or wallet recipient required" }` | Empty recipients                                                  |
| `401`  | `{ error: "Unauthorized" }`                                    | Missing / malformed / revoked `mp_*`                              |
| `404`  | `{ error: "Activity not found" }`                              | Live Activity id is wrong, expired, or belongs to another partner |
| `429`  | `{ error: "Rate limit exceeded: …" }`                          | Per-minute or per-day cap                                         |
| `429`  | `{ error: "Rate limit conflict — retry" }`                     | Serializable conflict (rare; safe to retry)                       |

Successful but no-delivery responses (no recipients found, no push-to-LA token registered) return `200` with `sent: 0` / `delivered: false` so partners don't have to fork on success codes.

## Threat model

| Threat                               | Mitigation                                                                                                                                   |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Token exfiltration / sale to brokers | Tokens never leave the gateway. Partners specify recipients by identifiers they already own (email / wallet).                                |
| Cross-tenant spam                    | `MiniAppPartner.tenantId` clamps the recipient lookup. A Based partner can't push to Hyena users.                                            |
| Brand impersonation                  | `displayName` is server-side. `[Pull] ...` always renders, partners can't disable.                                                           |
| Icon spoofing                        | Mobile host pulls icon from the manifest registry, not from any partner-supplied field.                                                      |
| User-existence enumeration           | Push response omits `missing[]`. Partners get aggregate `sent`/`failed` counts only.                                                         |
| Rate abuse                           | Per-minute + per-day sliding window. Atomically reserved — concurrent requests can't TOCTOU past the limit.                                  |
| Replay attacks (cron retries)        | `Idempotency-Key` header dedupes within 24h. Recommended for all event-driven pushes.                                                        |
| Layout injection on Live Activities  | Templates are compiled into the host's Widget Extension; partners pick a template + supply state, can't ship layout code.                    |
| Compromised partner key              | `DELETE /api/admin/mini-app-partners/<id>` soft-revokes via `revokedAt`. Existing requests start failing within seconds. Audit log persists. |

## Schema reference

Three Postgres tables back the partner API:

* `MiniAppPartner` — one row per partner. Hashed key, brand prefix, tenant scope, rate-limit overrides, `revokedAt`.
* `MiniAppPushLog` — one row per send. Recipient inputs, resolved count, prefixed title, body, deep link, data, sent/failed counts, `idempotencyKey`. Indexed on `(partnerId, createdAt)` for the rate-limit window scan and `(partnerId, idempotencyKey)` for dedupe lookups.
* `MiniAppLiveActivityState` — one row per active LA. Persisted state for partial-state PATCH semantics + the one-active-LA-per-mini-app-per-user constraint.
* `MiniAppLiveActivityToken` — host-registered iOS push-to-LA / Android FCM tokens, indexed by `(userId, miniAppId, activityId)`.

## Code examples

### Node.js (server-side)

```ts
const res = await fetch("https://app.based.one/api/partners/notifications/push", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    authorization: `Bearer ${process.env.BASED_ONE_PUSH_API_KEY}`,
    "idempotency-key": `deposit:${deposit.id}:completed`,
  },
  body: JSON.stringify({
    recipients: { wallets: [user.walletAddress] },
    title:      "Deposit complete",
    body:       `$${(deposit.amount / 100).toFixed(2)} added to your wallet`,
    deepLink:   `basedapp://miniapp/${miniAppId}/wallet`,
    data:       { depositId: deposit.id },
  }),
});
const { requestId, sent } = await res.json();
```

For Pull.Fun specifically there's a higher-level wrapper at `tcg-based/src/lib/based-one/` that handles activity-id persistence + idempotency keys; see the [Pull.Fun based-one client README](https://github.com/suberra/tcg-based/tree/main/src/lib/based-one) for the flow-helper API.

### curl (smoke test)

```bash
curl -X POST https://app.based.one/api/partners/notifications/push \
  -H "Authorization: Bearer mp_..." \
  -H "Content-Type: application/json" \
  -d '{
    "recipients": { "emails": ["you@yourdomain.com"] },
    "title":      "Test",
    "body":       "From the partner API"
  }'
```

## See also

* [`15-notifications.md`](/docs/integrations/mini-apps-platform/15-notifications.md) — phase plan and client-side surface.
* [`16-env-setup.md`](/docs/integrations/mini-apps-platform/16-env-setup.md) — APNs / FCM env-var setup walkthrough.
* [`08-security-and-sandbox.md`](/docs/integrations/mini-apps-platform/08-security-and-sandbox.md) — full sandbox threat model.
