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, commandmanajemin) 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>wherehex = HMAC-SHA256(webhook secret, "<t>.<raw body>"). There can be more than onev1=(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 anyv1matches. 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/usagereports usage deltas (not totals) for the metrics you declared;dedupe_keymakes 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:
{
"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 inendpoints.baseUrl, with no redirect, containing the single linemanajemin-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, andX-Frame-Optionsor a CSPframe-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 onX-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'sreceive()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:writeandevents: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
ackof an install returns aninstall_tokenonce, and you send it asX-Manajemin-Install-Tokenon every call that names an entity.@manajemin-dev-cli/sdkdoes 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.
| 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:
- The live version keeps serving. Your apply answers with
rollout: { id }. - 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).
- 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.
- 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.
- 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.
POST /api/integration/fileswith the entity, name, content type and size (at most 20 MB). You get a link toPUTto.PUTthe bytes to the link, with theupload_headersyou 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.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.
- Declare it. In the manifest, set
data.hostingto"managed-db", list"Nile"indata.subprocessorsand name the region indata.regions(aws-eu-central-1oraws-us-west-2). The validator refusesmanaged-dbwithout Nile, so the consent screen an organisation sees cannot leave out who holds the database. Apply the manifest first. - 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. - 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).
- Use one tenant per install, with a fresh id each time. The SDK's
withTenant(pool, tenantId, ...)andguardTenantTablesand the reference app (workspacestable) 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. - Watch it.
manajemin db statusand 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_requestsMCP 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 oncode;messageis English for you,erroris Indonesian for a person. Quote therequestIdwhen you ask for help. - A
429carriesRetry-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.