# Connect an app to Manajemin: quickstart

This is the public developer guide. It is served as `/api/developers/spec/quickstart.md`, next to the machine-readable
contract (`openapi.yaml`, `manifest.schema.json`, `example-manifest.json`, `contract.json`) and listed in `/llms.txt`.
Everything here targets API version **2026-11-01**.

An app built by you plugs into Manajemin for sign-in, plans and entitlements, billing, usage, audit, and AI. Your app
keeps running on your own servers with your own database. Manajemin never runs your code.

## What you need

- A Manajemin account. You create a developer organisation in the developer portal; it comes with a **sandbox**
  workspace of your own, so nothing here touches a customer.
- An HTTPS server that can receive webhooks and call back.
- About 15 minutes for the first connection.
- Optional, Node 20 or later: the SDK (`npm install @manajemin-dev-cli/sdk`) verifies deliveries and calls the API for you, and the
  CLI (`npm install -g @manajemin-dev-cli/cli`, command `manajemin`) applies your manifest and runs the checks from a terminal.

## Fastest start: a working app in one command

```sh
npm create @manajemin-dev-cli/app@latest my-app
```

This writes a small, complete app (a task list, Node 22.18 or later) that already keeps everything below: signed webhooks, install
acknowledgements, sign-in by one-time code, usage reporting, member removal and erasure. It runs in memory, or on a Manajemin Database
(section 19). Its `AGENTS.md` lists the rules to give an AI coding agent. Then register it in the developer portal (Apps, New app, or
`manajemin apply` with its `manifest.json`), set the webhook secret and app key in its `.env`, and test it on your own machine with
`manajemin listen` (section 15).

## 1. Describe your app in a manifest

One JSON document declares what your app is, which events it wants, which scopes it needs, and what data it holds.
Validate it against `manifest.schema.json`, or call the `validate_manifest` tool of the developer MCP server, which
never contacts any URL in your manifest. Start from `example-manifest.json`.

Rules the schema cannot express, which the gate checks: every event name must exist in the event catalog
(`contract.json`), and `api_version` must equal the version the gate serves.

Money stays out. The manifest may propose usage metrics; prices are set by Manajemin.

## 2. Apply it to your sandbox

Send the manifest in the developer portal (Apps, New app: a form writes it for you, or paste your own JSON), or with a developer API token
(below). The app is registered as
`pending_review` and its install into your sandbox starts at once. Production activation needs an operator's approval,
and a manifest that changes the app's hash returns it to review.

A developer manifest may not subscribe to cross-tenant events (`tenant.provisioned`, `client.lead.created`); those
carry other organisations' data and are granted by an operator only.

## 3. Receive events

Manajemin POSTs JSON to `base_url + events_path`. Verify every delivery before you read it:

- `X-Manajemin-Signature: t=<unix seconds>,v1=<hex>` where `hex = HMAC-SHA256(webhook secret, "<t>.<raw body>")`.
  **There can be more than one `v1=`** (`t=…,v1=<hex>,v1=<hex>`): for 24 hours after you replace the webhook secret, every
  delivery is signed with the new and the old secret, so your app keeps working while you deploy the new value. Accept the
  delivery if any `v1` matches. Replace the secret with "end overlap" ticked if the old one may have leaked.
- Reject a timestamp more than 300 seconds old, then compare the MAC in constant time. Verify the **raw** body, not a
  re-serialised object.
- Deduplicate on `X-Manajemin-Delivery`: a delivery can arrive more than once.
- Other headers: `X-Manajemin-Event`, `X-Manajemin-Tenant`, `X-Manajemin-Entity`, `X-Manajemin-Api-Version`.

Answer with a 2xx once you have stored the event. Anything else is retried with backoff, then dead-lettered.

**There is no fixed sender address to allow-list.** Deliveries come from shared cloud addresses that change, so trust a delivery
because its signature checks out, never because of where it came from. If your network only admits allow-listed addresses, tell us:
a fixed, published range is planned for when a customer needs it.

### Verify a delivery (Node)

```js
import crypto from "node:crypto";

export function verifyManajemin(secret, headers, rawBody) {
  const m = /^t=(\d+)((?:,v1=[0-9a-f]{64})+)$/.exec(headers["x-manajemin-signature"] ?? "");
  if (!m || headers["x-manajemin-timestamp"] !== m[1]) return false; // the header must be the signed timestamp
  if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false; // stale
  const expected = Buffer.from(crypto.createHmac("sha256", secret).update(`${m[1]}.${rawBody}`).digest("hex"));
  return m[2].slice(1).split(",").some((v) => crypto.timingSafeEqual(expected, Buffer.from(v.slice(3))));
}
```

Check your verifier against `signature-vectors.json` (in this folder, and at `/api/developers/spec/signature-vectors.json`):
test cases made by the gate's own signing code, including a rotation overlap, a replayed signature and a changed body. Pin your
clock to each case's `now`. `@manajemin-dev-cli/sdk` runs them in its own tests.

