Skip to main content

Hosted Apps & Installs

Register a vendor-hosted app by its manifest URL, bind its requirement slots, install it with a capped service key, and prove its event subscription endpoint.

An app's content reaches Talonic in one of two ways, recorded in the manifest's source field. A pushed app is authored here: drafts saved through PUT /v1/apps/:id/logic, published versions, the editor in the web app. A hosted app is served from a vendor's own host: Talonic fetches its manifest from manifest_url, validates it, and stores each document it reads as a version. A hosted app has no draft — saving or publishing one is refused with 409 and reason hosted_manifest_read_only — so the vendor's document is the single source of truth, and every change goes through an owner's approval before it runs.

A manifest served from a vendor's host names itself: name (slug grammar — it becomes the app's slug on registration), display_name, and version are required, with description, homepage, and icon optional; version is the vendor's own string and informational, because a version's identity is its content hash. allowed_origins lists bare origins (https://host[:port], no path) the vendor vouches for; the origin the manifest was served from is always allowed, and the manifest's subscriptions_endpoint and surface.url must lie inside that set. A fetched manifest names no raw workspace id and claims no workspace-wide "*" grant: everything it needs from a workspace it declares as a requirement slot.

A hosted manifest (excerpt)

{
  "name": "invoice-scorer",
  "display_name": "Invoice scorer",
  "description": "Scores the invoices of one Spec.",
  "version": "1.4.0",
  "homepage": "https://vendor.example/docs",
  "allowed_origins": ["https://api.vendor.example", "https://app.vendor.example"],
  "surface": { "embed": "iframe", "scopes": ["apps:read"], "url": "https://app.vendor.example/embed" },
  "requires": [
    { "alias": "invoices", "kind": "spec", "access": "read", "purpose": "The Spec whose completed runs this app scores." },
    { "alias": "reviews", "kind": "review", "access": "act", "scope": "self" },
    { "alias": "scores", "kind": "data_product", "access": "read", "optional": true }
  ],
  "required_grants": [{ "kind": "spec", "id": "$requires.invoices", "access": "read" }],
  "subscriptions_endpoint": "https://api.vendor.example/talonic/events",
  "subscriptions": [{ "event": "pipeline.completed", "match": { "schema_id": "$requires.invoices" } }]
}

Requirement slots

Each requires[] entry is a slot: an alias (^[a-z][a-z0-9_]{0,31}$, unique per manifest), a grant kind, an access level (default read), a purpose of up to 300 characters that the install disclosure shows, and optionally optional: true. scope: "self" means the app itself — allowed on a review slot at any access and on an app slot with access act — and needs no binding. Anywhere a workspace id would stand — required_grants[].id, an input binding's source id, a promotion target, a subscription's match value, the target half of an acts[] entry — the manifest writes $requires.<alias> instead. Publish checks that every reference names a declared slot of a fitting kind and that every non-optional slot is referenced. At install, a bound slot becomes the real id, an unbound optional slot removes every entry that referenced it, and an unbound required slot is refused with slot_unbound. A slot earns an app nothing on its own: grants are minted from required_grants rows that reference it.

Validate, register, refresh

POST/v1/apps/manifest/validate

curl — check a manifest from CI

curl -s -X POST https://api.talonic.com/v1/apps/manifest/validate \
  -H "Content-Type: application/json" \
  -d '{"manifest_url": "https://app.vendor.example"}'
# → { "valid": false, "mode": "url",
#     "violations": [ { "pointer": "/surface/url", "keyword": "origin",
#       "message": "must lie in an origin the manifest vouches for (https://app.vendor.example)" } ],
#     "normalized": { ... },
#     "manifest_url": "https://app.vendor.example/.well-known/talonic-app.json", "manifest_path": "well-known" }

POST /v1/apps/from-url registers a hosted app (human workspace owner only): the platform fetches the manifest with the same discovery, validates it in the hosted published context, and stores it as version 1, published and active. The app is created enabled, so an install can run it at once — POST /v1/apps/:id/disable remains the kill switch — and its trigger floor is stamped at registration, so it never replays history it was not around for. The response is 201 { app, version, validation }. POST /v1/apps/:id/host converts an existing pushed app to hosted by pointing it at a manifest URL; from then on the draft editor refuses writes.

POST /v1/apps/:id/refresh (human owner only) reads the stored manifest URL again. A changed document is stored as a new version with status fetched and nothing else moves: the active version, every install's approval state, and the subscription rows stay exactly as they were, and the app.version_fetched webhook announces the candidate. A vendor deploy can therefore never cause an outage or bypass re-approval. A fetch or validation failure is recorded on the app (last_fetch_error) and answered with 422 and reason refresh_failed; the app keeps running on its last approved version either way.

Install, approve, and the service key

POST /v1/apps/:id/install binds the app to the workspace; every install route is configure tier and human-owner only, because installing mints a credential and admits a browser origin. The body is optional field by field: bindings ({ "<alias>": "<uuid>" } for each required, non-self slot), confirm_subscriptions_endpoint (the manifest's endpoint retyped by the owner, compared byte for byte, required whenever the app subscribes to anything), surface_url (pushed apps only — a hosted surface URL comes from the manifest), allowed_origin (one exact https origin, which requires an oauth_client_id whose redirect URI shares its host), and key_name. The response carries the service key and the subscription secret in plaintext exactly once.

The service key is deliberately narrow. It holds the decide grant on its own app plus exactly the grants the manifest's required_grants resolve to, and nothing else: it is minted with the read scope only, it reaches a shared /v1 route only where a grant covers the resource, and Sources access rules remain the ceiling in front of every grant. Any refusal it receives reads insufficient_grant. Revoking the key suspends the install for good (it cannot be resumed — install afresh), and GET /v1/apps/:id/install/status is the one install route the key itself may call, even while paused, to see why it is being refused.

Approval is per version. Publishing a new version of a pushed app parks every live install on pending_approval_version, and a hosted app's fetched candidate only ever becomes active through that same approval: until an owner approves, the install is fail-closed — no surface session, no run start, no decision-task offer, no delivery — and the key receives install_pending_approval. GET /v1/apps/:id/install/diff[?version=N] shows what approving would change: grants and subscriptions added or removed, surface scopes, slots, vouched origins, identity, and whether the endpoint or surface moved. POST /v1/apps/:id/install/approve with { "version": N } (plus confirm_subscriptions_endpoint when the endpoint moved, and bindings for new slots) re-runs the install checks and lifts the pause; it is a compare-and-set, so it answers 409 if a newer version appeared after you read the diff.

curl — install and approve

curl -s -X POST https://api.talonic.com/v1/apps/$APP_ID/install \
  -H "Authorization: Bearer $OWNER_SESSION_TOKEN" -H "Content-Type: application/json" \
  -d '{ "bindings": { "invoices": "3f9c9e3a-1b2c-4d5e-8f60-1234567890ab" },
        "confirm_subscriptions_endpoint": "https://api.vendor.example/talonic/events",
        "key_name": "Invoice scorer" }'
# → { "install": { "status": "installed", "approved_version": 1, "pending_approval_version": null,
#                  "subscriptions": [ { "event_type": "pipeline.completed", "status": "pending_verification", ... } ],
#                  "bindings": [ { "alias": "invoices", "kind": "spec", "resource_id": "3f9c9e3a-…" } ], ... },
#     "service_key": "tlnc_…", "masked_prefix": "tlnc_ab12...",
#     "subscription_secret": "…", "warning": "Store this key now. It will not be shown again." }

# Later: review and approve a fetched candidate
curl -s "https://api.talonic.com/v1/apps/$APP_ID/install/diff?version=2" -H "Authorization: Bearer $OWNER_SESSION_TOKEN"
curl -s -X POST https://api.talonic.com/v1/apps/$APP_ID/install/approve \
  -H "Authorization: Bearer $OWNER_SESSION_TOKEN" -H "Content-Type: application/json" -d '{"version": 2}'

Event subscriptions

A manifest's subscriptions[] name outbox events the app wants pushed to its subscriptions_endpoint, optionally narrowed by match. Each event must be covered by a grant the app holds: document.* by a document-source grant, pipeline.*, run.*, result.*, and dispatch.* by a spec or pipeline grant, review.item.* by a review grant, and data_product.updated by a data_product grant; delivery is filtered by those grants per event. An endpoint is claimed by the manifest, confirmed by the owner's byte-for-byte echo, and proven before anything is delivered: the platform POSTs { "type": "talonic.subscription.verify", "install_id", "nonce" }, and the endpoint must answer { "nonce_echo": hex(HMAC-SHA256(secret, nonce)) }. Every delivery then carries X-Talonic-Signature: sha256=<hex HMAC-SHA256(secret, raw body)>, X-Talonic-Idempotency-Key: <subscription_id>:<event_id> (stable across retries), and event id, type, and attempt headers.

TypeScript — a receiver that answers the challenge and verifies deliveries

import { createHmac, timingSafeEqual } from 'node:crypto';

const secret = process.env.TALONIC_SUBSCRIPTION_SECRET!;
const hmac = (data: string | Buffer) => createHmac('sha256', secret).update(data).digest('hex');

export function handle(rawBody: Buffer, headers: Record<string, string>) {
  const expected = Buffer.from(`sha256=${hmac(rawBody)}`);
  const given = Buffer.from(headers['x-talonic-signature'] ?? '');
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) return { status: 401 };

  const body = JSON.parse(rawBody.toString('utf8'));
  if (body.type === 'talonic.subscription.verify') {
    return { status: 200, json: { nonce_echo: hmac(body.nonce) } };   // ownership proof
  }
  // Dedupe on X-Talonic-Idempotency-Key, then process the event.
  return { status: 200 };
}

