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—readorwrite. Not two rungs of a ladder: offeringreaddoes not offerwrite, and the reverse is equally untrue. Declare two rows if you mean both.labelanddescription— 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_v2cannot.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 underModules\in your own code and implement the interface — see below.writer— the counterpart ofreaderon anaccess: writeoffer: 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. $filtersis 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
providesand appear on the consent screen, but have no read channel yet.
Only what is published is visible. Other apps see
providesfrom 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
trueonly 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.
writebeside a livereadis 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 the sandbox: your own apps read each other without consent
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
- The caller is the active module context, not something you sent.
- 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. - 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.ReadFromAppchecks the same thing and then returns the data.
Limits worth knowing
writeis enforced. okta-web compares theaccesscolumn: areadconsent opens reads only, awriteconsent opens writes only. An app that relied on a read row passing a write check (which this guide used to describe) now needs awriteoffer with a writer on the provider, awriteentry 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.