**Prefer the v2 signature.** Every delivery also carries `X-Manajemin-Signature-V2: t=<timestamp>,v2=<hex>`, where hex is
HMAC-SHA256 over the timestamp, `X-Manajemin-Event`, `X-Manajemin-Tenant` (empty if absent), `X-Manajemin-Entity` (empty if absent),
`X-Manajemin-Delivery` and the raw body, joined by newlines. It covers the routing headers too, so a captured delivery cannot be replayed
with another entity or event. The v1 signature (`X-Manajemin-Signature`) covers only the timestamp and the body; it is still sent, and if
you verify only v1, **take the event, the tenant and the entity from the body, not the headers**, and refuse a delivery whose headers
disagree with it. From 0.1.3 the SDK's `receive()` and `verifySignature()` require v2 and refuse a delivery signed with v1 only (pass `acceptV1: true` to allow it); from 0.1.2 they check the body against the headers.

```js
export function verifyManajeminV2(secret, headers, rawBody) {
  const m = /^t=(\d+)((?:,v2=[0-9a-f]{64})+)$/.exec(headers["x-manajemin-signature-v2"] ?? "");
  if (!m || headers["x-manajemin-timestamp"] !== m[1]) return false;
  if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
  const signed = [m[1], headers["x-manajemin-event"], headers["x-manajemin-tenant"] ?? "", headers["x-manajemin-entity"] ?? "",
    headers["x-manajemin-delivery"], rawBody].join("\n");
  const expected = Buffer.from(crypto.createHmac("sha256", secret).update(signed).digest("hex"));
  return m[2].slice(1).split(",").some((v) => crypto.timingSafeEqual(expected, Buffer.from(v.slice(3))));
}
```

`signature-vectors.json` has v2 cases too (`vectorsV2`), including changed headers that must be refused.

To see your handler work before any install, send yourself a **test event** (the "Send test event" button on the app's Test tab in the developer portal, or the
`send_test_event` MCP tool): one signed `app.test_event` with no tenant and no data, to the URL your manifest declared. Answer it with a 2xx.
Limit: 6 per minute and 60 per hour per organisation.

Events fall into three audiences (`contract.json` lists them): **entity** events go to apps installed on that entity,
**tenant** events (`tenant.closed`, `tenant.erased`) go to every app installed on any entity of the tenant, and **app**
events are opt-in.

## 4. Handle the install

When an admin installs your app you receive `app.install`. Create the workspace for that `entity_id`, then call
`POST /api/integration/ack` with `{ "entity_id": "..." }`. **The ack is what activates the install.** Until you ack,
the install stays `pending`, and after a few minutes without an ack it is marked `failed` and the admin can retry.

When an admin uninstalls, you receive `app.uninstall_requested`. Delete (or export and delete) that workspace's data,
then call `POST /api/integration/uninstall-ack`. An uninstall ends in an acknowledged deletion, never in silence.

