API & integration

Cross-app access (your app needs another app's data)

The schedule app needs to know who was absent today. Attendance data lives in the attendance app's own database — a separate schema nothing else reaches. So the question is not "how do I read that table", it is "how do I ask the attendance app's developer to open a specific part of their data to me".

The answer is two consents, not one:

Who consents To what Where
The developer who owns the data "this app may ask" /dashboard/app-links
Every school installing both apps "yes, read OUR pupils' data" the app's store page

The second has existed in okta-web all along. The first is what this section is about, and it cannot be skipped: an access declaration with no developer approval behind it does not publish.

1. If you own the data: declare what you share

Nobody can ask you for something you have not declared. In the app (or version) editor → "Cross-app access" → "What this app shares":

"provides": [
  {
    "resource": "records",
    "access": "read",
    "label": "Daily attendance records",
    "description": "A pupil's presence, absence and lateness, by date and period.",
    "reader": "Modules\\Attendance\\Services\\PartnerApi\\RecordsReader"
  }
]
  • resource — lowercase and _ only, up to 40 characters. This is the key a school's consent is stored under in okta-web, so renaming it later cuts every existing consent loose from its meaning.
  • access — read or write. Not two rungs of a ladder: offering read does not offer write, and the reverse is equally untrue. Declare two rows if you mean both.
  • label and description — written for the school, not for a programmer. This exact text is what appears on the store's consent screen. "Daily attendance records" can be read and decided on; att_rec_v2 cannot.
  • reader — the field that makes the offer yield anything. Everything else is consent paperwork establishing that a link may exist; this class is what the platform actually runs to produce the rows. Without it a school can grant the row and it returns nothing. It must live under Modules\ in your own code and implement the interface — see below.
  • writer — the counterpart of reader on an access: write offer: the class that accepts what the other app sends, validates it and stores it by your rules. A write offer with no writer is a consent that stores nothing. See "Writing into another app's data" below.

Write the reader

namespace Modules\Attendance\Services\PartnerApi;

use App\Services\PartnerApi\Contracts\CrossAppReader;
use Modules\Attendance\Models\AttendanceRecord;

final class RecordsReader implements CrossAppReader
{
    public function read(string $resource, array $filters, string $consumerSlug): array
    {
        return AttendanceRecord::query()
            ->when($filters['from'] ?? null, fn ($q, $d) => $q->whereDate('date', '>=', $d))
            ->limit(200)
            ->get()
            ->map(fn ($r) => [
                'student_ulid' => $r->student->ulid,
                'date' => $r->date->toDateString(),
                'status' => $r->status,
            ])
            ->all();
    }
}

Three rules that are not negotiable:

  • Do not re-check permission here. The platform calls your class only after establishing all three: that the consuming developer was granted this pair, that this particular school consented to it, and that the caller is $consumerSlug — taken from the active context, never from anything the caller sent. A second check adds complexity, not safety.
  • $filters is untrusted input. It comes from another app. Validate it as you would a $request, and cap the row count.
  • Return plain values, and ULIDs rather than numeric ids. The caller is a different codebase possibly deployed at a different version; handing back an Eloquent model couples the two products' schemas, and any later column rename becomes a cross-partner breaking change.

The class runs inside your context: your schema, your approved scopes, the current tenant. Scope your query to the current tenant exactly as you do on your own pages.

Embedded only. The reader is a PHP class living in code you ship inside okta-web. External apps can declare provides and appear on the consent screen, but have no read channel yet.

Only what is published is visible. Other apps see provides from your latest published version only. While it sits in a draft it does not exist for them — deliberately: a link is a commitment between two shipping products, and building one against a draft means what you depend on can change or vanish before anyone outside your team ever saw it.

Document what you share

label and description are one sentence each, for the school. The developer building on your offer needs what the manifest has no room for: the shape of the rows a reader returns, the filters it understands, the payload a writer accepts and what it returns, an example call. That is what shared-data docs are: one Markdown page per app, written from the app editor ("Document what you share" on the "What this app shares" card) or from the MCP assistant:

set_shared_docs {
  "slug": "attendance",
  "body": "## records (read)\n\nEach row: `student_ulid`, `date` (YYYY-MM-DD), `status` (present|absent|late).\n\nFilters: `from` start date. Capped at 200 rows.\n\n```php\napp(ReadFromApp::class)('attendance', 'records', ['from' => '2026-09-01']);\n```\n\n## records (write)\n\nPayload: `student_ulid`, `date`, `status`. Returns `{id, status}`."
}

Who reads it: your team, and the developers of apps whose link request to your app you approved — after approval, not before. A pending request reads nothing; a revoked one no longer does. Not schools, not the public, so it can carry the field-level detail a consent card cannot. And it sits outside the manifest: an edit reaches the reader at once, with no new version.

2. If you need the data: send the request

/dashboard/app-links → "New access request". Pick your app, then the app that owns the data, then — from its published list — what you need, then write your reason.

The reason is not a formality: it is everything the other developer reads before deciding. Say exactly what you will show the user ("we show absence periods inside the pupil's schedule, nothing stored") rather than something generic ("for integration").

3. The reply: all, part, or refusal

The owning developer sees it under "Incoming" and can:

  • Approve everything — the common case, one click.
  • Approve part of it — "read the monthly summary, not the detailed register". That is the normal answer to a real request, not an edge case.
  • Refuse — with a reason, required. A bare "no" leaves the other developer with nothing to change and no way to know whether asking again could ever work.

Approval needs no explanation; refusal does. After approving, a developer may still revoke (also with a reason) — after which your app can no longer declare that access in future versions.

Once approved, the provider's docs open to you: the "Docs" button on the link in the app links page, or get_shared_docs {"slug": "attendance"} from the assistant. Read them before writing a line of ReadFromApp or WriteToApp; the manifest carries only a name and a sentence per offer.

4. Declare the dependency in your version

Once approved, the row appears in your app editor under "Data this app needs". Do not hand-write it — the list is built from live grants, so anything not in it cannot be declared at all. For each row you choose two things:

"cross_module_access": [
  {
    "module": "attendance",
    "resource": "records",
    "access": "read",
    "required": true,
    "reason": "To show absence periods inside the pupil's schedule."
  }
]
  • required — the field the school actually feels:

    • true → the store asks it to install both apps together, and your app does not work without it.
    • false → the rest of the app works and only this feature degrades.

    Get it backwards and a school is left unable to use what it paid for. Use true only when the app is meaningless without the data.

  • reason — appears verbatim on the store page next to the consent control. Write it for the school.

What happens at install — and on every update

okta-web reads cross_module_access from your manifest and creates one row per entry in module_cross_access_grants, ungranted (is_granted = false). The store page then shows the card to the school, which grants or refuses each row itself. No data moves before that.

The rows are created on every update too, not only on first install. If you add the dependency in a later version (say 2.3.17), every school that installed your app before it gets the missing rows the moment the version is published, and the card appears for them with its switches — no reinstall. Existing rows are never touched: what a school granted stays granted, what it refused stays refused.

A developer's approval is not a school's. It means only "this app may ask". Every school is asked separately afterwards, and may refuse.

And who at the school answers? Whoever holds "Approve data sharing between apps" (app_store.cross_access.approve) through one of their roles — granted to the entity-admin role by default, and the school may hand it to anyone else. Someone opening your app's page without it sees the state and cannot change it. If a school tells you the feature does not work and "nobody can approve", that is the first question to ask: who there holds it.

What the release gate checks — and what it does not

The platform checks your cross_module_access declaration at three moments, and does not check the same thing at each:

Moment What is checked Why
Saving in the editor Every entry, against the provider's published catalogue (and on the sandbox, what your sibling app installed there) This is where you can act — drop a line, or ask for a new approval
Submitting for review and publishing Only what is new: entries not in your currently published version's declaration, keyed by (module, resource, access) What shipped was checked when it shipped; re-checking it against the provider's current catalogue was stalling your release for somebody else's stumble
Republishing Nothing A republish pushes what already shipped and adds no line

The story that forced the rule: an app declares a read of attendance_status from "Attendance", ships, schools consent, reads flow. Then "Attendance" publishes a version that accidentally dropped its provides — and republishing the first app is refused with «"Attendance" does not publish okta_hdor.attendance_status.read in its latest published version», while the version installed at every school does publish it. The app was punished for a change it did not make, and the punishment stalled its whole release pipeline — a fix unrelated to sharing could not get out.

Why the rule is safe: a school does not need the gate to protect it from a line that already shipped. The runtime refuses the read anyway when the provider no longer publishes the resource or the school never consented (ReadFromApp), and drops a broken offer from the catalogue. The gate at publish protects against a new line shipping without a developer approval or a published offer — which is exactly what it still does.

What this means for you:

  • "does not publish … in its latest published version" at save time, on a line that used to work, means the provider changed its catalogue. Contact its developer; your published version is unaffected, and your next release is not blocked by that line.
  • The same message at publish means a new line in this version: the provider does not publish what you ask for, or has not approved it yet. That is a correct refusal, and there is no way around it.
  • write beside a live read is a new line — two consent rows, not two degrees of one.

Writing into another app's data

The channel was a read channel by shape, and this guide used to say that write was declared, shown to the school, and not enforced. That is no longer so: writes have their own door, and the word on the consent card decides which door opens.

On the data owner's side — an access: write offer names a writer:

{
  "resource": "records",
  "access": "write",
  "label": "Record attendance from another app",
  "description": "Lets another app record a pupil as present, absent or late for the day.",
  "writer": "Modules\\Attendance\\Services\\PartnerApi\\RecordsWriter"
}
namespace Modules\Attendance\Services\PartnerApi;

use App\Services\PartnerApi\Contracts\CrossAppWriter;
use Illuminate\Support\Facades\Validator;
use Modules\Attendance\Models\AttendanceRecord;

final class RecordsWriter implements CrossAppWriter
{
    public function write(string $resource, array $payload, string $consumerSlug): array
    {
        $data = Validator::validate($payload, [
            'student_ulid' => ['required', 'string'],
            'date' => ['required', 'date'],
            'status' => ['required', 'in:present,absent,late'],
        ]);

        $record = AttendanceRecord::query()->updateOrCreate(
            ['student_ulid' => $data['student_ulid'], 'date' => $data['date']],
            ['status' => $data['status'], 'source' => $consumerSlug],
        );

        return ['id' => $record->ulid, 'status' => $record->status];
    }
}

The reader's three rules apply, plus one: $payload is input from another app — validate it as you would a $request, and refuse bad input by throwing, never silently; a writer that returns without error for something it never stored leaves the other app believing it wrote.

On the consumer's side — one call, shaped like the read:

use App\Services\PartnerApi\CrossApp\WriteToApp;

$written = app(WriteToApp::class)('attendance', 'records', [
    'student_ulid' => '01J…',
    'date' => '2026-09-13',
    'status' => 'absent',
]);
// ['id' => '01K…', 'status' => 'absent']

The call checks what the read checks, with one difference you must know: the school's consent to a write is a separate consent. read and write are two rows in module_cross_access_grants and two lines on the card, and okta-web now compares the access column: a read consent does not open WriteToApp, and a write consent does not open ReadFromApp. An app that needs both declares two entries in cross_module_access and the school is asked about each on its own.

On a sandbox tenant, two apps of yours read each other directly: no approval from another developer (there is none), no consent from the entity (it is yours), and no production publish. What is judged is what you have installed on the sandbox: install the providing app there with a version whose provides declares the resource and names a reader, then call ReadFromApp from the other app and the data comes back. The consent card in the sandbox store shows the row with a "granted automatically" badge instead of a switch.

In the version editor, resources of your other apps that are installed on the sandbox are declarable even before they are published, with a "sandbox only" badge. That is enough to save and to try, and not enough for review: submitting the version for review returns to the original rule — the other app must have published what you depend on, because a production school cannot be asked to consent to a resource that was never shipped to it.

The rule never applies on a production tenant, even when both apps are yours: a school's data is the school's, and the granted row is the only permission there.

Worked example: a write between two of your apps on the sandbox, nothing published

The case: an "attendance" app owns attendance records, and a "gate" app wants to mark a pupil absent when their card is swiped. Both are yours; neither is published yet. The steps from the MCP assistant (or the editor — both write the same columns):

1. The provider declares a write offer with a writer — on attendance's editable version:

set_shared_data {
  "slug": "attendance",
  "shares": [
    {
      "resource": "records",
      "access": "write",
      "label": "Record attendance from another app",
      "description": "Lets another app record a pupil as present, absent or late for the day.",
      "writer": "Modules\\Attendance\\Services\\PartnerApi\\RecordsWriter"
    }
  ]
}

Then write the RecordsWriter class as in "Writing into another app's data" above, and push it.

2. The provider is installed on the sandbox — install_on_sandbox {"slug": "attendance"} (or the install button on the Sandbox tab). This is what is judged: the version installed on the sandbox, not the latest published one.

3. The consumer declares the dependency — the offer now appears in get_app_dependencies {"slug": "gate"} under "available to declare", marked "(sandbox only)":

set_app_dependencies {
  "slug": "gate",
  "dependencies": [
    {
      "module": "attendance",
      "resource": "records",
      "access": "write",
      "required": false,
      "reason": "To record a pupil's absence automatically when their card is swiped at the gate."
    }
  ]
}

The save is accepted because both apps are yours and the provider is installed on the sandbox. (Were "attendance" another developer's, the row would be refused until that developer approved and published the offer.)

4. The consumer is installed on the sandbox and writes — install_on_sandbox {"slug": "gate"}, then from the gate's code:

use App\Services\PartnerApi\CrossApp\WriteToApp;

$written = app(WriteToApp::class)('attendance', 'records', [
    'student_ulid' => $student->ulid,
    'date' => today()->toDateString(),
    'status' => 'absent',
]);

No consent row is waited on: on a sandbox tenant, with both apps by one developer, the call goes straight to RecordsWriter inside attendance's context. The consent card in the sandbox store shows the write row with a "granted automatically" badge.

5. Before review — publish "attendance" first (so the write offer is published), then submit "gate" for review. In the reverse order gate is refused with "does not publish", and that is the right refusal: a production school will be asked about a write row, and there must be a published offer behind it.

The only thing that changes on production afterwards: the school's consent to the write row becomes the permission, asked about separately from any read row.

Actually reading the data

One call, from inside your app:

use App\Services\PartnerApi\CrossApp\ReadFromApp;

$records = app(ReadFromApp::class)('attendance', 'records', ['from' => '2026-08-01']);
// [['student_ulid' => '01J…', 'date' => '2026-08-16', 'status' => 'absent'], …]

Three arguments: the app that owns the data, the resource exactly as it declared it in provides, and optional filters passed through to its reader. You get back whatever the reader returned — plain arrays and scalars.

You do not pass your own identity. No $tenantId, no slug for your app: both are read from the context you are already running in. If the caller's slug were an argument, any app could claim to be another and borrow its links.

What the call checks before returning anything

  1. The caller is the active module context, not something you sent.
  2. The school consented to this exact row — (requester, provider, resource) — the card it saw in the store. The developer's approval alone is not enough.
  3. The provider still publishes the resource and named a reader for it, in the manifest of the installed version — not in the row you were granted on approval day. Withdraw a resource and you stop answering for it.

The platform then enters the provider's context and calls its reader there, so it reads its own data with its own credentials, and your context is restored on the way back. You never touch its schema and learn nothing about it — by design: every install owns its own Postgres schema behind a restricted role, so reading directly is not merely forbidden, it is impossible.

A refusal is an exception, not an empty array

A row the school did not grant throws a RuntimeException whose message names which gate closed; it does not return []. "There is nothing to read" and "you are not allowed" are entirely different states, and conflating them makes you ship a blank panel instead of a fixable error.

Which means you expect the exception and treat it as ordinary on a required=false row:

try {
    $records = app(ReadFromApp::class)('attendance', 'records');
} catch (\RuntimeException) {
    $records = null; // render the schedule without the absence column.
}

On a required=true row it is only what happens between install and consent, and does not recur afterwards.

Why not CrossModuleAccessService::hasAccess()? This guide used to point you at it, and it was wrong twice over: the policy scanner rejects the import (internal-service-import — App\Services\PartnerApi\* is the whole contract), and it returns a boolean anyway, telling you that you were allowed to read while giving you no way to read. ReadFromApp checks the same thing and then returns the data.

Limits worth knowing

  • write is enforced. okta-web compares the access column: a read consent opens reads only, a write consent opens writes only. An app that relied on a read row passing a write check (which this guide used to describe) now needs a write offer with a writer on the provider, a write entry in its own declaration, and the school's consent to that row.
  • Revocation is not retroactive. If a developer revokes, your future versions stop being able to declare the access, but schools that already granted it keep it until the platform is updated.
  • Your own apps need no request. Two apps of one partner have nothing to negotiate — declare the dependency directly. On the sandbox it is enough that the other app is installed there with the resource; at review it must still have published what you name in its provides.

View the whole guide on one page · ↧ Raw Markdown