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
- Integration types
- Quick start
- Scopes
- App lifecycle
- Embedded apps
- Extending the student profile
- External apps
- Notification apps
- Manifest
- API
- Webhooks
- Notifications catalog
- Security
- Local testing
- Design system & UI
- AI support
- Okta mobile app
- Example: Daycare Operations app (External)
- 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— OktaPOSTs to your endpoint with HMAC.embedded— you ship a class inside okta-web that implementsPartnerNotificationProvider.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_urland 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:
- lowercase + dot-separated with no wildcards.
- Partners only ever get
readorwrite. Delete is never granted to partners. - The catalog is the single source of truth. You can't invent new scopes.
- 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 thanApp\Services\PartnerApi\* - ✗ Reading platform env keys (DB_, REDIS_, etc.)
- ✗ Reading
.envdirectly
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');
}
}
keyandcomponentare derived automatically from the class.- Only
title/label+permissionare required; the rest is optional.
Mandatory rules
- Use
studentHashidonly — never a numeric id. - Every contribution declares a
permissionin 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 passsource-label/accent/icondirectly.)- Status is dynamic: pass
statusandlast-synced-atat 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
studentHashidonly:<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.channelsis a non-empty array of known values:whatsapp,sms,push,slack,email,telegram,voice.notification.deliverymust beapi,embedded, orhybrid.- If
deliveryisapiorhybrid⇒notification.api.send_endpointis required and must be HTTPS. - If
deliveryisembeddedorhybrid⇒notification.embedded.provider_classis required (must be a valid fully-qualified PHP class name likeModules\Foo\Providers\Bar). - A
notificationblock is rejected unlessintegrationTypeis set tonotification.
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:
- NotificationScanner in CI: scans the codebase for any
DispatchNotification('<key>', ...)call and fails the PR when<key>isn't inmanifest.json["notifications"]. - 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=criticalsparingly — it breaks quiet-hours. - Keep
variables_schemasmall and meaningful. - Use
is_active=falseinstead 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.
- ✓
BlocksPartnerDirectAccesstrait refuses access outsideApp\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_secretin 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:
- 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. - Tailwind palette only:
primary-{50..900},neutral-{50..900},emerald/amber/red/blue/indigo/purple-{50..700}. No raw hex. - RTL-first:
start/end,ms-/me-,ps-/pe-,text-start/text-end. Mirror arrows viartl:rotate-180. IDs/ URLs alwaysfont-mono+dir="ltr". - Card-based composition: each section in
<x-card>. Card paddingp-4mobile /p-5 md:p-6hero. - Single sticky save bar at the bottom for long forms.
- Explicit states: empty / loading / error are first-class.
- Modals via wire-elements/modal exclusively. Open with
wire:click="$dispatch('openModal', { component: '<slug>', arguments: {...} })". Close with$this->closeModal()or$dispatch('closeModal'). - Toasts via
$this->dispatch('toast', message: ...). - Animations:
fadeInUp 0.35s ease-outonly.
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): eitherown(the partner's own tooling) orplatform(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 centralAiManagerin okta-web. Declaring it is not enough: the platform team must manually grant theai_platform_approvedflag on themodulesrow in okta-web. Until that happens,EnsureAiPlatformApprovedrejects 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=platformonly. Apps usingaiMode=owncall 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 — theEnsureAiPlatformApprovedmiddleware will return a standard "platform approval pending" response to the user, along with theX-Ai-Approval-Required: trueheader. 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
- The user types: "Add 5 committees for the first period with 30 students each".
- The component POSTs to
POST /api/apps/ai/agent/stream. - The platform resolves the current app from
AppContextManager, auto-discovers tools underModules/Exams/AiTools/, filters them byrequiredScopes()against the installation's granted scopes, and exposes the survivors to the model. - The model responds with
{"tool": "exams.committees.add", "args": {...}}. - The platform executes
AddCommitteesTool::handle()and returns the result. - 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_installchannel.
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
- Tool name: use the pattern
module.resource.action. - Description: write it like API documentation — the model relies on it to decide when to call.
- JSON Schema: be strict (enums, min/max). It's the only contract between the LLM and your code.
- Return shape: keep it simple (array/scalar). The model summarizes it back to the user.
- Validate early: throw
AiToolExceptionfor 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
entrymust be a repo-relative path insidemobile/only — nohttp(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.webviewmiddleware (the mobile surface is fenced to the/appnamespace). - In
externalmode you ship nothing here — you host the page, and isolation comes fromallowed 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_centeras a primary target tenant type when creating your app. daycare_centertenants 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.readonly. If you later need to write, addeducation.students.writein 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
- Create an External app in the portal; declare
daycare_centeras a target tenant type. - Request
education.students.readfrom the scope picker (addwriteonly if needed). - Subscribe to
education.students.created,education.students.updated, andpartner.installation.token_rotated. - Add a
redirect_urlif you want an OAuth-style initial-sync flow. - On install: fetch the full roster via pagination, then rely on webhooks for ongoing changes.
- 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
- OpenAPI: https://partners.getokta.io/docs/openapi.json
- Postman: https://partners.getokta.io/docs/postman_collection.json
- Status: getokta.io/status
- Support: partners@getokta.io