Also handle: `member.removal_requested` (reassign that member's data, then `POST /api/integration/ack-removal`),
`member.suspended`, `member.session_revoked` (end that person's sessions in your app), `subscription.*` (plan and
entitlement changes), and `tenant.erased` (the organisation's data is to be erased everywhere).

A member event says which member it is about in the body's `payload` object (`member_id`, `user_id`, `email`, and for a
removal `transfer_to_member_id`; `contract.json` lists the fields per event). Acknowledge a removal with
`{ "member_id": payload.member_id, "outbox_id": <the body's outbox_id> }`.

**A paused install.** An admin can pause your app for one workspace without uninstalling it. While paused, people cannot
sign in to it from Manajemin, and your calls to the integration API for that `entity_id` are refused with a 403 whose
message says the admin paused the app. Keep the workspace and its data. You still receive the platform's events for it,
and `ack-removal`, `subject-erasure-ack` and `export-ready` still work, so removals and erasures are honoured during a
pause. Other apps' events are not pushed to you while paused; pull them with `GET /api/integration/events` once calls
succeed again (events are kept for seven days).

## 5. Your key

Every call to `/api/integration/*` carries `X-Manajemin-Key: <key_prefix>.<secret>`. You get one key per app from the
developer portal, shown once; rotate it there (the old key keeps working for 24 hours, or press **End the old key now** if it
leaked). If your key is used from a country it never called from before, every owner and admin of your organisation gets an email.
What the key may call is limited
twice: by the **scopes** your manifest declared, and by the install: a call that names an entity your app is not
installed on is refused with a 403.

If the platform team switches your app to **per-install tokens**, a leaked key alone opens nothing: your first `ack` of
an install also returns an `install_token` (once; store it with that install's workspace), and every later call that
names that entity, including later acks and `uninstall-ack`, also carries `X-Manajemin-Install-Token: <token>`. A token
only works for the install it was issued for. Only a fresh (`pending`) install is handed a token by its ack; an install that
was already active when the platform team switched your app to tokens gets one from them instead. If you lose one, ask the platform team to reissue it; the old one stops
working at once. The token ends with the uninstall.

## 6. Sign users in (SSO)

Manajemin opens your app at `/sso/<app id>?entity=<entity id>` (the app picker does this; you may link to it too, with
an optional relative `redirect`). The user comes back to `<base_url>/auth/manajemin/sso` with a one-time `?code=`, valid for 60
seconds, plus `entity=<entity id>` and `redirect` if one was given. Redeem it with `POST /api/integration/exchange` and your key; you receive the user's claims (`sub`, `email`,
`tenant_id`, `entity_id`, `product`). No reusable token ever appears in a URL. An app on per-install tokens sends the landing URL's
`entity` as `entity_id` in the exchange body, with that install's token; a wrong token is refused before the code is spent, so the
user's login is not lost.

## 7. Plans, usage and the snapshot

- `GET /api/integration/snapshot?app_id=&entity_id=` returns the entity's current plan and limits. Use it to repair state
  after a missed event.
- `POST /api/integration/usage` reports usage deltas (not totals) for the metrics you declared; `dedupe_key` makes a
  retry a no-op.

## 8. Platform services (declare the scope in your manifest)

These scopes are never given by default: a person reviews them when your app is approved, and the admin who installs your
app sees them on the consent screen.

| Scope | Route | What it does |
|---|---|---|
| `members:read` | `GET /api/integration/members` | The workspace's members who may open your app |
| `audit:write` | `POST /api/integration/audit` | Writes an entry in the organisation's tamper-evident activity log |
| `notify:send` | `POST /api/integration/notify` | Emails one of those members through Manajemin (5 per member per hour) |
| `ai:invoke` | `POST /api/integration/ai` | Asks Manajemin's model for a named member, on the organisation's AI budget |

The member list is limited to people the admin let open your app. An audit entry is always stored as
`app.activity_recorded` with your own event name inside; you cannot write a line that looks like a login or a payment.
A notification says that your app wrote it and may link only to your own host. AI is refused for a member whose AI access
the admin switched off, and it offers no tools. (Tools your app offers to Jemi are a separate, reviewed mechanism: see below.)

### Offer tools to Jemi (optional)

An app that declares the `ai:invoke` scope can offer tools to the organisation's Jemi: **read** tools such as "which tasks are
overdue?", and **write** or **destructive** tools such as "rename this task" or "delete this task". Declare them in the manifest's `tools` array (name, description, a small JSON Schema `inputSchema`, `risk`,
`transport`, and the `path` that receives the call). `transport` is `"http"` (a signed POST, below) or `"mcp"` (your app already
runs an MCP server over streamable HTTP at `path`; see "Tools over MCP"). The tools are part of the manifest hash, so a person reviews
exactly what the model will see. A description or parameter text that reads as an instruction to the model is refused at
registration.

None of it reaches anyone's Jemi on its own. A tool is offered only when the organisation's admin switches that tool on
(Settings, AI), the app is active and approved, and the person asking may open your app.

A `read` tool runs as soon as Jemi calls it. A `write` or `destructive` tool never does: Jemi can only draft the call, and the
person sees a confirmation card whose text the platform wrote from your reviewed manifest (tool name and description) and
the validated arguments, not from words the model chose. A destructive tool's card says it cannot be undone from there.
**Your server is called only after the person confirms**, and the platform checks everything again at that moment: the
person may still open your app, the tool is still switched on, and the tool's declaration is unchanged since the card was
shown. If you edit a tool's description or schema, pending cards for it stop working.

Jemi calls your `path` with a signed POST, exactly like a webhook (same headers, same signature, `X-Manajemin-Event: tool.call`;
verify it the same way). The body:

```json
{
  "api_version": "2026-11-01",
  "tool": "list_overdue_tasks",
  "call_id": "<uuid, also X-Manajemin-Delivery>",
  "arguments": { "limit": 10 },
  "context": {
    "tenant_id": "...", "entity_id": "...",
    "member": { "id": "...", "user_id": "...", "name": "...", "email": "...", "role": "manager" },
    "initiated_by": "jemi"
  }
}
```

A write or destructive tool's call also carries `"confirmed": true`, always: you are never called for one without a
person's confirmation, and `call_id` (also `X-Manajemin-Delivery`) is the id of that confirmed card, so deduplicate on it:
the platform never retries a confirmed call on its own, but a person may confirm only once per card.

Answer within 8 seconds with a JSON object of at most 32 KB. **You** check what that person may see: Jemi never has more
access than the person asking. Only the arguments your schema declares are passed. Your answer is treated as data, never
as instructions; it is shown to the model labelled as coming from your app.

#### Tools over MCP

