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

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)

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.

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.

ScopeRouteWhat it does
members:readGET /api/integration/membersThe workspace's members who may open your app
audit:writePOST /api/integration/auditWrites an entry in the organisation's tamper-evident activity log
notify:sendPOST /api/integration/notifyEmails one of those members through Manajemin (5 per member per hour)
ai:invokePOST /api/integration/aiAsks 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:

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

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

ScenarioWhat it sendsWhat it expects
lifecycleThe lifecycle events your manifest subscribes to (plan changes, a suspended member, a closed tenant)A 2xx for each
duplicateThe same event twice with the same X-Manajemin-DeliveryA 2xx both times, and the effect once (check your data)
out_of_orderA newer event, then an older oneA 2xx both times; the older must not undo the newer (compare generatedAt)
unknown_eventAn event type you do not knowA 2xx: new events arrive within an API version
bad_signatureAn event signed with the wrong secretA 4xx (401), and nothing stored
stale_timestampA correctly signed event an hour oldA 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:

RoleCan
ownereverything an admin can, and is the only one who makes or changes admins; cannot be removed or demoted
adminapp keys and webhook secrets, the business identity, inviting and removing developers, putting an app forward for production
developerthe 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.
codeStatusWhat it meansWhat to do
invalid_key401The key is missing, malformed, rotated away or revokedCheck X-Manajemin-Key; create or rotate a key in the portal
app_paused403The app is paused (awaiting review, or paused by an operator)Wait for approval; test in your sandbox meanwhile
scope_missing403The route needs a scope your manifest does not declareAdd it to scopes and apply the manifest
scope_not_consented403The organisation agreed to an older manifest without that scopeAsk its admin to agree to the current version
not_installed403The app is not installed on that entity, or the install token is wrongAct only on entities you received app.install for
install_paused403The organisation's admin paused the app for that entityTell your users; calls resume when the admin resumes
rate_limited429Too many calls in the windowWait Retry-After seconds
daily_quota_exceeded429The day's quota for this app and organisation is spentWait until 00:00 UTC (Retry-After)
storage_quota_exceeded429No storage left for this app or organisationDelete files first
file_scanning409The file is still being scanned for virusesWait Retry-After seconds, then download again
file_infected404The virus scanner found malware; the file's bytes were deletedTell your user the file was refused; delete the record
validation400The body or query is invalidRead message; check the OpenAPI document
platform_halted503Manajemin paused all third-party apps for an incident; deliveries to you wait in the queue, nothing is lostRetry after Retry-After; nothing to fix on your side

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