A Crystallize Plugin is a vendor-hosted application that extends the Crystallize App UI with custom functionality — rendered inside iframes at predefined placement points. Plugins don't run code in the App UI itself; they receive a scoped Backend Token to act on behalf of the signed-in user via Crystallize APIs, and any secrets they need are encrypted client-side with the vendor's own public key.
Plugins are server-hosted. The iframe is loaded via a form POST carrying an encrypted payload that contains the Backend Token, configuration, and secrets. Plugin vendors must operate a backend that can handle POST requests and decrypt the payload — a Cloudflare Worker, Vercel/Netlify edge function, Lambda, or any server-side runtime. Pure static file hosting is not sufficient.
The contract establishes the interface between three parties:
Three entities manage the lifecycle of a plugin from creation to installation.
Where plugins are defined. A developer creates a plugin by calling a mutation on /@me, providing the plugin metadata (name, description, logo, etc.) and its first revision.
Every plugin has a state:
pending — submitted, awaiting validation by Crystallizeactive — validated, visible in the Plugin Store and installableinactive — disabled. The plugin is no longer installable and stops working immediately across all tenants (live auth rejects any request made with a token whose act.pluginId matches an inactive plugin — the kill is not bounded by token TTL).A plugin can't be installed until it reaches active state.
A plugin evolves through revisions. A revision locks the contract surface — the fields that define what the plugin is allowed to do and where it shows up:
upstream — the base URL of the plugin originentryPoints — where the plugin appears in the UIscopes — the permissions the plugin requiresconfigurationSchema — the JSON Schema defining the install-time configuration formsecrets — which configuration fields are secretspublicKey — the JWK public key for payload encryptionpostInstallationUri — path where Crystallize POSTs installation dataversion — vendor-declared semver for this revisionA revision is immutable once submitted. The code running at upstream is entirely under the vendor's control — bug fixes, UI changes, and performance improvements can be deployed anytime without a new revision. A new revision is only needed when the contract itself changes.
Plugin-level fields (name, description, logo, etc.) live on the plugin and can be updated independently of revisions. They're cosmetic — they don't affect what the plugin is allowed to do.
Revision fields are locked per revision because they directly affect the Backend Token: when a user installs a plugin, they consent to a specific upstream, a specific set of entrypoints, and a specific set of scopes. The token issued at runtime is bounded by that consent. Locking the contract per revision ensures the token always reflects what was approved at installation.
Each plugin carries an approvedRevisionId pointer. This is the revision that the Plugin Store surfaces and that new installations default to. Installations pin a specific revisionId and never auto-migrate — upgrading an installation to a newer revision requires the user to re-install.
The full mutation input is detailed in the Revision Fields section.
Where installations live. When a user installs a plugin on their tenant, the Plugin Registry stores the result: which tenant, which plugin, which revision, the non-secret configuration, the granted scopes, and the encrypted secrets.
One plugin can be installed on many tenants, each with its own configuration and scopes. Each (tenant, plugin) pair maps to exactly one installation.
To change configuration, scopes, or revision, the plugin must be re-installed. Re-install is atomic and keeps the same installationId.
The public-facing catalog of available plugins. Only plugins in active state appear, enriched with marketing content (descriptions, screenshots, vendor info, etc.).
Users browse the Plugin Store in the App UI to discover and install plugins.
A valid Plugin must satisfy:
/@me in Crystallize Core.postInstallationUri) where Crystallize POSTs configuration and encrypted secrets at install / reinstall / uninstall time.All URLs follow the same pattern:
$upstream/$tenantIdentifier/$path$upstream/$tenantIdentifier/$path$upstream is the plugin's declared upstream URL (from the revision).$tenantIdentifier is the Crystallize tenant the request is scoped to.$path is either postInstallationUri (for install events) or an entrypoint's target (for iframe loads).When a user installs, re-installs, or uninstalls a plugin on their tenant, Crystallize POSTs a JWE-encrypted body to:
$upstream/$tenantIdentifier/$postInstallationUri$upstream/$tenantIdentifier/$postInstallationUriThe plaintext body (after decryption) is:
type PostInstallBody = {
event: 'install' | 'reinstall' | 'uninstall'
tenantIdentifier: string
installationId: string
userId: string // installer / actor
configuration?: Record<string, JsonValue> // omitted for uninstall
encryptedSecrets?: Record<string, string> // JWE-per-field, omitted for uninstall
pluginIdentifier: string
revisionId: string
issuedAt: number // epoch seconds
}
type PostInstallBody = {
event: 'install' | 'reinstall' | 'uninstall'
tenantIdentifier: string
installationId: string
userId: string // installer / actor
configuration?: Record<string, JsonValue> // omitted for uninstall
encryptedSecrets?: Record<string, string> // JWE-per-field, omitted for uninstall
pluginIdentifier: string
revisionId: string
issuedAt: number // epoch seconds
}
Delivery semantics: fire-and-forget, best-effort, no retries. A 2xx response counts as success; anything else is logged and ignored. The install/reinstall/uninstall mutation does not block on the vendor's response — the user-visible operation completes regardless. Vendors should therefore design their post-install handler to be idempotent and to tolerate losing an occasional event.
All other endpoints (the ones loaded inside iframes) are addressed by an entrypoint's target:
$upstream/$tenantIdentifier/$target$upstream/$tenantIdentifier/$targetThese endpoints are called via POST. The body contains a single encrypted payload (see Plugin Loading Protocol).
The vendor chooses the target paths — the entrypoints registered in the revision are the source of truth.
The plugin definition is submitted via a mutation on /@me. It lives in the Plugin Collection.
The definition has two layers: plugin-level fields describing the plugin (updatable anytime) and revision-level fields defining the contract (locked per revision).
These describe the plugin itself. They can be updated independently of revisions.
Field | Type | Description |
|---|---|---|
|
| Display name of the plugin |
|
| Reverse-DNS identifier, globally unique, immutable (e.g. |
|
| (optional) Author / vendor name |
|
| (optional) Short description |
|
| (optional) URL to a logo image |
|
| (optional) URL to an icon image |
These define the contract surface and are locked per revision. Changing any of them requires a new revision via createPluginRevision
Field | Type | Description |
|---|---|---|
|
| Base HTTPS URL of the plugin origin |
|
| Where the plugin appears in the UI (see Entrypoints) |
|
| Permissions the plugin requires (see Scopes) |
|
| JSON Schema (draft 2020-12) defining the install-time configuration form (see Configuration) |
|
| Configuration property names to treat as secrets (see Secrets) |
|
| JWK public key used to encrypt secrets at install time and the iframe payload at runtime (see Cryptography) |
|
| Path where Crystallize POSTs installation data (see Post-Installation Endpoint) |
|
| Semver string. The plugin's "current version" is the |
input CreatePluginRevisionInput {
upstream: String!
entryPoints: [PluginEntryPointInput!]!
scopes: [PluginScope!]
configurationSchema: JSON
secrets: [String!]
publicKey: PluginPublicKeyInput!
postInstallationUri: String!
version: String!
}input CreatePluginRevisionInput {
upstream: String!
entryPoints: [PluginEntryPointInput!]!
scopes: [PluginScope!]
configurationSchema: JSON
secrets: [String!]
publicKey: PluginPublicKeyInput!
postInstallationUri: String!
version: String!
}The scopes field declares the permissions the plugin needs. This is what the installer reviews before granting access.
{
"scopes": ["order:read", "order:write", "customer:read"]
}{
"scopes": ["order:read", "order:write", "customer:read"]
}At install time:
order:read and order:write, the installer can grant only order:read.V1 behavior: granted scopes are recorded on the installation but are not enforced when minting the Backend Token. In V1, the Backend Token carries the current user's full permissions (bounded by the user's own role, as always). Scope intersection enforcement — narrowing the token to grantedScopes ∩ userPermissions — is planned for V2. Declare scopes honestly now so that the installer's consent is meaningful and so that V2 will "just work" for your plugin.
The revision must include a publicKey object in JWK format (RFC 7517). This key serves two purposes:
Only the vendor holds the matching private key. Crystallize never holds plaintext secrets.
The key must be an RSA key with:
JWK field | Required value |
|---|---|
|
|
|
|
|
|
|
|
JWE compact serialization is used on the wire.
{
"publicKey": {
"kty": "RSA",
"kid": "public",
"use": "enc",
"alg": "RSA-OAEP-256",
"enc": "A256GCM",
"n": "<modulus>",
"e": "AQAB"
}
}{
"publicKey": {
"kty": "RSA",
"kid": "public",
"use": "enc",
"alg": "RSA-OAEP-256",
"enc": "A256GCM",
"n": "<modulus>",
"e": "AQAB"
}
}A plugin is a single application deployed once by the vendor, but it can be installed on many tenants, each with its own context. The configuration is what makes each installation unique.
The revision declares the shape of the configuration as a JSON Schema. When a user installs the plugin, the App UI renders that schema as a form. The values the user fills in become that installation's specific configuration.
The same Invoice App can be installed on Tenant A with a purple theme pointing to Stripe instance X, and on Tenant B with a blue theme pointing to Stripe instance Y. The plugin code is identical — the configuration is what differentiates each installation.
Configuration is set at installation time and stored in the Plugin Registry. Non-secret values are stored in plaintext; secrets are stored as ciphertext (see below). To change any of it, the plugin must be re-installed.
The configurationSchema field must be a valid JSON Schema, draft 2020-12.
{
"configurationSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["organizations"],
"properties": {
"StripeApiKey": {
"type": "string",
"title": "Stripe API Key",
"description": "API Key of the Stripe instance"
},
"organizations": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["label", "email"],
"properties": {
"label": { "type": "string", "title": "Organization Label" },
"email": { "type": "string", "title": "Organization Email" },
"address": { "type": "string", "title": "Organization Address" }
}
}
}
}
}
}
{
"configurationSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["organizations"],
"properties": {
"StripeApiKey": {
"type": "string",
"title": "Stripe API Key",
"description": "API Key of the Stripe instance"
},
"organizations": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["label", "email"],
"properties": {
"label": { "type": "string", "title": "Organization Label" },
"email": { "type": "string", "title": "Organization Email" },
"address": { "type": "string", "title": "Organization Address" }
}
}
}
}
}
}
At install and re-install, Crystallize validates the submitted config against this schema (with secret-field nodes excluded from the validated subset).
A top-level array of configuration property names to treat as secrets:
{
"secrets": ["StripeApiKey", "mySuperSecretPassword"]
}{
"secrets": ["StripeApiKey", "mySuperSecretPassword"]
}Every name in secrets[] must resolve to a property in configuration.
When a property is listed in secrets, the App UI:
<input type="password"> for that field.publicKey, producing a JWE compact string.{ config, encryptedSecrets } to Crystallize. The plaintext secret value never leaves the user's browser.Crystallize stores the ciphertext verbatim. Each stored ciphertext is tagged with the revisionId it was encrypted against.
Re-install rules:
publicKey. Crystallize rejects stale-tagged ciphertexts to prevent silent leaks if a vendor rotates keys.At runtime, Crystallize passes the stored ciphertexts through into the iframe payload as-is. The payload itself is then wrapped in an outer JWE using the same publicKey. The plugin's backend:
encryptedSecrets[field] — a per-field JWE — and decrypts it a second time with the same private key.Only the vendor can decrypt secrets. Crystallize never holds plaintext.
Entrypoints declare where the plugin appears in the Crystallize App UI. Each entrypoint defines a unique placement.
Placements follow the convention:
$CONCERN/$VIEW/$PLACEMENT(/$TYPE?)$CONCERN/$VIEW/$PLACEMENT(/$TYPE?)order, customer, subscription-contract, dashboard, …view, edit, nerdy, developer, createtoolbar-button, main, side-widget, main-widgetdialog, widget, linkExamples:
orders/nerdy/toolbar-button (dialog by design)
orders/nerdy/toolbar-action/link (redirect by design)
orders/nerdy/toolbar-action/button (dialog by design)
orders/nerdy/main-widget (inline by design)
order/view/main (widget by design)
order/view/toolbar-button (dialog by design)
order/view/sidebar (widget by design)orders/nerdy/toolbar-button (dialog by design)
orders/nerdy/toolbar-action/link (redirect by design)
orders/nerdy/toolbar-action/button (dialog by design)
orders/nerdy/main-widget (inline by design)
order/view/main (widget by design)
order/view/toolbar-button (dialog by design)
order/view/sidebar (widget by design)In V1, placement is a free string (no enum enforcement). Stick to the convention so your plugin lines up with where the App UI actually renders entrypoints.
Each entrypoint has the following fields:
Field | Type | Description |
|---|---|---|
|
| Server-assigned at revision creation. Used by the App UI in |
|
| The |
|
| URI path suffix appended to |
|
| (optional) Display label |
|
| (optional) URL to an icon |
Most concerns (order, customer, subscription-contract, …) are entity-scoped — the plugin appears on a page tied to a specific resource. For entity-scoped entrypoints, the App UI passes the entity context (at minimum the entity ID) in the payload so the plugin knows which resource is currently on screen.
Some concerns, such as dashboard, are not entity-scoped — the plugin operates at the tenant level. For those, entityContext is omitted from the payload.
A plugin adding a "Preview Order" button on both the Order view and edit pages, plus a dashboard widget:
{
"entryPoints": [
{
"placement": "order/view/toolbar-button",
"target": "/order/preview",
"label": "Preview Order",
"icon": "https://example.com/icon.png"
},
{
"placement": "order/edit/toolbar-button",
"target": "/order/preview",
"label": "Preview Order",
"icon": "https://example.com/icon.png"
},
{
"placement": "order/edit/main-widget",
"target": "/order/edit-widget",
"label": "Order Edition Customer documentation"
},
{
"placement": "dashboard/view/main",
"target": "/dashboard/main",
"label": "Tenant Overview"
}
]
}
{
"entryPoints": [
{
"placement": "order/view/toolbar-button",
"target": "/order/preview",
"label": "Preview Order",
"icon": "https://example.com/icon.png"
},
{
"placement": "order/edit/toolbar-button",
"target": "/order/preview",
"label": "Preview Order",
"icon": "https://example.com/icon.png"
},
{
"placement": "order/edit/main-widget",
"target": "/order/edit-widget",
"label": "Order Edition Customer documentation"
},
{
"placement": "dashboard/view/main",
"target": "/dashboard/main",
"label": "Tenant Overview"
}
]
}
Note that order/edit/main-widget and order/edit/toolbar-button are two distinct positions on the same page. Without view in the placement, there would be no way to distinguish order/edit/main from order/view/main.
The App UI uses a form-submit-to-named-iframe pattern to load each entrypoint:
issuePluginPayload(installationId, entryPointId, entityContext?) on Crystallize Core. The server returns { url, encryptedPayload }, where url is $upstream/$tenantIdentifier/$target and encryptedPayload is a JWE compact string.name attribute.<form> targets that iframe by name and submits via POST.<iframe name="plugin-frame-<entryPointId>"></iframe>
<form
method="POST"
action="<url>"
target="plugin-frame-<entryPointId>"
>
<input type="hidden" name="payload" value="<encryptedPayload>" />
</form><iframe name="plugin-frame-<entryPointId>"></iframe>
<form
method="POST"
action="<url>"
target="plugin-frame-<entryPointId>"
>
<input type="hidden" name="payload" value="<encryptedPayload>" />
</form>The POST body contains a single encrypted payload, built by Crystallize Core and encrypted with the revision's publicKey. After decryption, the plaintext shape is:
type PluginPayloadPlaintext = {
backendToken: string // RS256 JWT — see next section
configuration: Record<string, JsonValue> // plaintext non-secret settings
encryptedSecrets: Record<string, string> // per-field JWE compact strings
entityContext?: Record<string, JsonValue> // e.g. { orderId: "..." }, omitted for non-entity concerns
installationId: string
tenantIdentifier: string
pluginIdentifier: string // reverse-DNS; sanity-check that this matches you
revisionId: string
userId: string // the viewing user; redundant with backendToken.sub
issuedAt: number // epoch seconds
expiresAt: number // epoch seconds, matches backendToken.exp
}type PluginPayloadPlaintext = {
backendToken: string // RS256 JWT — see next section
configuration: Record<string, JsonValue> // plaintext non-secret settings
encryptedSecrets: Record<string, string> // per-field JWE compact strings
entityContext?: Record<string, JsonValue> // e.g. { orderId: "..." }, omitted for non-entity concerns
installationId: string
tenantIdentifier: string
pluginIdentifier: string // reverse-DNS; sanity-check that this matches you
revisionId: string
userId: string // the viewing user; redundant with backendToken.sub
issuedAt: number // epoch seconds
expiresAt: number // epoch seconds, matches backendToken.exp
}This pattern provides:
The plugin origin must accept the POST, decrypt the payload, and return HTML.
The Backend Token is generated by Crystallize Core on every plugin load and delivered inside the encrypted payload. No exchange step is required — the plugin uses it immediately to call Crystallize APIs server-side.
The token impersonates the visitor (the user currently viewing the page). A Plugin Token gives access to Core Next only.
RS256 JWT, signed by Crystallize Core.
Claim | Value |
|---|---|
| Crystallize Core base URL |
| The viewing user's |
| Your plugin's |
|
|
| Issue time (epoch seconds) |
| Random UUID |
|
|
Verify every token you receive:
$CORE_BASE_URL/.well-known/jwks.json.iss matches the Crystallize Core base URL you expect.aud matches your plugin's identifier.exp is in the future.act.pluginId / act.installationId / act.revisionId match the installation you're handling.Send the token as a Bearer credential:
Authorization: Bearer <jwt>Authorization: Bearer <jwt>Crystallize's incoming auth pipeline verifies the token against the JWKS, rejects it if the plugin is not currently in active state (live check, not TTL-bounded), and produces a normal user context with act surfaced as the via field.
Plugin permissions follow least privilege:
scopes).This means a plugin can never escalate a user's privileges. If a user has read-only access to orders, the plugin cannot write orders on their behalf — regardless of what the plugin requested.
V1 behavior: the Backend Token currently carries the viewing user's full permissions. grantedScopes is stored on the installation but not yet enforced when minting the token. The user's own permissions still bound the token in the usual way. Scope-intersection enforcement is planned for V2; declaring accurate scopes now is still the right thing to do — the installer's consent is recorded and becomes binding in V2 without any vendor change.
pending → active) ensures nothing runs without review. inactive is a live kill-switch that invalidates every outstanding Backend Token for the plugin regardless of TTL.upstream don't require a new revision; changes to the contract itself do.revisionId and never auto-migrate. The user has to re-install to move to a newer revision — at which point they re-consent to the new contract and re-encrypt all secrets against the new public key.publicKey and are stored as ciphertext only. The outer payload envelope is then encrypted again with the same public key. Crystallize never sees plaintext secrets — not during install, not during storage, not during runtime delivery, not during the post-install webhook.config and encryptedSecrets.TDB