Getting started

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
  2. Quick start
  3. Scopes
  4. App lifecycle
  5. Embedded apps
  6. Extending the student profile
  7. External apps
  8. Notification apps
  9. Manifest
  10. API
  11. Webhooks
  12. Notifications catalog
  13. Security
  14. Local testing
  15. Design system & UI
  16. AI support
  17. Okta mobile app
  18. Example: Daycare Operations app (External)
  19. 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 POSTs 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

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>):

{{-- 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

{
  "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 hybridnotification.api.send_endpoint is required and must be HTTPS.
  • If delivery is embedded or hybridnotification.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:

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:

$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:

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:

{
  "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).

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.

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.

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:

$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 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

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:

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:

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

Manifest contract

{
  "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

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

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:

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 or the Postman collection. 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 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

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

$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)

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)

$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

$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

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

Error handling

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:

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:

$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 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

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

<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

// 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

{
  "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):

// 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:

# 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 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 section. Short: Apps → pick your app → "Notifications" tab (/dashboard/modules/<slug>?tab=notifications).


More resources


↧ Download this page as Markdown · API reference →