Consecutive delivery failures suspend a subscription and emit app.subscription.suspended; an owner re-proves the endpoint with POST /v1/apps/:id/install/verify-subscriptions. POST /v1/apps/:id/install/rotate-secret re-mints the shared secret (shown once) and re-runs the challenge before anything is delivered under it. POST /v1/apps/:id/install/suspend (optional reason) stops offers and invalidates every open decision-task lease; POST /v1/apps/:id/install/resume brings it back on the approved version (409 when the key was revoked); PATCH /v1/apps/:id/install re-points the surface URL, origin, OAuth client, or slot bindings; and DELETE /v1/apps/:id/install uninstalls, revoking the key and keeping the row for the audit trail. Suspension and resumption are announced by the app.install.suspended and app.install.resumed webhooks.

An install's allowed origin joins the deployment-wide CORS allowlist, not just your workspace's. CORS is defence in depth only — the bearer token is the boundary — so approve an origin exactly as deliberately as you would hand that host a credential.

Frequently asked questions

Does refreshing a hosted app change what runs?+
No. A refresh only stores the changed document as a fetched candidate and fires app.version_fetched. The approved version keeps running until a workspace owner reviews the diff and approves the candidate by version number.
Can an API key install or approve an app?+
No. Every install route, registration from a URL, refresh, and host conversion requires a human workspace owner session; machine callers are refused whatever grants they hold, because installing mints a credential and admits a browser origin.
What can the install's service key reach?+
Its own app's runs, reviews, and decision tasks, plus exactly the resources its approved required_grants cover, filtered further by Sources access rules. Everything else answers 403 with reason insufficient_grant, which tells the app the owner has to grant it.
How do I test a manifest before registering it?+
Call POST /v1/apps/manifest/validate from your CI with either the manifest URL or the document inline. It needs no credential, has no side effects, and always answers 200 with valid and a list of violations, each carrying a JSON pointer, keyword, and message.
Why are my events not arriving?+
Check the install's subscriptions in GET /v1/apps/:id/install: a row stays pending_verification until the signed-nonce challenge succeeds, and a suspended row stopped after repeated failures. Fix the endpoint, then run verify-subscriptions; also confirm the app holds a grant covering the event type.