# Okta Partner App Developer Guide

> Aimed at developers building an app that integrates with the Okta
> platform. Assumes basic familiarity with HTTP/JSON and a server
> framework of your choice.

## Table of contents

1. [Integration types](#integration-types)
2. [Quick start](#quick-start)
3. [Scopes](#scopes)
4. [App lifecycle](#app-lifecycle)
5. [Embedded apps](#embedded-apps)
6. [Extending the student profile](#extending-the-student-profile)
7. [External apps](#external-apps)
8. [Notification apps](#notification-apps)
9. [Manifest](#manifest)
10. [API](#api)
11. [Webhooks](#webhooks)
12. [Notifications catalog](#notifications-catalog)
13. [Security](#security)
14. [Local testing](#local-testing)
15. [Design system & UI](#design-system--ui)
16. [AI support](#ai-support)
17. [Okta mobile app](#okta-mobile-app)
18. [Example: Daycare Operations app (External)](#example-daycare-operations-app-external)
19. [FAQ](#faq)

---

## Integration types

When you create a new app the very first thing you'll choose is the
**integration type**. Everything else flows from this decision.

### Embedded

Your app is shipped into okta-web as code and runs in the same process.

| | |
|---|---|
| Hosted by | Okta |
| UI | Livewire/Blade rendered inside the tenant dashboard |
| Communication | In-process calls into `App\Services\PartnerApi\*` |
| Repository | Dedicated GitHub repo with ready-made boilerplate |
| Pro | Seamless tenant UX, low latency |
| Con | Strict isolation rules — must go through PartnerApi |

### External

Your app is hosted by you and talks to Okta exclusively over HTTP.

| | |
|---|---|
| Hosted by | You |
| UI | Yours (web/mobile/server-only) |
| Communication | REST API + signed webhooks |
| Repository | None — no source code lives at Okta |
| Pro | Full freedom over your stack |
| Con | You manage hosting, secrets, webhook security |

### Notification (provider)

A pluggable notification provider (WhatsApp / SMS / Push / Slack / ...)
that the platform consumes through one unified `send(recipient, message)`
interface, regardless of the underlying transport.

| | |
|---|---|
| Hosted by | You (api), Okta (embedded), or both (hybrid) |
| UI | None — server-only app invoked by the rest of the platform |
| Communication | Single unified call from the platform per recipient |
| Pro | Adds a new channel (e.g. WhatsApp Cloud, Twilio) without core changes |
| Con | The platform owns the contract — you can't extend it on your own |

Three delivery modes:
- **`api`** — Okta `POST`s to your endpoint with HMAC.
- **`embedded`** — you ship a class inside okta-web that implements `PartnerNotificationProvider`.
- **`hybrid`** — embedded as the primary, falls back to api on failure.

> **Rule of thumb:** Pick *Embedded* for features that should feel
> like part of Okta (e.g. a custom reports tab). Pick *External*
> when you need an independent service (e.g. linking Okta to a
> system you already operate). Pick *Notification* when you only
> want to add a delivery channel — no UI, no workflow, just a way
> for the rest of the platform to reach users on a new medium.

---

## Quick start

### 1. Create a partner account

Sign up through the partner portal and complete your company
profile. You'll receive a tenant of your own that hosts every app
you build.

### 2. Create your first app

From **Apps → New app**:

- Pick the **integration type**.
- Fill in basics (name, description, category, icon).
- For Embedded: connect GitHub so we can provision the repo.
- For External: enter `webhook_url` and the events you want.
- Pick the **scopes** you need from the catalog.

### 3. Submit for review

Hit **Submit for review** when the version is ready. Our team
checks the manifest, runs the policy scanner against your repo
(Embedded), and probes your webhook URL (External). Reviews
typically come back in 1-3 business days.

### 4. Approved

The app publishes automatically to the marketplace. Tenants can
install it, approve the scopes you requested, and start using it.

---

## Scopes

Every piece of data in Okta is gated by a **scope** of the form:

```
<feature>.<resource>.<action>
```

Examples:

```
education.students.read       → list/show students
education.students.write      → add/update students (no delete)
employees.directory.read      → list/show employees
reports.builder.read          → run curated reports
```

Rules:

1. **lowercase + dot-separated** with no wildcards.
2. Partners only ever get `read` or `write`. **Delete is never
   granted to partners.**
3. The catalog is the single source of truth. You can't invent new
   scopes.
4. Okta adds new scopes through platform updates.

Pull the live catalog:

```
GET /api/partners/permissions/catalog
```

For the full per-resource scope reference (endpoints + PHP/HTTP
examples + ULID/core_reference rules), see commit `a164484` on prod
— the content is unchanged.

---

## App lifecycle

```
Draft  →  Submitted  →  In Review  →  Approved  →  Published
                              ↓
                          Rejected
```

- **Draft**: edit freely.
- **Submitted**: in the review queue. Locked.
- **In Review**: our team is looking at it.
- **Approved**: green-lit. Will publish on next publish trigger.
- **Published**: live in the marketplace, installable by tenants.
- **Rejected**: returns to Draft with reviewer comments.

---

## Embedded apps

Embedded apps live inside Okta but are forbidden from:

- ✗ Importing `App\Models\*` (platform models)
- ✗ Importing `App\Services\*` other than `App\Services\PartnerApi\*`
- ✗ Reading platform env keys (DB_*, REDIS_*, etc.)
- ✗ Reading `.env` directly

Use `PartnerApi` services exclusively. Each service automatically
enforces its required scope. If the tenant hasn't granted your app
that scope you'll get a `MissingScopeException` (HTTP 403).

You get a Postgres schema entirely separate from Okta's
(`m_<my_module>`). PostgreSQL enforces the boundary at the role
level.

Full boilerplate layout, the right way to access data, and tables
guidance: see commit `a164484` on prod.

---

## Extending the student profile

An **embedded** app can add content to okta-web's student-profile page
**straight from code** — no portal forms. Write a Livewire component in a
fixed folder, annotate it with a single attribute, and the platform
auto-discovers and registers it.

> **Embedded apps only.** External apps surface their data over
> HTTP/webhooks, not through this registry.

### Folder layout (shipped in the boilerplate)

```
Modules/<App>/app/StudentProfile/
├── Panels/    ← full cards (Panel)
├── Stats/     ← header-strip numbers (Stat)
└── Actions/   ← header buttons (Action)
```

Any annotated component under these folders is discovered and registered
automatically at boot — there is no registration code to write.

### Declaration: an attribute on the class

```php
namespace Modules\MyApp\app\StudentProfile\Panels;

use App\Support\StudentProfile\Attributes\StudentPanel;
use App\Support\StudentProfile\StudentProfileZone;
use Livewire\Component;

#[StudentPanel(
    title: 'Attendance summary',
    permission: 'my_app.records.view',
    order: 50,
    zone: StudentProfileZone::Main,
)]
class AttendanceSummary extends Component
{
    public string $studentHashid;   // the only identifier passed — never a numeric id

    public function render()
    {
        return view('my-app::student-profile.attendance-summary');
    }
}
```

- `key` and `component` are **derived automatically** from the class.
- Only `title`/`label` + `permission` are required; the rest is optional.

### Mandatory rules

- Use **`studentHashid` only** — never a numeric id.
- Every contribution declares a **`permission`** in the
  `<feature>.<resource>.<action>` shape.
- This surface is **embedded-only**.

### The four types (attribute parameters)

#### 1) `#[StudentPanel]` — a full card

| Param | Required | Description |
|---|---|---|
| `title` | ✓ | Card title |
| `permission` | ✓ | View permission |
| `order` | — | Order (default `100`, lower first) |
| `zone` | — | `StudentProfileZone::Main` (wide column) or `::Sidebar` |
| `icon` | — | Icon |
| `description` | — | Short description |
| `key` | — | Override the derived key |

#### 2) `#[StudentStat]` — a header-strip number

| Param | Required | Description |
|---|---|---|
| `label` | ✓ | Label |
| `permission` | ✓ | View permission |
| `order` | — | Order |
| `icon` | — | Icon |
| `color` | — | `primary`/`success`/`warning`/`danger`/… |

The component itself is the live widget that renders the value.

#### 3) `#[StudentAction]` — a header button

| Param | Required | Description |
|---|---|---|
| `label` | ✓ | Button text |
| `permission` | ✓ | View permission |
| `url` **or** `event` | ✓ | Open a URL or dispatch an event |
| `params` | — | Event params (with `event` only) |
| `order` | — | Order |
| `variant` | — | `primary`/`secondary`/`danger`/… |
| `icon` | — | Icon |

`url` takes precedence over `event`. The event is broadcast via Alpine
`$dispatch` and caught by Livewire `#[On]` or `wire-elements`
(`openModal`).

#### 4) Zone

- **`Main`** — the wide centre column.
- **`Sidebar`** — the side column.

### The unified block chrome (`<x-profile-block>` / `<x-profile-stat>`)

The student profile is a **unified block dashboard**: every card renders inside
**platform-owned chrome** that shows its **source** (your app) with its accent
colour, a **sync-status** pill, last-synced time, and an "open in app" link. To
make your card blend into the board, **wrap your panel content in
`<x-profile-block>`** (instead of a bare `<x-card>`):

```blade
{{-- my-app::student-profile.attendance-summary --}}
<x-profile-block
    :title="__('my-app::profile.attendance')"
    :source="\App\Support\StudentProfile\BlockSource::resolve('my-app')"
    status="live"                    {{-- live | syncing | offline | error --}}
    :last-synced-at="$syncedAt"
    :open-in-app-href="route('store.show', 'my-app')"
    :open-in-app-label="__('my-app::profile.open')">

    {{-- your content: chart / table / list — tenant data via App\Services\PartnerApi\* only --}}

    <x-slot:actions>…</x-slot:actions>   {{-- optional: ⋮ menu --}}
</x-profile-block>
```

- **`BlockSource::resolve('<module-slug>')`** gives you a stable source identity
  (name / accent / icon) — don't invent a colour per card. (Or pass
  `source-label` / `accent` / `icon` directly.)
- **Status is dynamic**: pass `status` and `last-synced-at` at render time from
  your real sync state. `status="offline"` shows an offline state — use
  `<x-slot:offline>` for the fallback (link-to-connect) content.
- **Stat** tiles (`#[StudentStat]`) use **`<x-profile-stat>`** (`:label` +
  `:value` + `:source`) so they appear in the stats row attributed to your app.
- The platform owns the chrome; you supply **content + status** only. The
  "data sources" bar and the source filter are derived automatically from your
  registered blocks (no extra config).

### How it shows in the portal

The partner portal **does not run your code**, so its **Student Profile**
tab is **read-only**: it lists the contributions okta-web discovered after
a sandbox install, and the published manifest's `studentProfile` block is
derived from them. You write the code — everything else is automatic.

> Your component receives `studentHashid` only:
> `<livewire:my-app::student-profile-summary :studentHashid="$studentHashid" />`.

---

## External apps

When you create an External app you provide:

- **`webhook_url`**: HTTPS URL that receives tenant events.
- **`webhook_events`**: subscription list.
- **`redirect_urls`**: OAuth callback URLs.

When a tenant installs your app, an **installation token** unique
to that (tenant, app) pair is issued. Use this token on every API
call. The token is long-lived until rotated or revoked. On rotation
you receive a webhook `partner.installation.token_rotated`; the old
token remains valid for **15 minutes** for graceful deploy.

---

## Notification apps

A notification app registers as a pluggable channel that the rest of
the platform consumes through one unified interface. You build the
provider once; the platform calls it with `send(recipient, message)`
regardless of the underlying medium (WhatsApp, SMS, Push, Slack, ...).

### Manifest block

```json
{
  "integrationType": "notification",
  "notification": {
    "channels": ["whatsapp", "sms"],
    "delivery": "api",
    "api": {
      "send_endpoint": "https://your-app.example/notifications/send",
      "auth": "hmac"
    },
    "embedded": {
      "provider_class": "Modules\\AcmeSms\\Providers\\AcmeSmsProvider"
    },
    "capabilities": {
      "supports_templates": true,
      "supports_media": false,
      "supports_bulk": true,
      "max_bulk_recipients": 1000
    },
    "settings_ui": {
      "has_settings_page": true,
      "livewire_component": "partner-apps.acme-sms.settings"
    }
  }
}
```

Validation rules enforced by the platform:

- `notification.channels` is a non-empty array of known values:
  `whatsapp`, `sms`, `push`, `slack`, `email`, `telegram`, `voice`.
- `notification.delivery` must be `api`, `embedded`, or `hybrid`.
- If `delivery` is `api` or `hybrid` ⇒ `notification.api.send_endpoint`
  is required and must be HTTPS.
- If `delivery` is `embedded` or `hybrid` ⇒
  `notification.embedded.provider_class` is required (must be a valid
  fully-qualified PHP class name like `Modules\Foo\Providers\Bar`).
- A `notification` block is rejected unless `integrationType` is set
  to `notification`.

### Scopes (granted automatically)

Picking `integrationType=notification` auto-attaches:

- `notifications.providers.send` (required)
- `notifications.logs.read` (optional)

### Path 1: delivery=api

The platform sends:

```http
POST https://your-app.example/notifications/send
Content-Type: application/json
X-Okta-Timestamp: 1736435261
X-Okta-Signature: <hmac-sha256-hex>
X-Okta-Delivery-Id: <uuid>

{
  "channel": "whatsapp",
  "recipient_identifier": "+966500000000",
  "message": {
    "body": "Message body",
    "title": null,
    "template_id": null,
    "variables": {},
    "media": []
  },
  "metadata": { "recipient_type": "phone" }
}
```

Verify the signature:

```php
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $signingSecret);
if (! hash_equals($expected, $signature)) abort(401);
if (abs(time() - (int) $timestamp) > 300) abort(401);
```

Expected response: `200` with `{ "provider_id": "..." }`. **4xx** =
permanent failure, no retry. **5xx** = transient failure, retried
once.

### Path 2: delivery=embedded

Ship a class implementing the contract:

```php
namespace Modules\AcmeSms\Providers;

use App\Contracts\PartnerNotificationProvider;
use App\Services\PartnerApi\Notifications\NotificationPayload;
use App\Services\PartnerApi\Notifications\NotificationResult;

final class AcmeSmsProvider implements PartnerNotificationProvider
{
    public function send(NotificationPayload $payload): NotificationResult
    {
        try {
            $response = SmsClient::send([
                'to'   => $payload->recipientIdentifier,
                'text' => $payload->body,
            ]);
            return NotificationResult::success(providerId: $response['id'] ?? null);
        } catch (\Throwable $e) {
            return NotificationResult::failure($e->getMessage());
        }
    }
    public function supports(string $channel): bool { return $channel === 'sms'; }
    public function isConfigured(): bool { return ! empty(config('acme-sms.api_key')); }
}
```

### Path 3: delivery=hybrid

The platform builds a `HybridNotificationProvider` that tries the
embedded path first and falls back to api on `isConfigured()=false`
or `send()` returning a failure. Useful for canary releases.

### Settings UI (settings_ui) — optional

If your app needs an in-tenant settings page, declare it in the
manifest. The platform mounts your registered Livewire component
under `/partner-apps/notification/providers`.

### Per-version overrides

Every notification field above is overridable per version from the
**Integration** tab. Blank fields inherit module-level values at
manifest-build time.

---

## Manifest

Every app emits a `manifest.json` describing its capabilities:

```json
{
  "moduleId": "warehouse",
  "displayName": "Warehouse Manager",
  "version": "1.0.0",
  "category": "logistics",
  "integrationType": "embedded",
  "description": "...",
  "scopes": [
    { "key": "education.students.read",  "required": true,  "reason": "to list student rosters" },
    { "key": "education.students.write", "required": false, "reason": "to record results" }
  ]
}
```

External apps add `external` block; notification apps add `notification`
block (see above). Embedded apps that extend the student profile add a
`studentProfile` block (see [Extending the student
profile](#extending-the-student-profile)).

> Manifests are generated automatically from the partner-portal
> form fields — you don't write them by hand except in advanced
> cases.

---

## API

> 📘 A **public, structured API reference** with the full endpoint
> catalog, scopes, and cURL samples is at [API reference](/docs/api).

Base URL: `https://getokta.io/api/apps`. Auth:
`Authorization: Bearer <installation_token>`.

Available endpoints (whoami, education.*, employees.directory,
reports.builder.* — see commit `a164484` for the full table).
Full list: [https://partners.getokta.io/docs/openapi.json](https://partners.getokta.io/docs/openapi.json).

Pagination: `?page=2&per_page=50` (max 100/page). Responses include
`data`, `total`, `per_page`, `current_page`, `last_page`.

Idempotency: write operations accept `Idempotency-Key: <uuid>`.
Same key within 24 hours returns the cached response.

---

## Webhooks

Each webhook is an HTTP POST with a JSON body, signed with
HMAC-SHA256 over `<timestamp>.<body>`. Verify:

```php
$expected = hash_hmac(
    'sha256',
    $request->header('X-Okta-Timestamp') . '.' . $request->getContent(),
    $YOUR_WEBHOOK_SECRET,
);
if (! hash_equals($expected, $request->header('X-Okta-Signature'))) abort(401);
```

Reject `X-Okta-Timestamp` outside ±5 minutes and cache
`X-Okta-Delivery-Id` for 15 minutes for replay protection.

Retry schedule: 30s → 2m → 10m → 1h → 6h. After 6 attempts the
delivery is marked terminal and shown in "Webhook Deliveries".

Respond **2xx within 10 seconds**.

---

## Notifications catalog

Every partner app declares a **notifications catalog** of its own —
the list of events the app can dispatch to end users, with each event
having a stable key, semantics, variables, and default delivery
channels. Partners define the catalog from the partner dashboard;
it's shipped to okta-web on publish and surfaces on the tenant's
`/settings/notifications` page where they enable what they want per
installation and pick delivery channels.

> **Distinction from the integration type**: section
> [Notification apps](#notification-apps) above is about an
> integration type where the app itself is a *delivery-channel
> provider* (WhatsApp/SMS/Push). The catalog here is different:
> it's available to *every* app type (Embedded/External/Notification)
> to declare the catalog of events the app itself emits, regardless
> of which channels carry them.

### Philosophy: declare-first

Your app cannot dispatch a notification from code before declaring
the key in the catalog. This is enforced through two gates:

1. **NotificationScanner in CI**: scans the codebase for any
   `DispatchNotification('<key>', ...)` call and fails the PR when
   `<key>` isn't in `manifest.json["notifications"]`.
2. **Runtime guard on okta-web**: if a dispatch arrives for a key not
   in the ingested catalog, it's silently dropped and logged. Nothing
   ships to the tenant.

Tenants need to know **upfront** every notification your app might
fire so they can decide whether to enable it, over which channels,
and for whom.

### Lifecycle

```
Draft version  →  Edit catalog freely  →  Submit  →  Approved  →  Published
       │                                                              │
       │                                                              ▼
       │                                                       Catalog frozen
       │                                                       Shipped to okta-web
       │                                                       Tenants can install
       │
       └──── Create new version ──→ Catalog cloned forward ──→ Edit freely ──→ ...
```

Key points:

- **The catalog is bound to a version**, not the module.
- **Auto-clone on new version**: every notification is copied
  forward as a starting point.
- **Frozen at publish**: once a version is published, its catalog
  becomes read-only.
- **Continuous sync to GitHub**: every edit updates
  `manifest.json["notifications"]` on the version's branch.

### Anatomy of a notification entry

| Field | Type | Notes |
|---|---|---|
| `key` | string | `<your-slug>.<dot.path>`. Lowercase, snake_case, 3+ parts. Immutable after creation. |
| `display_name_ar` / `display_name_en` | string | What the tenant sees. Bilingual, required. |
| `description_ar` / `description_en` | text | Optional context. |
| `variables_schema` | map | `{ name → php-type }` describing the payload. |
| `default_channels` | array | Subset of `email`, `sms`, `whatsapp`, `push`, `in_app`, `webhook_out`. Tenants narrow this further. |
| `severity` | enum | `info` / `warning` / `critical`. |
| `is_active` | bool | Toggle without deleting. Inactive notifications are silently dropped at dispatch. |

### How to declare

From the partner dashboard: **Apps → pick your app → "Notifications"
tab**. Direct URL: `/dashboard/modules/<your-slug>?tab=notifications`.

- Pick the version (latest draft selected by default).
- **Add notification** → type the key suffix (the `<slug>.` prefix
  is added automatically), fill display name (ar/en), variables,
  channels, severity, save.

### Dispatching from code

```php
use App\Services\PartnerApi\Notifications\DispatchNotification;

app(DispatchNotification::class)('hr-pro.leave_request.approved', [
    'employee_name' => $request->user()->name,
    'leave_days' => 5,
    'start_date' => '2026-04-26',
]);
```

Short form:

```php
app(DispatchNotification::class)('<key>', $payload);
partner_notify('<key>', $payload);   // helper
```

All three are detected by `NotificationScanner`.

### The scanner

`scripts/partner-policy/NotificationScanner.php` collects every
"used" key from 3 patterns and compares against "declared" keys in
`manifest.json`:

- **Used but not declared** → blocking violation (fails CI).
- **Declared but not used** → warning (non-blocking).

Run locally:

```bash
php scripts/partner-policy/check.php Modules/
```

### Manifest contract

```json
{
  "notifications": [
    {
      "key": "hr-pro.leave_request.approved",
      "display_name": { "ar": "تمت الموافقة على طلب الإجازة", "en": "Leave request approved" },
      "description": { "ar": "يُرسَل تلقائياً ...", "en": "Sent automatically ..." },
      "variables": { "employee_name": "string", "leave_days": "int" },
      "default_channels": ["email", "in_app"],
      "severity": "info",
      "is_active": true
    }
  ]
}
```

okta-web's `SyncCatalogFromManifest` reads this block on publish and
upserts the rows into the platform's notifications table.

> **Don't edit this block by hand** in code. The Notifications tab
> writes it for you.

### Who controls which channel

- **Partner declares** the possible channels in `default_channels`.
- **Tenant enables** a subset and picks recipients per installation.
- **okta-web transports** the message via the enabled channels.

Your app says `dispatch(key, payload)` without worrying about
delivery. okta-web handles the entire fan-out (email via Mailable,
SMS via provider, WhatsApp via template API, push via Web Push/FCM,
in_app writes to `notifications` table, webhook_out POSTs an
HMAC-signed envelope). **Your app writes no per-channel transport
classes.**

### Worked example: HR app

#### 1. Declare in the dashboard

| Key | Default channels | Severity |
|---|---|---|
| `hr-pro.leave_request.submitted` | `in_app`, `email` | info |
| `hr-pro.leave_request.approved` | `email`, `whatsapp`, `in_app` | info |
| `hr-pro.leave_request.rejected` | `email`, `in_app` | warning |
| `hr-pro.attendance.alert` | `email`, `in_app` | critical |

#### 2. Dispatch from code

```php
namespace Modules\HrPro\Services\LeaveRequests;

use App\Services\PartnerApi\Notifications\DispatchNotification;

final class ApproveLeaveRequest
{
    public function __construct(private readonly DispatchNotification $notify) {}

    public function __invoke(LeaveRequest $request, int $approverId): LeaveRequest
    {
        $request->update(['status' => 'approved', 'approved_by' => $approverId]);
        ($this->notify)('hr-pro.leave_request.approved', [
            'employee_name' => $request->employee_name,
            'leave_days' => $request->days_count,
            'start_date' => $request->starts_at->toDateString(),
        ]);
        return $request->fresh();
    }
}
```

#### 3. Submit, approve, publish. Tenants now see the notifications
in `/settings/notifications`.

### Tips

- Name keys by **domain**, not technology. ✅
  `hr-pro.leave_request.approved` × ❌ `hr-pro.email.sent`.
- Start with conservative default channels (`in_app` + `email`).
- Use `severity=critical` sparingly — it breaks quiet-hours.
- Keep `variables_schema` small and meaningful.
- Use `is_active=false` instead of delete on a published version.

### Common errors

| Error | Fix |
|---|---|
| `notification-key-not-declared` | Declare on the tab, pull manifest. |
| `notification-key-unused` (warning) | Remove key or wire the dispatch. |
| `cannot_edit_published` | Create new version, catalog auto-cloned. |
| `delete_blocked_by_installs` | Use `is_active=false` instead. |
| `key_prefix` validation error | Type only the suffix after `<slug>.`. |

### Local testing

```php
app(\App\Services\PartnerApi\Notifications\DispatchNotification::class)(
    'hr-pro.leave_request.approved',
    ['employee_name' => 'Sara', 'leave_days' => 3]
);
```

Inspect: `notifications` table on okta-web sandbox, tenant inbox
(Mailtrap), Notification log page.

---

## Security

### Embedded
- ✓ Policy scanner (regex + PHPStan AST) on every PR.
- ✓ Postgres role/schema isolated from the platform.
- ✓ `BlocksPartnerDirectAccess` trait refuses access outside `App\Services\PartnerApi\*`.
- ✓ Every scope checked at runtime via `AppPermissionGuard`.

### External
- ✓ Installation token encrypted at rest.
- ✓ Webhooks signed + timestamped + replay-protected.
- ✓ HTTPS-only `webhook_url`.
- ✓ Okta-side rate limit (60 req/min per installation).

### Your responsibilities
- 🔒 Never log the installation token.
- 🔒 Store `webhook_secret` in a secret manager.
- 🔒 Apply your own rate limits if forwarding tenant data.
- 🔒 Don't store tenant data beyond what you need.

---

## Local testing

### Sandbox tenant

Every Embedded app gets a sandbox tenant with seeded data. Free,
unlimited.

### Webhook tunneling

For External apps use ngrok:

```bash
ngrok http 8000
```

Paste the URL into the sandbox app's `webhook_url`. Replay any past
delivery from "Webhook Deliveries → Replay".

### Postman / Insomnia

Import [https://partners.getokta.io/docs/openapi.json](https://partners.getokta.io/docs/openapi.json) or the
[Postman collection](https://partners.getokta.io/docs/postman_collection.json). Paste a
sandbox token into the `{{token}}` variable.

---

## Design system & UI

Embedded apps render inside the tenant dashboard alongside okta-web's
native screens, so visual identity must match. Hard rules:

1. **Use existing components**: `<x-card>`, `<x-button>`, `<x-badge>`,
   `<x-input-field>`, `<x-textarea>`, `<x-alert>`, `<x-spinner>`,
   `<x-modal-card>`. No custom components, no third-party UI libs.
2. **Tailwind palette only**: `primary-{50..900}`, `neutral-{50..900}`,
   `emerald/amber/red/blue/indigo/purple-{50..700}`. No raw hex.
3. **RTL-first**: `start`/`end`, `ms-`/`me-`, `ps-`/`pe-`,
   `text-start`/`text-end`. Mirror arrows via `rtl:rotate-180`. IDs/
   URLs always `font-mono` + `dir="ltr"`.
4. **Card-based composition**: each section in `<x-card>`. Card
   padding `p-4` mobile / `p-5 md:p-6` hero.
5. **Single sticky save bar** at the bottom for long forms.
6. **Explicit states**: empty / loading / error are first-class.
7. **Modals via wire-elements/modal exclusively**. Open with
   `wire:click="$dispatch('openModal', { component: '<slug>',
   arguments: {...} })"`. Close with `$this->closeModal()` or
   `$dispatch('closeModal')`.
8. **Toasts** via `$this->dispatch('toast', message: ...)`.
9. **Animations**: `fadeInUp 0.35s ease-out` only.

For the full component catalog with props/slots/examples and the
ready-made AI prompt that bakes in all the above rules, see commit
`a164484` on prod or commit `eb910cf` on main. Both have the
expanded Design System section. The condensed version here is kept
for the prod doc budget — the AI prompt content is identical in
substance.

### Reference files in the platform

- `resources/views/livewire/partner/modules/module-edit.blade.php` —
  hero header + sticky tab nav + save bar.
- `resources/views/livewire/partner/modules/notifications/notifications-index.blade.php` —
  table + empty state + version picker.
- `resources/views/livewire/partner/modules/notifications/notification-form-modal.blade.php` —
  full modal form.

---

## AI support

Apps that use artificial intelligence must declare it explicitly in their manifest through two fields:

- **`aiSupport`** (boolean): does the app use AI?
- **`aiMode`** (enum): either `own` (the partner's own tooling) or `platform` (Okta's unified AI engine — requires approval).

### Where to declare it in the platform

- **When creating the app:** an "AI support" card appears between "Basic information" and "Pricing" on `/partner/modules/create`.
- **When updating versions:** the same card lives in the version editor's "Overview" tab, so you can add AI support to a specific release without modifying the parent app. A declaration at the version level overrides the module-level declaration.

### The options

- **`own`** — your app manages its own AI providers (OpenAI / Anthropic / Gemini / ...) and keys. No additional approval from the platform team is needed.
- **`platform`** — your app consumes the central `AiManager` in okta-web. **Declaring it is not enough:** the platform team must manually grant the `ai_platform_approved` flag on the `modules` row in okta-web. Until that happens, `EnsureAiPlatformApproved` rejects every AI call with a 403.

### Detailed reference

See [`ai-support.en.md`](./ai-support.en.md) for the full validation rules, `manifest` examples, and FAQ about cost and upgrading between `own` and `platform`.

### AiManager usage examples

> These examples apply to `aiMode=platform` only. Apps using `aiMode=own` call their providers directly (see the last sub-section).

#### Injecting AiManager

```php
use App\AI\AiManager;
use App\AI\AiException;

class StudentReportsController extends Controller
{
    public function __construct(private AiManager $ai) {}

    public function summary(Request $request)
    {
        try {
            $summary = $this->ai->summarize($request->long_text);
            return response()->json(['summary' => $summary]);
        } catch (AiException $e) {
            return response()->json(['error' => $e->getMessage()], 502);
        }
    }
}
```

Laravel resolves `AiManager` automatically through `AiServiceProvider` (registered as a singleton). You can also use `app(AiManager::class)` or `app('ai')`.

#### `chat()` — simple conversation

```php
$reply = app(AiManager::class)->chat(
    prompt: 'Summarize the following student report in 3 bullet points',
    context: [
        ['role' => 'user',      'content' => 'Student report: ...'],
        ['role' => 'assistant', 'content' => 'Sure, let me read the report.'],
    ],
    opts: [
        'model'         => 'gpt-4o',     // optional — default picked by Okta's engine
        'temperature'   => 0.3,
        'max_tokens'    => 500,
        'system_prompt' => 'You are a professional educational assistant.',
    ],
);
```

#### `stream()` — streaming response (for interactive UIs)

```php
use App\AI\AiManager;

return response()->stream(function () {
    $full = app(AiManager::class)->stream(
        prompt: 'Explain the concept of factor analysis',
        context: [],
        opts: ['temperature' => 0.5],
        onChunk: function (string $chunk) {
            echo "data: " . json_encode(['text' => $chunk]) . "\n\n";
            ob_flush();
            flush();
        },
    );
}, 200, [
    'Content-Type' => 'text/event-stream',
    'Cache-Control' => 'no-cache',
    'X-Accel-Buffering' => 'no',
]);
```

In Livewire, use `wire:stream` or update a public property from inside the `onChunk` callback.

#### `complete()` — text completion (no chat history)

```php
$completion = app(AiManager::class)->complete(
    text: "In his essay, the student wrote: 'Education is the path to",
    opts: ['max_tokens' => 50],
);
// result: "...progress and renaissance in modern societies, and therefore..."
```

#### `summarize()` — summarize a long text

```php
$summary = app(AiManager::class)->summarize(
    longText: $student->report_full_text,
    opts: [
        'max_tokens' => 300,
        'system_prompt' => 'Summarize in one paragraph, focusing on strengths and weaknesses.',
    ],
);
```

#### `translate()` — translation

```php
$ar = app(AiManager::class)->translate(
    text: 'The student excels in mathematics and sciences',
    toLocale: 'ar',
);
// result: "الطالب متفوّق في الرياضيات والعلوم"
```

#### Error handling

```php
use App\AI\AiException;
use Illuminate\Http\Client\ConnectionException;

try {
    $result = app(AiManager::class)->chat($prompt);
} catch (AiException $e) {
    // Provider failure, quota exceeded, model unavailable, ...
    Log::warning('AI request failed', ['error' => $e->getMessage()]);
    return back()->with('error', 'Could not process your request right now. Try again later.');
} catch (ConnectionException $e) {
    // Service unreachable (rare)
    return back()->with('error', 'AI service is unavailable.');
}
```

> ⚠️ **Do not catch** `PlatformAiNotApprovedException` (HTTP 403). Let it propagate — the `EnsureAiPlatformApproved` middleware will return a standard "platform approval pending" response to the user, along with the `X-Ai-Approval-Required: true` header. This is the platform's canonical approval-required UX, and swallowing the exception breaks it.

#### `aiMode=own` examples — partner's own tooling

If you picked `aiMode=own`, Okta plays no part in routing requests. You call your provider directly using its SDK. OpenAI example:

```php
use OpenAI\Laravel\Facades\OpenAI;

$response = OpenAI::chat()->create([
    'model' => 'gpt-4o',
    'messages' => [
        ['role' => 'system', 'content' => 'You are an educational assistant'],
        ['role' => 'user',   'content' => $userInput],
    ],
]);

$reply = $response->choices[0]->message->content;
```

Or Anthropic Claude:

```php
$response = Http::withHeaders([
    'x-api-key' => config('services.anthropic.key'),
    'anthropic-version' => '2023-06-01',
])->post('https://api.anthropic.com/v1/messages', [
    'model' => 'claude-opus-4-7',
    'max_tokens' => 1024,
    'messages' => [
        ['role' => 'user', 'content' => $userInput],
    ],
])->json();

$reply = $response['content'][0]['text'];
```

**Your responsibilities in `own` mode:**
- Store API keys securely (env vars, KMS, ...)
- Manage usage quotas and provider billing
- Disclose to the tenant any data sent outside the platform
- Comply with privacy and data-protection policies

See [`ai-support.en.md`](./ai-support.en.md) for the full reference on `aiSupport`/`aiMode` validation rules and migration paths.

### AI Agent with tool use

Apps that want AI that **performs actions** (not just answers) use the Agent with the concept of "Tools". You define tools as PHP classes, and the AI decides when to call them — for example, when a user asks "add 5 committees with 30 students each", the AI autonomously calls the `exams.committees.add` tool with the right arguments.

> This feature requires `aiMode=platform` + Okta platform approval (`ai_platform_approved=true`).

#### Tool anatomy

Each tool in your app is a PHP class that implements the `App\AI\Contracts\AiTool` interface and lives under `Modules/<ModuleStudly>/AiTools/`. The platform auto-discovers them when the app loads — no manual registration required.

#### Full example: `AddCommitteesTool` in the Exams app

File: `Modules/Exams/AiTools/AddCommitteesTool.php`

```php
<?php

namespace Modules\Exams\AiTools;

use App\AI\Contracts\AiTool;
use App\AI\Exceptions\AiToolException;
use Modules\Exams\Models\ExamCommittee;
use Modules\Exams\Services\CommitteeDistributor;

class AddCommitteesTool implements AiTool
{
    public function __construct(
        private readonly CommitteeDistributor $distributor,
    ) {}

    public function name(): string
    {
        return 'exams.committees.add';
    }

    public function description(): string
    {
        return 'Creates new exam committees and distributes students across them '
            .'according to the given settings. Use this tool when the user asks '
            .'to add committees or distribute students into committees.';
    }

    public function parametersSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'committee_count' => [
                    'type' => 'integer',
                    'minimum' => 1,
                    'maximum' => 100,
                    'description' => 'Number of committees to create',
                ],
                'students_per_committee' => [
                    'type' => 'integer',
                    'minimum' => 1,
                    'maximum' => 60,
                    'description' => 'Number of students per committee',
                ],
                'exam_period' => [
                    'type' => 'string',
                    'enum' => ['first', 'second', 'final'],
                    'description' => 'The exam period (first/second/final)',
                ],
                'distribution_strategy' => [
                    'type' => 'string',
                    'enum' => ['alphabetical', 'random', 'by_grade'],
                    'default' => 'alphabetical',
                    'description' => 'The distribution strategy',
                ],
            ],
            'required' => ['committee_count', 'students_per_committee', 'exam_period'],
        ];
    }

    public function requiredScopes(): array
    {
        return ['exams.committees.write', 'education.students.read'];
    }

    public function handle(array $params): mixed
    {
        $count = (int) $params['committee_count'];
        $perCommittee = (int) $params['students_per_committee'];
        $period = (string) $params['exam_period'];
        $strategy = $params['distribution_strategy'] ?? 'alphabetical';

        $available = $this->distributor->countAvailableStudents($period);
        $needed = $count * $perCommittee;

        if ($available < $needed) {
            throw new AiToolException(
                "Available students ({$available}) is fewer than required ({$needed}). "
                ."Reduce the number of committees or students per committee."
            );
        }

        $committees = $this->distributor->createAndDistribute(
            count: $count,
            studentsPerCommittee: $perCommittee,
            period: $period,
            strategy: $strategy,
        );

        return [
            'created' => $committees->count(),
            'total_students_assigned' => $committees->sum('student_count'),
            'period' => $period,
            'committee_ids' => $committees->pluck('id')->all(),
            'message' => "Created {$count} committees and assigned {$needed} students successfully",
        ];
    }
}
```

#### Embedding the chat UI in your app's page

```blade
<x-ai-agent
    :title="'Committees Assistant'"
    :placeholder="'e.g. Add 5 committees for the first period with 30 students each'"
    :system-prompt="'You are a smart assistant for managing exam committees in the okta-exams app. Help the user create, distribute, and manage exam committees.'"
    height="600px"
/>
```

#### How the flow works

1. The user types: "Add 5 committees for the first period with 30 students each".
2. The component POSTs to `POST /api/apps/ai/agent/stream`.
3. The platform resolves the current app from `AppContextManager`, auto-discovers tools under `Modules/Exams/AiTools/`, filters them by `requiredScopes()` against the installation's granted scopes, and exposes the survivors to the model.
4. The model responds with `{"tool": "exams.committees.add", "args": {...}}`.
5. The platform executes `AddCommitteesTool::handle()` and returns the result.
6. The component renders each step live (running → done) via SSE frames (`tool_call`, `tool_result`, `text`, `done`, `error`).

#### Error handling

- **`AiToolException`**: a recoverable error — the message is fed back to the model so it can adjust and retry. Use it for validation failures.
- **Any other throwable**: aborts the entire agent turn and logs the failure on the `partner_install` channel.

#### Permissions

Every tool declares `requiredScopes()`. The agent loop filters out tools whose scopes the installation doesn't hold before showing the toolset to the model. This means a write-capable tool stays safe even when the tenant only granted read scopes.

#### Iteration cap

Each agent turn is bounded to 5 iterations. After the cap, the model is asked to produce a final summary instead of more tool calls.

#### Authoring tips

1. **Tool name**: use the pattern `module.resource.action`.
2. **Description**: write it like API documentation — the model relies on it to decide when to call.
3. **JSON Schema**: be strict (enums, min/max). It's the only contract between the LLM and your code.
4. **Return shape**: keep it simple (array/scalar). The model summarizes it back to the user.
5. **Validate early**: throw `AiToolException` for expected error paths so the model can retry sensibly.

#### Testing a tool locally

```php
// tests/Feature/AddCommitteesToolTest.php
$tool = app(\Modules\Exams\AiTools\AddCommitteesTool::class);
$result = $tool->handle([
    'committee_count' => 3,
    'students_per_committee' => 25,
    'exam_period' => 'first',
]);
$this->assertEquals(3, $result['created']);
```

---

## Okta mobile app

The Okta mobile app shows the user a set of **service cards**. Your app
can expose a service that appears as a card inside it. This is declared
**per version** — a later version can opt in without touching the
original app.

### Where to enable it

Partner dashboard → app → version → **Integration** tab → **"Services
inside the Okta mobile app"** card → toggle it on, then provide the
settings.

### Settings you provide

| Setting | Description |
|---|---|
| **mode** | `embedded` (you ship files in your repo) or `external` (a URL you host). |
| **entry** | Embedded: repo-relative path inside `mobile/`. External: full HTTPS URL. |
| **allowed origins** | External only: HTTPS origins the page may be loaded from. |
| **required scope** | Optional. The card is shown only if the user's active role holds this scope (from the scopes granted to the version). Empty = shown to any role that can open the app. |
| **pass role claim** | Optional, external only. Adds the role claim inside the signed JWT. No database access either way. |

### File structure (embedded)

Put everything rendered in the mobile app inside a `mobile/` folder at
the root of your app repo:

```
mobile/
├── README.md
├── manifest.json        ← optional metadata for the mobile surface
├── screens/             ← entry files rendered in the WebView
│   └── dashboard.blade.php
└── assets/              ← css / js / images for those screens
```

Example entry: `mobile/screens/dashboard.blade.php`.

### Policy (enforced)

Mobile services are **confined to the dedicated `mobile/` namespace**:

- The embedded `entry` must be a repo-relative path **inside `mobile/`
  only** — no `http(s)://` scheme, no `..` traversal. Anything else is
  rejected at save/review time.
- Mobile screens may not link, navigate, or redirect to any
  platform/tenant page **outside** the `mobile/` namespace.
- This is also enforced **at runtime** by okta-web's `app.webview`
  middleware (the mobile surface is fenced to the `/app` namespace).
- In `external` mode you ship nothing here — you host the page, and
  isolation comes from `allowed origins` + zero data binding.

---

## Example: Daycare Operations app (External)

> A self-contained worked example showing how to build an External app
> that targets **daycare center** tenants (`daycare_center`) — the new
> tenant type added to the Okta platform for leaf operating entities.

### Why External and not Embedded?

Daycare centers need operationally-specific features: attendance
check-in/check-out, daily reports pushed to guardians, and authorized
pickup management. These are short-lived (daily) records that typically
live in the partner's own store and integrate with third-party messaging
or biometric hardware. **External** is the right fit because:

| Reason | Detail |
|---|---|
| **Operational data stays with the partner** | Attendance logs, daily reports, and pickup lists live in the partner's store, not in okta-web. |
| **Independent stack** | The partner may use biometric readers, RFID scanners, or existing mobile apps — none of which can be shipped inside okta-web. |
| **Real-time guardian notifications** | The partner's own messaging channels (WhatsApp, Push, SMS) are already configured on their infrastructure. |
| **Full stack freedom** | Any language or framework, without Embedded isolation constraints. |

> Attendance records, daily reports, and pickup data are **not stored in
> okta-web and are not defined as partner scopes** — they are
> operational data owned entirely by the partner app. Scopes are used
> only to read the okta-web data needed for correlation (student roster,
> guardian contacts).

### Tenant type `daycare_center`

The platform has added the `daycare_center` tenant type to the shared
`canonicalTenantTypes` catalog, which is pushed from okta-web to
okta-partners over the bridge. This means:

- The type appears automatically in the partner portal — no action
  needed on your side.
- You can declare `daycare_center` as a primary target tenant type when
  creating your app.
- `daycare_center` tenants can install your app and approve the
  requested scopes, just like any other tenant type.

No additional code is required to handle this type — once your app is
published, the platform surfaces it to eligible tenants.

### Required scopes

A daycare operations app needs to **read student data** from okta-web
to correlate it with its own operational records. Pick scopes from the
scope picker in the partner portal — the catalog is a mirror of
okta-web, kept in sync automatically in the `partner_available_scopes`
table. Do not hard-code scope names in your code; what appears in the
picker is the authoritative source.

| Scope | Why |
|---|---|
| `education.students.read` | Read the enrolled children for each tenant and match them with local attendance records. |
| `education.students.write` | **Optional** — only if the app needs to write back a custom field (e.g. an RFID card number). Request it only when needed. |

> **Least-privilege principle**: start with `education.students.read`
> only. If you later need to write, add `education.students.write` in a
> new version with a clear reason in the manifest.

There is no attendance or daily-report scope because that data lives
in your app's own store, not in okta-web.

### Manifest

```json
{
  "moduleId": "daycare-ops",
  "displayName": "Daycare Operations",
  "version": "1.0.0",
  "category": "operations",
  "integrationType": "external",
  "description": "Attendance check-in/out, daily guardian reports, and authorized pickup management for daycare centers.",
  "scopes": [
    {
      "key": "education.students.read",
      "required": true,
      "reason": "Match tenant children with attendance and pickup records"
    }
  ],
  "external": {
    "webhookUrl":    "https://daycare.example/okta/webhook",
    "webhookEvents": [
      "education.students.created",
      "education.students.updated",
      "partner.installation.token_rotated"
    ],
    "redirectUrls":  ["https://daycare.example/oauth/callback"]
  }
}
```

`redirectUrls` is useful if you want an OAuth-style flow after
installation — redirect the tenant to `/oauth/callback`, receive the
installation token, and run an initial roster sync.

### Staying in sync via webhooks

After installation you receive subscribed events automatically. Verify
the signature on every webhook (see [Webhooks](#webhooks)):

```php
// Example: handling a student-update event
$event = $request->json('event'); // "education.students.updated"
$data  = $request->json('data.student');

match ($event) {
    'education.students.created' => $this->syncNewStudent($data),
    'education.students.updated' => $this->updateStudentRecord($data),
    'partner.installation.token_rotated' => $this->storeNewToken(
        $request->json('data.new_token')
    ),
    default => null,
};

return response()->json(['ok' => true]);
```

> Respond **2xx within 10 seconds**. For longer processing, return
> 202 immediately and handle via a background queue.

**Recommended events** for a daycare operations app:

| Event | When it fires |
|---|---|
| `education.students.created` | A new child is added to the tenant |
| `education.students.updated` | A student's data changes (name, contact, ...) |
| `partner.installation.token_rotated` | The tenant rotated the token — store the new one immediately |

Event names follow the canonical `<feature>.<resource>.<action>` format;
do not invent custom names.

### Initial sync after installation

On first install, page through the full student roster to seed your
local store:

```bash
# First page
GET /api/apps/education/students?page=1&per_page=100
Authorization: Bearer <installation_token>

# Repeat until last_page is reached
```

After the initial sync, rely on webhooks for incremental changes. This
minimises API calls and keeps your data real-time.

### Guardian notifications

Daycare-specific notifications (arrival, departure, daily report) go
out through the partner's own channels (WhatsApp / SMS / Push) — they
are not part of the Okta notifications system.

If you later want to unify notifications with the Okta platform so
tenants can pick channels from their dashboard, you can declare a
notifications catalog for your app — see the
[Notifications catalog](#notifications-catalog) section.

### Step-by-step summary

1. Create an External app in the portal; declare `daycare_center` as
   a target tenant type.
2. Request `education.students.read` from the scope picker (add `write`
   only if needed).
3. Subscribe to `education.students.created`, `education.students.updated`,
   and `partner.installation.token_rotated`.
4. Add a `redirect_url` if you want an OAuth-style initial-sync flow.
5. On install: fetch the full roster via pagination, then rely on
   webhooks for ongoing changes.
6. Submit for review — the Okta team checks the manifest and webhook
   URL, then publishes the app to the marketplace.

---

## FAQ

### Can I change integration type after creating?

No. Create a new app and archive the old one.

### How long is review?

Typically 1-3 business days. Embedded apps take a bit longer.

### Can one token serve multiple tenants?

No. Each installation token is bound to exactly one (tenant, app)
pair.

### What if a webhook is delayed?

Nothing is lost. Every delivery shows up on the "Webhook
Deliveries" page with manual replay.

### Where do I add my app's notifications?

See the [Notifications catalog](#notifications-catalog) section.
Short: **Apps → pick your app → "Notifications" tab**
(`/dashboard/modules/<slug>?tab=notifications`).

---

## More resources

<ul class="docs-reslist">
<li><span class="k">OpenAPI:</span> <a href="https://partners.getokta.io/docs/openapi.json">https://partners.getokta.io/docs/openapi.json</a></li>
<li><span class="k">Postman:</span> <a href="https://partners.getokta.io/docs/postman_collection.json">https://partners.getokta.io/docs/postman_collection.json</a></li>
<li><span class="k">Status:</span> <a href="https://getokta.io/status">getokta.io/status</a></li>
<li><span class="k">Support:</span> <a href="mailto:partners@getokta.io">partners@getokta.io</a></li>
</ul>
