Apps: registry, installs and consent
An app extends the Ivora dashboard for one team and can call the API on a project's behalf. A team registers its apps; each project of the team decides whether to install one and exactly which scopes it may hold. Apps are private: only the owning team's projects can install them. Ivora's own apps are the exception: they are first-party, and every project can install them.
Hosting the app on a serverless platform? See Building serverless apps.
All routes below are for a signed-in person. API keys and connected MCP apps
are refused (403 forbidden), so an automation never grants itself an app.
Note
The Ivora dashboard does not show apps in production yet. The API routes on this page are available in production.
Register an app
POST /v1/teams/{team_id}/apps (team owners, admins and developers, and Ivora
administrators):
{
"name": "Fleet ops",
"ui_origins": ["https://fleet.ivoraapps.com"],
"extensions": [
{"point": "project.tab", "path": "/", "title": "Fleet ops"},
{"point": "station.panel", "path": "/station", "title": "Warranty"},
{"point": "overview.widget", "path": "/widget", "size": "1x1"},
{"point": "action", "target": "station", "path": "/action", "title": "Open ticket"}
],
"scopes": ["stations:read", "stations:control"],
"webhook_url": "https://fleet.example.com/ivora/webhooks"
}
ui_originsarehttpsorigins with no path and no port, and each is one label (1-63 characters) under the environment's apps domain:https://<app>.ivoraapps.comin production,https://<app>.ivoraapps.devin preproduction. Any other host, an explicit port (even:443) or a longer label is refused with 422app_origin_outside_apps_domain, on registering and on updating. Only a test environment may setIVORA_APPS_DOMAINto another domain; production and previews refuse to start otherwise. Hosts on Ivora's own domains are refused (app_origin_refused): the dashboard's sign-in cookie covers them, so the app would receive it. Host the app on its own domain.- Apps registered before this rule keep working (the check runs on register and
update only), but their next
PATCHofui_originsmust move to the apps domain. Ivora contacts each team whose app still uses another origin; re-host the UI andPATCHthe origin (a new origin needs a new consent). extensionssay where the dashboard may show the app:project.tab,station.panel,session.panel,overview.widgetoraction.- The response carries
client_secretonce. Ivora stores only its digest; rotate it withPOST /v1/teams/{team_id}/apps/{app_id}/secret. - A team registers at most 50 apps (
app_limit). - Changing, archiving or rotating the secret of an app, and listing its installs, reaches every project it is installed in. A developer limited to some of the team's projects can do it only while every active install is in one of their projects; otherwise a team owner or admin does it.
Scopes an app may request
An app may request any project scope except billing:refund and
stations:admin (app_scope_forbidden). It never reaches /v1/admin, API-key
management, members or payment methods. Four scopes can act on chargers,
money or prices and are marked sensitive on the consent screen:
| Scope | Lets the app |
|---|---|
stations:control | Send real commands to chargers |
billing:write | Create bills and start or stop paid sessions |
tariffs:write | Set prices: create, change and archive tariffs and choose the tariff of a connector, location or project |
settlement:write | Report externally settled payments |
An app sets prices only with tariffs:write: billing:write never creates,
changes or assigns a tariff; a bill may still name another of the project's
tariffs, reported as tariff_differs_from_connector.
Each install has its own request budget, 120 requests a minute by default. Ivora administrators can change it.
Install, with consent
GET /v1/tenants/{tenant_id}/appslists the team's apps, and Ivora's first-party apps, with this project's active install, if any.GET /v1/tenants/{tenant_id}/apps/{app_id}/consentis the consent screen: the requested scopes with descriptions and sensitive flags, the rate limit and arevision.POST /v1/tenants/{tenant_id}/apps/{app_id}/installwith the scopes you accept and thatrevisionasconsent_revision.
Only a team owner, admin or developer of the project (or an Ivora administrator, with a reason) installs. You can grant only scopes you hold yourself. An install never holds more than the app requests, and staff roles are never carried into it: it has exactly the scopes that were granted.
consent_revision ties the consent to what was shown. When the app's scopes,
origins or extensions change, its revision rises and an old consent answers
409 consent_outdated (details.revision holds the current one). Narrowing the
app's requested scopes removes them from every install at once; widening them
grants nothing until an installer consents again.
PATCH /v1/tenants/{tenant_id}/installs/{install_id} replaces an install's
scopes with a fresh consent.
First-party apps
An app with visibility: first_party is Ivora's own, such as Ivora Checkout.
Only Ivora administrators set it, when registering the app or with
PATCH /v1/teams/{team_id}/apps/{app_id} (403 forbidden for anyone else);
each change is in the team's audit trail (app.visibility_changed).
- Every project lists it in
GET /v1/tenants/{tenant_id}/appsand can install it, with the same consent as any app. In a project of another team, only that team's owners and admins install it (403 forbiddenfor a developer); in the registering team's own projects, owners, admins and developers do, as for any app. It carriespublisher: "Ivora", which the dashboard shows as made by Ivora. - Read from another team's project, it has no
secret_prefix,secret_rotated_at,created_byorwebhook_url(null). Its registry, secrets, deliveries and installs list stay with the team that registered it. - Only Ivora administrators change its
webhook_url, itsui_origins, its client secret (POST .../secret) or its webhook signing key (POST .../webhook-secret): anyone else, including the registering team's owners, admins and developers, gets403 app_first_party_staff_only. Reads stay as they are, and an unchanged value in aPATCHis accepted. Each staff change is in the team's audit trail (app.first_party_changed) with the actor and, for the webhook URL and origins, the values before and after; a rotation of either secret records only that it happened, never the secret. - It becomes
privateagain only once no project outside its team has it installed (409 app_installed_outside_team).
Installs by Ivora staff
A project's own owners, admins and developers normally install apps. When a
project asks Ivora to do it, an Ivora administrator who is not a member of the
project's team installs with a reason:
{"scopes": ["stations:read"], "consent_revision": 3, "reason": "Requested by the project owner, ticket 4521"}
Without one the install answers 422 staff_reason_required; the consent view's
reason_required says when it is needed. The install shows the reason as
staff_reason, and the project's audit trail records it with the installer
(app.installed, with staff: true). Every other install is recorded there
too, with staff: false.
Revoke
DELETE /v1/tenants/{tenant_id}/installs/{install_id} uninstalls the app and
revokes everything it held at once. DELETE /v1/teams/{team_id}/apps/{app_id}
archives the app and revokes every install in every project
(revoked_reason: app_archived). A revoked install stays in
GET /v1/tenants/{tenant_id}/installs for the record; install the app again to
restore access.
Lifecycle webhooks
When an app has a webhook_url, Ivora sends it a signed POST for each change to
one of its installs:
type | When | data |
|---|---|---|
app.installed | A project installs the app | scopes, consent_revision, rate_limit_per_minute, installed_by |
app.scopes_changed | The install's scopes change: a new consent (reason: consent) or the app stopped requesting some (reason: app_narrowed) | scopes (now), previous_scopes, reason, consent_revision, changed_by |
app.uninstalled | The install is revoked | reason (uninstalled or app_archived), revoked_by |
The body is {"id", "type", "app_id", "install_id", "tenant_id", "created_at", "data"};
it never carries a token or a secret. Ivora-Signature: t=<unix>,v1=<hex> is the HMAC-SHA256
of <t>.<body> with the app's webhook secret, as for project webhooks;
Ivora-Event-Id and Ivora-App-Id repeat the event and the app. Answer with a 2xx.
Deliveries retry with backoff for about a day and are at least once: deduplicate by event
id. Ivora calls public https addresses only and never follows redirects.
Each attempt goes to the app's webhook_url and is signed with its webhook secret as
they are at that moment: after you change the URL or replace the secret, events still
pending go to the new URL with the new signature. Delivered events are listed for 30 days.
- The webhook secret (
whsec_...) is returned once, with the client secret, when the app is registered.POST /v1/teams/{team_id}/apps/{app_id}/webhook-secretreplaces it (and issues the first one for an app registered before lifecycle webhooks). Rotating the client secret leaves it alone. For a first-party app only Ivora administrators replace it (403 app_first_party_staff_only). - An app without a
webhook_url, or without a webhook secret, receives nothing. GET /v1/teams/{team_id}/apps/{app_id}/deliveries?after=<sequence>&limit=lists the events with their deliverystatus(pending,delivered,failed),attempts,last_statusandlast_error. A developer limited to some projects reads their projects' events only, and only while the app is active in no project outside them.- Changing the app's
ui_origins, scopes or extensions raises its revision; installs consented to an earlier revision keep working but are not told. Narrowing the scopes is reported asapp.scopes_changedper install.
App tokens
An installed app acts on its project with a token Ivora signs (ES256). Verify
one with the public keys at GET /v1/apps/jwks.json (no credential; cache for
a few minutes, refetch on an unknown kid). Its issuer is the API origin and
its audience is the app's id.
| Token | How | Lifetime | Who it acts as |
|---|---|---|---|
| Session token | POST /v1/apps/session-tokens with {"install_id": ...}, by a signed-in project member (the dashboard does this for the app's UI) | 5 minutes | the member |
| Access token | POST /v1/oauth/token, grant_type=urn:ietf:params:oauth:grant-type:token-exchange with the session token as subject_token | 10 minutes | the same member |
| Client credentials | POST /v1/oauth/token, grant_type=client_credentials with install_id | 10 minutes | the install itself, no user |
The token endpoint takes application/x-www-form-urlencoded. The app's backend
authenticates with client_id (the app's id) and client_secret, in the body
or by HTTP Basic. A scope parameter narrows the token. Errors use the RFC 6749
body (error, error_description): invalid_client (401), invalid_grant,
invalid_scope, invalid_request, unsupported_grant_type.
Scopes are the install's consented scopes, within what the app still requests.
For a token that names a member they are also within that member's current team
role in the project, so a viewer's session token reads and nothing more. An
Ivora staff role is never part of it: a platform administrator who is not a
member of the project's team gets 403 from the session endpoint.
Every request re-reads the install and the member's role, so uninstalling,
archiving the app, narrowing its scopes or removing the member takes effect on
the next call, and a token never outlives them. An app token is refused on
/v1/admin, API-key, member, app-registry and payment-method routes, and cannot
ask for another token. A session token stays valid only while you can trust the
UI that holds it: when an update adds a UI origin, installs consented earlier
get 409 consent_outdated from the session endpoint until an owner, admin or
developer consents again (PATCH the install). The app's backend tokens are
not affected, and removing an origin needs no new consent.
CORS reads the app's current registered origins (cached for 15 seconds), not the
install's consent. An origin you add, but that an install has not yet consented
to again, can therefore already call /v1 with the app tokens that exist until
those tokens expire (5 minutes for a session token, 10 for an access token). No
new session token is issued for that install until it consents.
Minted tokens are written to the audit log, one entry
per install, kind (app.token.session, app.token.exchange,
app.token.client_credentials) and hour, with the number minted. The actor is
app:<install_id>. A request made with an app token that names a user is
recorded as app:<install_id>/user:<user_id>, which the audit actor filter
accepts.
Each install has its own request budget (rate_limit_per_minute, 120 by
default): every token of the install counts in it, whoever holds them. Browsers
may call /v1 from the app's registered UI origins with that app's tokens
(CORS); no cookies or credentials are ever allowed, and no other origin is
admitted. Never put an API key or the client secret in browser code.
An app that creates payments for anonymous drivers from its server, such as a
driver checkout, can have each driver's IP address count in the per-address
payment limit (rate limits). Ask Ivora to mark
the app checkout_service: only Ivora administrators set it (PATCH the app
with {"checkout_service": true}; 403 for anyone else, 422 for an app that
does not request billing:write), every change is recorded in the
audit log as app.checkout_service, and the app's
record shows it. The app's backend then sends X-Ivora-Driver-Address on
POST /payment/{service}/v1/tenants/{tenant_id}/payments with its
client-credentials token for that project's install, which must hold
billing:write. The header is ignored, and logged, on session and exchanged
tokens (they act for a dashboard member, not for drivers) and on any app not
marked. A change takes effect within a minute.
Operators set IVORA_APP_TOKEN_KEY (or _FILE), an EC P-256 private key in PEM
form. To rotate it, set IVORA_APP_TOKEN_KEY_PREVIOUS to the old key for
10 minutes. Without a key a shared ledger answers 503.
Errors
| Code | Status | Meaning |
|---|---|---|
app_scope_forbidden | 422 | A scope an app cannot hold |
app_origin_outside_apps_domain | 422 | A UI origin that is not https://<label>.<apps domain> of this environment |
app_origin_refused | 422 | A UI origin on an Ivora domain |
app_limit | 409 | The team registers 50 apps already |
app_archived | 409 | The app is archived |
app_already_installed | 409 | Already installed in this project |
consent_outdated | 409 | The app changed after the consent screen |
install_revoked | 409 | The install was uninstalled or its app archived |