With `transport: "mcp"` the platform speaks MCP to `path` on your base URL instead of the signed-POST body above. It sends
`initialize` (no client capabilities: no sampling, no elicitation, no roots), `notifications/initialized`, then one
`tools/call` with the tool's name and the validated arguments; the platform context (`tenant_id`, `entity_id`, the
member, `initiated_by`, `call_id`, and `confirmed` for write tools) rides in `params._meta.manajemin`, never in the arguments.
Every request is signed like a webhook. Reply with JSON or an event stream, within the same 8 seconds and 32 KB.

What the platform never does: call `tools/list` (the tools the model sees are the ones in your reviewed manifest, so a
description cannot change after review), answer a request your server makes of it, or show your error text to the model.
Use `structuredContent` or text content in the result; images and other content types are dropped. `isError: true` becomes
a generic "the app could not do that".

## 9. Run the Connection check

The Connection check walks your app through the whole loop in your sandbox: it delivers `app.install` and waits for your
ack, delivers a signed test event, redeems an SSO code with your key, waits for a usage report, then opens an uninstall
and waits for your acknowledgement. It runs against the exact manifest you applied, so changing the manifest means
running it again. Limit: 3 per minute and 20 per hour per organisation, because it calls your server.

What your app does for the middle steps: the check sends a signed `app.connection_check` event whose `payload` is
`{ "check_id": "...", "sso_code": "..." }`. Redeem `sso_code` at `POST /api/integration/exchange` (with `entity_id` set to the
body's `entity_id`), then report one usage delta for a metric your manifest declares, with `dedupe_key`
`connection_check:<check_id>`. Each step waits 10 seconds.

## 10. Use a developer API token (CI and coding agents)

Create a token in the developer portal. It belongs to your organisation, lasts at most 90 days, can be revoked at once,
and can only act in your sandbox: apply a manifest (as `pending_review`), run the Connection check, read the delivery
log. It cannot approve your app, reach another organisation or production, or read a webhook secret or an app key.

Send it as `Authorization: Bearer mjd_<...>` to the developer MCP server at `/api/mcp`. Without a token the server
still answers the documentation and validation tools.

**From a terminal, without pasting a token.** `manajemin login --device` shows a short code (`BCDF-GH23`) and the portal address. Open the
portal, signed in with your second factor, type the code on the "Hubungkan CLI" page and approve it; the CLI collects a 30-day token by
itself and keeps it in a file only you can read. Type the code yourself: a link or a code someone sent you is not yours to approve. While
you work, `manajemin dev` watches `manifest.json`, validates and applies it on every save, and prints each delivery to your app as it
arrives.

All of your tokens are revoked when you change or reset your password or use an MFA recovery code, and a token stops
working while your account is disabled. Create a new one afterwards; update the secret in your CI when you do.

## 11. Run the automated review (before you ask for production)

When the Connection check passes, run the review: in the developer portal, with `manajemin review <app id>` (it exits 1 when fixes are needed, so CI can gate on it), or with the `run_review` tool of the developer MCP server.
It returns a score from 0 to 100, a verdict and a list of fixes, and it sends your endpoint a few requests of its own, so it is
limited to 2 per minute and 10 per hour per organisation. **It approves nothing**: an operator makes the app live. The last review is kept
on the app and shown to the operator beside Approve, with the scopes and sub-processors set apart for a person to read; a review of an older
manifest is marked out of date, so run it again after you change the manifest.

What it checks, and what each failure means:

- **The manifest.** A privacy policy URL and a support URL are declared and answer 200; the data categories and regions you hold
  are declared. Scopes that reach people or speak as the platform (`members:read`, `ai:invoke`, `audit:write`, `notify:send`,
  `guest-grants:write`) and any sub-processors are not a failure; they are listed for a person to read.
- **That the host is yours.** Publish a text file at `https://<your host>/.well-known/manajemin-verification.txt`, on the
  host in `endpoints.baseUrl`, with no redirect, containing the single line `manajemin-site-verification=<value>`. The exact
  line for your app is in the review's fix list the first time it fails; it is computed by the platform for your app and host,
  so a file made for another app is no proof for this one. Changing the host needs a new line. If you cannot put a file on the
  host, add a **DNS TXT record** at `_manajemin-verification.<your host>` with the same value instead; the file is tried first, DNS
  only when the file did not do, and a long value split into several strings by your DNS provider is accepted.
- **Two suggestions that never block.** The review reads the TLS connection it made (protocol, and days left on the certificate;
  a warning under 14 days or on TLS 1.1 and older) and the headers of your SSO landing page `/auth/manajemin/sso`
  (`Strict-Transport-Security`, `X-Content-Type-Options: nosniff`, and `X-Frame-Options` or a CSP `frame-ancestors`; the page's
  URL carries a one-time code). They are marked "saran", show their fix, and change neither the score nor the verdict.
- **That your endpoint verifies what it receives.** The review sends a correctly signed `app.test_event`, then the same delivery
  again (both must get a 2xx: deduplicate on `X-Manajemin-Delivery`, do not fail it), then one signed with a wrong secret and one
  signed correctly but an hour old. **The last two must be refused with a 4xx**, normally 401. An endpoint that accepts them
  trusts anyone who knows its URL, or a captured request: this is the check that counts most. `@manajemin-dev-cli/sdk`'s `receive()`
  does both. A correctly signed event that is not accepted at all skips the other checks, since a dead endpoint refuses everything.
- **Why you need each sensitive scope.** For `members:read`, `ai:invoke`, `audit:write`, `notify:send`, `guest-grants:write` and
  `events:publish`, add `"scopePurposes": { "members:read": "Shows who can be assigned a task, and nothing else." }` to the manifest: a
  sentence on what the app does with it. It is shown to the tenant admin beside the permission and handed to the operator with the
  scope. A missing or empty one ("for the app") fails the review. The review does not decide whether the reason fits; a person does.
- **Speed.** A correctly signed event should be answered within 2 seconds (a warning from 2 s, a failure from 8 s; the platform gives
  up at 10 s). Store, answer, then do the slow work.
- **The Connection check** passed for the manifest you are submitting, not an older one.

Verdicts: `needs_fixes` (a check failed, or could not run: set the webhook secret first), `needs_human_review` (nothing to fix, but a
person should read the scopes and sub-processors), and `ready`. A check that was skipped earns nothing, so an app is never scored
for what the review could not see.

## 12. Before an operator approves your app

Three things stand between a clean review and production, and none of them is automatic:

- **Your business is verified.** In the portal, under "Identitas badan usaha", give the legal name, registration number and country of the
  business behind the organisation. An operator compares them with a real record and verifies or rejects them (a rejection comes with a
  reason, and you can submit again). Changing them afterwards sends them back for verification. No app of the organisation is approved
  until this is `verified`.
- **Install tokens are required.** An app you register always uses per-install tokens: the first `ack` of an install returns an
  `install_token` once, and you send it as `X-Manajemin-Install-Token` on every call that names an entity. `@manajemin-dev-cli/sdk` does this
  for you when you give it a place to keep tokens. It cannot be switched off for an app a developer owns.
- **A new organisation waits a week.** The operator is asked to confirm knowingly before approving an app of an organisation younger than
  7 days. Creating organisations is also limited per person, per network address and per day for the whole platform, so a refusal there
  means "try later", not "you did something wrong".

Once approved, a tenant admin choosing your app sees a card with your organisation's name (and the legal name once verified), the date
of approval and how often our probes found your app up over the last 30 days. Keep the app answering.

## 13. Hand events to other apps (and receive theirs)

A tenant that installs several apps expects them to work together: HR hires someone, Finance sets up payroll. Apps never call each
other; they go through the platform, which checks consent and keeps the record.

**Publish.** Declare the event in your manifest and ask for the scope:

```json
"scopes": ["...", "events:publish"],
"appEvents": { "publishes": [ { "type": "hris.employee.hired.v1", "description": "A person was hired.", "fields": ["employee_id", "start_date"] } ] }
```

The type is `<your app id>.<noun>.<verb>.v<N>`. You may publish only under your own app id. Then call
`client.publishEvent({ entityId, type, data: { employee_id: "e-1" }, idempotencyKey })` (`POST /api/integration/events`). `data` may carry
only the declared fields and only strings, numbers, booleans or null: **events are thin facts**, ids and what changed, never the record.
A subscriber that needs more asks for it with its own permission. You are not told who receives it, and a retried publish with the same
`idempotencyKey` returns the first event.

**Subscribe.** List the types in `appEvents.subscribes` (no extra scope). A delivery arrives at your events path like any other,
signed, with `event` set to the type, `source` the publisher, `event_id`, `trace_id` and `data`. **Delivery is at least once**:
deduplicate on `X-Manajemin-Delivery`. If you cannot keep up with pushes or were down, `client.pullEvents(entityId, { after })` returns the
same events in order from a cursor, for 7 days.

**Consent decides.** An event is delivered only when both apps are active on the entity and the tenant admin consented to the manifest
each has on file now. The consent screen says what an app sends and what it receives. Change the list and the tenant is asked again.

**Versions.** Never rename a field or change its meaning inside a version. Add a field (new fields are optional), or publish `.v2` next to
`.v1` and keep delivering `.v1` until your subscribers have moved.

**When an app leaves.** If a publisher is uninstalled from the workspace, its events stop at once (anything still queued from it is dropped, not
delivered). Subscribe to the platform event `app.peer_removed` (add it to your manifest's `events`) to be told, once, which app left
(`payload.peer_app_id`); you receive it only if your manifest receives that app's events. What you derived from its events is yours to keep
or delete; the platform does not delete it for you. The same holds on your side: if **your** app is paused or uninstalled on the
workspace, or its consent there is out of date, deliveries already queued for it are not sent (checked again just before sending). A
paused app can pull them with `pullEvents` when it resumes.

**Limits.** 600 events a minute per app and workspace, and 20,000 a day. A subscriber is sent at most 50,000 a day for one workspace; past
that, deliveries wait for the next UTC day instead of being dropped. Five failed deliveries in a row pause deliveries to your app for
a minute, doubling while it keeps failing, and everything queued waits: answer 2xx quickly, store, and work afterwards.

**Tracing.** Every event carries a 32-hex trace id, also sent as a `traceparent` header on each delivery. Put it on your log lines; pass
your own with `publishEvent(..., { traceId })` to continue a trace that began in your app.

## 14. A person asks to be erased, and a tenant asks for its data

**`subject.erasure_requested`.** A person asked the organisation to erase what is held about them, and the tenant admin recorded it.
Every app installed on the organisation gets the event, whether or not it subscribed. `payload` carries `request_id`, `email`, and
`user_id` and `member_id` when known. Delete the person's data on `entity_id` (their user, their sessions, what they own), **then**
`client.ackSubjectErasure({ requestId, entityId })`. The request is complete when every app has confirmed; if you never do, the operator
chases you and the organisation cannot meet its own deadline. Deliveries are at least once: deleting what is already gone is not an error.

**`app.export_requested`.** The tenant admin (or the organisation's exit) wants everything your app holds for `entity_id`. Build the
file on your side, put it behind a link on **your own host** that stops working within 7 days, and answer with
`client.exportReady({ exportId, entityId, url, expiresAt, bytes })`. The link must be https and carry no credentials. The platform shows
it to the admin and never holds the data. The reference app answers with an HMAC-signed link that needs no state; see `exportLink` in
`examples/reference-app/src/app.ts`. When an organisation leaves, every installed app is asked automatically.

## 15. Test your handlers without waiting for a tenant

The platform can send your endpoint the deliveries a real tenant would cause, or a deliberately bad one, in your sandbox. It tells you
whether the endpoint did the right thing. Nothing touches a real tenant: ids are made up and bodies say `"simulated": true`.

| Scenario | What it sends | What it expects |
|---|---|---|
| `lifecycle` | The lifecycle events your manifest subscribes to (plan changes, a suspended member, a closed tenant) | A 2xx for each |
| `duplicate` | The same event twice with the same `X-Manajemin-Delivery` | A 2xx both times, and the effect **once** (check your data) |
| `out_of_order` | A newer event, then an older one | A 2xx both times; the older must not undo the newer (compare `generatedAt`) |
| `unknown_event` | An event type you do not know | A 2xx: new events arrive within an API version |
| `bad_signature` | An event signed with the wrong secret | A **4xx** (401), and nothing stored |
| `stale_timestamp` | A correctly signed event an hour old | A **4xx** (401), and nothing stored |

Run one from the portal (the app's Test tab), with `manajemin simulate <app id> <scenario>`, or with the `simulate` MCP tool. The report lists
each step, whether it met the expectation, and what only you can check in your own data. The platform cannot see inside your database.

**On your own machine, before you deploy.** `manajemin listen <app id> --forward-to http://localhost:4600` asks the platform to sign the
scenario's deliveries with your webhook secret (the `prepare_deliveries` MCP tool), posts them to your local server at your manifest's
events path, and judges each answer the same way. The platform never calls your machine; the CLI does. Press Enter to send them again;
`--scenario` picks another scenario. A URL with a path (`--forward-to http://localhost:4600/hooks`) is used as given. Your local app can
call the Manajemin API as usual (acknowledge the install, redeem a sign-in code from the portal's Test tab), because those calls go out.

**Replay.** `manajemin replay <app id> <delivery id> [--same-id]` (the `replay_delivery` tool) sends one delivery from **your sandbox's**
delivery log again. With `--same-id` the endpoint should recognise it and skip it; without, it should process it again. A delivery to a real
tenant cannot be replayed from here: it carries that tenant's data. Simulations and replays share a limit of 3 a minute and 20 an hour.

## 16. Changing your manifest after tenants installed you

Each install's admin agreed to the manifest on file when they installed. When you change it, the new version does not reach every tenant
at once, and it cannot widen what you may do on an install nobody agreed to it for:

- **A new scope, data category, region, sub-processor or app event** is shown to each tenant admin as "what the app asks for now",
  beside a button to agree. Until they do, a call that needs the new scope on **that** install is refused with a 403, while everything
  they agreed to keeps working. Other installs move on their own as their admins agree, so a bad version meets only the tenants who read
  and accepted it.
- **Removing** something needs no new agreement.
- Your calls on installs that never recorded an agreement (installed before the consent screen existed) behave as they always did.
- Every manifest you have had is kept by its hash, so the comparison is against exactly what the admin read.

Treat adding a scope as a request: say why in `scopePurposes`, and expect it to arrive install by install.

**When your app is live, a change to its endpoints is rolled out gradually.** If the new manifest differs from the live one only in
`endpoints` or presentation (name, description, icon, support and privacy links), nothing pauses:

1. The live version keeps serving. Your apply answers with `rollout: { id }`.
2. If your organisation has two owners or admins, the one who did **not** apply it puts it forward (Production tab). An operator then
   approves it; a new host must carry your ownership proof first (section 11).
3. It reaches **10%, then 50%, then 100%** of organisations, an hour per stage. Each workspace is assigned by a stable hash, so a
   workspace on the new version stays on it. Its deliveries, sign-ins and Jemi tool calls go to the new endpoint, and links you send
   it (exports, notification buttons) may point at the new host.
4. Deliveries to each side are counted. If the new version fails more than 20% of at least 10 deliveries in a stage, and more than
   10 points above the old one, it **rolls back by itself** and you get an email.
5. After the last stage, it becomes your app's manifest.

You can roll it back yourself on the app's Production tab. While a new version is waiting or rolling out, other changes are refused
(409). Any other change (scopes, data, events, tools, catalog) still pauses a live app until an operator approves it (section 12).

**Your app's audit trail** is on the app's Test tab: a CSV of the last 30 or 90 days of what your app did on the workspaces it is
installed on, and what was done to it. It holds no addresses and no one's identity.

**When an organisation reports a problem with your app**, every owner and admin gets an email. The report appears on the app's
Production tab, where you mark it "being handled" or "resolved" with a reply the organisation reads. A report can name a second app
("your app and theirs disagree"); Manajemin sees every open report and coordinates.

## 17. Working as a team

An organisation is a team. Whoever created it is the **owner**; they can invite others from the portal's team card. An invitation is a
one-time code in the form `XXXXXX-XXXXXX-XXXXXX-XXXXXX`, valid for seven days, that the invited person types in after signing in and
completing MFA. The code carries a role:

| Role | Can |
|---|---|
| owner | everything an admin can, and is the only one who makes or changes admins; cannot be removed or demoted |
| admin | app keys and webhook secrets, the business identity, inviting and removing developers, putting an app forward for production |
| developer | the sandbox: apply manifests, run checks, simulate, make tokens for their own CI |

- A **token acts for the person who made it**. When that person leaves the team, their tokens stop working.
- A person belongs to one organisation at a time.
- **Two people for production.** While only one person can put an app forward (one owner or admin), that person may put forward what they
  applied. As soon as the organisation has two owners or admins, the version on file must be put forward by one who did **not** apply it,
  and the operator will not approve it until then. Applying the same manifest again does not change who is recorded as its applier; a new
  manifest needs a new submission.

## 18. Report usage, and keep files

Two platform services an installed app can use, each behind its own scope that you declare in the manifest (and explain in
`scopePurposes`).

**Telemetry (`telemetry:write`).** Tell the platform which of your features are used, so adoption shows beside the platform's own
figures. `POST /api/integration/telemetry` takes up to 100 events for one workspace: a feature name and, if you like, the member who
used it (a member who may open your app). It carries no content and no free-form field, by design. The platform files each event
under the module `app:<your app id>`, keeps the raw events 90 days and the daily totals longer. The SDK: `client.telemetry(entityId,
[{ feature: "report.exported", memberId }])`. 20,000 events per day per app and tenant.

**Files (`files:store`).** Keep a file for a workspace in the platform's private storage instead of running your own. It counts
against the organisation's storage quota, so a tenant sees one number. One app may keep at most 500 MB for one organisation,
uploads still in flight included; past that, delete older files before storing new ones.

1. `POST /api/integration/files` with the entity, name, content type and size (at most 20 MB). You get a link to `PUT` to.
2. `PUT` the bytes to the link, with the `upload_headers` you were given. The link is valid for ten minutes and is signed for exactly
   that type and size, so storage refuses anything else. Never send your app key to it.
3. `POST /api/integration/files/{fileId}/complete`. The platform checks the stored size; only then is the file kept and charged. An
   upload still unfinished after an hour is discarded by the next daily cleanup.

**Every stored file is scanned for viruses.** After step 3 the file's `scan_status` is `pending`, and a download answers `409`
`file_scanning` with `Retry-After` until the scan says `clean`, usually within half a minute. If the scanner finds malware, the bytes are
deleted at once, the size is given back, and the file answers `404` `file_infected` from then on: tell your user the file was refused.
Files stored before scanning was switched on show `not_scanned`. `scan_status` is in the complete, list and download answers.

Later: `GET /api/integration/files?entity_id=` lists your finished files, `GET /api/integration/files/{id}?entity_id=` gives a fresh
five-minute download link (it saves the file; it does not display it), and `DELETE` removes it and gives its size back. The SDK
does steps 1 to 3 in one call: `client.storeFile({ entityId, name, contentType, data })`.

You reach only your own files, on the workspaces you are installed on. **When an install ends, its files are deleted** (after your
uninstall ack), so export what the tenant asked for first (section 14). A tenant's erasure removes them too. Limits per app and
tenant: 2,000 uploads started per day, 20 unfinished at once.

## 19. A database Manajemin holds for you (sandbox)

If you would rather not run a database, Manajemin can create one for your app on Nile. Your code still runs on your own servers; only the
database is ours. **This is a sandbox: synthetic data only, never a customer's real data.** A production database comes later, after the
gate described in the developer terms, and has a price (below). It is switched on per environment, so the developer portal tells you when it
is not available yet.

1. **Declare it.** In the manifest, set `data.hosting` to `"managed-db"`, list `"Nile"` in `data.subprocessors` and name the region in
   `data.regions` (`aws-eu-central-1` or `aws-us-west-2`). The validator refuses `managed-db` without Nile, so the consent screen an
   organisation sees cannot leave out who holds the database. Apply the manifest first.
2. **Create it.** In the portal's "Database terkelola" card choose the app and the region, or run
   `manajemin db create <app id> --region aws-eu-central-1`. One database per app; up to 10 sandbox databases across the whole platform.
3. **Get a connection.** It is shown **once**, in the portal, and "Buat koneksi baru" makes another at any time. The CLI and the MCP server
   never return a connection: a connection is a secret, and a coding agent should not hold it. Use TLS with the certificate verified
   (Nile accepts a plaintext connection if the client does not ask for TLS).
4. **Use one tenant per install, with a fresh id each time.** The SDK's `withTenant(pool, tenantId, ...)` and `guardTenantTables` and the
   reference app (`workspaces` table) show the rules learned on real Nile: never use the entity id as the Nile tenant id, because Nile
   never frees a deleted tenant's id and an organisation that reinstalls your app could not get its workspace back.
5. **Watch it.** `manajemin db status` and the portal show the database's size (read once a day), the price and this month's draft.

**What it costs once it is live (not charged anywhere yet).** US$3 per GB-month, billed in USD and paid in rupiah before tax. Storage is
pooled: one 1 GB block is shared by all of your organisation's live databases, and each further started GB is another block, with at least
one block for a live organisation. Each block includes 30 million query tokens; use above that is billed per million (US$0.10). The price
is set by Manajemin, shown before you create anything, and a higher price takes effect only after notice. The price in force on the first day of a month prices that whole month, so a change in the middle of a month shows from the next month's statement. The monthly figure on the
portal is a **draft of usage**, not an invoice, and sandbox storage is free. Query tokens are not measured per database yet, and the
draft says so instead of showing zero.

Deleting the database deletes everything in it and cannot be undone. The size readings already taken stay, because past months were
priced from them.

## Where to look when something fails

- The delivery log in the developer portal shows every event, its status code and the next retry.
- **Failed API calls** (the app's Test tab, or the `get_failed_requests` MCP tool) lists your own calls the platform refused in the last 7
  days, grouped by minute: route, status, `code`, how many, and the last request id. A call with a wrong key is not listed: it could be
  anyone's.
- Errors share one shape: `{ "error": "...", "code": "...", "message": "...", "requestId": "..." }`. Branch on `code`; `message`
  is English for you, `error` is Indonesian for a person. Quote the `requestId` when you ask for help.
- A `429` carries `Retry-After` (seconds). Wait that long; a daily quota resets at 00:00 UTC.

| `code` | Status | What it means | What to do |
|---|---|---|---|
| `invalid_key` | 401 | The key is missing, malformed, rotated away or revoked | Check `X-Manajemin-Key`; create or rotate a key in the portal |
| `app_paused` | 403 | The app is paused (awaiting review, or paused by an operator) | Wait for approval; test in your sandbox meanwhile |
| `scope_missing` | 403 | The route needs a scope your manifest does not declare | Add it to `scopes` and apply the manifest |
| `scope_not_consented` | 403 | The organisation agreed to an older manifest without that scope | Ask its admin to agree to the current version |
| `not_installed` | 403 | The app is not installed on that entity, or the install token is wrong | Act only on entities you received `app.install` for |
| `install_paused` | 403 | The organisation's admin paused the app for that entity | Tell your users; calls resume when the admin resumes |
| `rate_limited` | 429 | Too many calls in the window | Wait `Retry-After` seconds |
| `daily_quota_exceeded` | 429 | The day's quota for this app and organisation is spent | Wait until 00:00 UTC (`Retry-After`) |
| `storage_quota_exceeded` | 429 | No storage left for this app or organisation | Delete files first |
| `file_scanning` | 409 | The file is still being scanned for viruses | Wait `Retry-After` seconds, then download again |
| `file_infected` | 404 | The virus scanner found malware; the file's bytes were deleted | Tell your user the file was refused; delete the record |
| `validation` | 400 | The body or query is invalid | Read `message`; check the OpenAPI document |
| `platform_halted` | 503 | Manajemin paused all third-party apps for an incident; deliveries to you wait in the queue, nothing is lost | Retry after `Retry-After`; nothing to fix on your side |

New codes may be added; existing ones are never renamed or removed.
