Build an Extension
This guide walks you from creating an extension catalog entry to having a working iframe panel with authenticated API access and live event delivery.
Create the extension in the dashboard
Go to Extensions in your workspace sidebar. Give it a name, declare your iframeUrl (for UI extensions) and/or a webhookUrl + eventSubscriptions (for event extensions), and list the scopes you need.
Create and publish a version
POST /api/v1/extensions/:id/versions to snapshot the current manifest as an immutable version, then POST /api/v1/extensions/:id/versions/:vid/publish to mark it installable.
Install into an application
POST /api/v1/extensions/:id/install with a versionId. The response includes a one-time API key and (if webhookUrl is declared) a webhookSigningSecret. Store both securely — they are never shown again.
Wire up the iframe bridge
In your iframe page, call createBridge() from @usethrottle/extension-bridge (npm) or load the hosted script. Wait for bridge.ready, then use bridge.api.* to call Throttle REST endpoints.
1. Create the extension
The extension manifest declares what surfaces your extension exposes, which scopes it needs, and which events it wants to receive. You can declare an iframeUrl, a webhookUrl with eventSubscriptions, or both. Every scope listed must be extensionAllowed — see the Scopes reference.
# 1. Create the extension catalog entry
curl -X POST https://api.usethrottle.dev/api/v1/extensions \
-H "Authorization: Bearer <dashboard-session>" \
-H "content-type: application/json" \
-d '{
"name": "Inventory Sidebar",
"iframeUrl": "https://extensions.example.com/inventory",
"webhookUrl": "https://extensions.example.com/webhooks",
"eventSubscriptions": ["order.created", "order.completed"],
"scopes": ["orders:read", "customers:read"]
}'
# → { data: { id: "ext_...", name: "Inventory Sidebar", lifecycle: "draft", ... } }extensionAllowed flag when the extension is created or updated. Human and admin scopes (team_members:*, billing:*, admin:*, *) are never grantable to extensions and return 400 invalid_scope.2. Create and publish a version
Versions are immutable snapshots of the manifest. Create a version when you're ready to make an installable release, then publish it. A version must be published before it can be referenced in an install call.
# 2. Create a version (snapshots the current manifest)
curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/versions \
-H "Authorization: Bearer <dashboard-session>" \
-H "content-type: application/json" \
-d '{ "version": "1.0.0", "notes": "Initial release" }'
# → { data: { id: "ver_...", version: "1.0.0", status: "draft", ... } }
# 3. Publish the version (marks it installable)
curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/versions/ver_xxx/publish \
-H "Authorization: Bearer <dashboard-session>" \
-H "content-type: application/json" \
-d '{}'
# → { data: { id: "ver_...", status: "published", ... } }3. Install into an application
Installs are application-scoped and environment-scoped. The install response contains a one-time API key (and, if a webhook URL is declared, a one-time signing secret). Copy both immediately — they cannot be retrieved again.
# 4. Install into an application (requires X-Throttle-Application-Id header)
curl -X POST https://api.usethrottle.dev/api/v1/extensions/ext_xxx/install \
-H "Authorization: Bearer <dashboard-session>" \
-H "X-Throttle-Application-Id: <applicationId>" \
-H "X-Throttle-Environment-Id: <workspace-environment-uuid>" \
-H "content-type: application/json" \
-d '{ "versionId": "ver_xxx" }'
# Response (show once — store both values immediately):
# {
# "data": {
# "id": "inst_...",
# "extensionId": "ext_xxx",
# "versionId": "ver_xxx",
# "status": "active",
# "environmentId": "8a5f2d1e-3d2a-4d24-93a1-fd3f9e1f37f0",
# "environmentSlug": "uat",
# "environmentKind": "non_production",
# "providerEnvironment": "sandbox",
# "apiKey": "sk_uat_...", ← store this securely; shown once
# "webhookSigningSecret": "whsec_..." ← only present if webhookUrl was declared; shown once
# }
# }apiKey and webhookSigningSecret are generated fresh for each install and shown exactly once. Store them in your secrets manager. If you lose them, uninstall and reinstall to get new ones.4. Wire the iframe bridge
For UI (iframe) extensions, call createBridge() once at page load. The bridge sends a ready message to the dashboard, which responds with a signed session token and context. The token is automatically refreshed before it expires (10-minute TTL).
Option A — hosted script (no build step)
<!-- Option A: hosted script (no build step) -->
<script src="https://docs.usethrottle.dev/extension-bridge.v1.js"></script>
<script>
const bridge = window.ThrottleBridge.createBridge({
// Lock to the dashboard origin in production to prevent token leaks.
targetOrigin: 'https://app.usethrottle.dev',
});
bridge.ready.then((ctx) => {
console.log('Extension running as', ctx.user.email, 'in', ctx.environment.environmentSlug);
// Call any Throttle API route the extension's scopes allow.
bridge.api.get('/api/v1/orders?limit=10').then((res) => {
console.log('Recent orders:', res.data);
});
// Resize the dashboard iframe panel to fit your content.
bridge.resize(480);
// Show a toast notification in the dashboard.
bridge.toast('Inventory synced', 'success');
// Ask the dashboard to navigate to another route.
bridge.navigate('/orders');
});
</script>Option B — npm package
npm install @usethrottle/extension-bridge// Option B: npm package (React / bundled apps)
import { createBridge } from '@usethrottle/extension-bridge';
const bridge = createBridge({
targetOrigin: 'https://app.usethrottle.dev',
});
// Await the session handshake
const ctx = await bridge.ready;
console.log('workspace:', ctx.workspace.slug, 'app:', ctx.application.slug);
console.log('environment:', ctx.environment.environmentSlug, 'role:', ctx.role);
console.log('scopes:', ctx.scopes);
// Authenticated REST calls using the pinned extension scopes
const orders = await bridge.api.get('/api/v1/orders?limit=5');
const customer = await bridge.api.get('/api/v1/customers/cust_xxx');
// Mutations (requires the relevant :write scope in the manifest)
await bridge.api.post('/api/v1/orders/ord_xxx/notes', { note: 'Inventory reserved' });
// UI helpers
bridge.resize(600); // resize the iframe panel
bridge.toast('Done', 'info'); // show a dashboard toast
bridge.navigate('/orders'); // navigate the parent window
// Clean up when the component unmounts
bridge.destroy();type SessionContext = {
user: { id: string; email: string };
workspace: { id: string; slug: string };
application: { id: string; slug: string };
environment: {
environmentId: string;
environmentSlug: string;
environmentKind: 'production' | 'non_production';
providerEnvironment: 'production' | 'sandbox';
};
installationId: string;
extensionId: string;
version: string;
role: string;
scopes: string[];
};
const ctx = await bridge.ready;
// Not included: configSchema, config values, API keys, or webhook signing secrets.
// Fetch config explicitly from an allowed API route or keep it server-side.React pattern
// React hook pattern
import { useEffect, useRef, useState } from 'react';
import { createBridge, type Bridge, type SessionContext } from '@usethrottle/extension-bridge';
export function useExtensionBridge() {
const bridgeRef = useRef<Bridge | null>(null);
const [ctx, setCtx] = useState<SessionContext | null>(null);
useEffect(() => {
const bridge = createBridge({ targetOrigin: 'https://app.usethrottle.dev' });
bridgeRef.current = bridge;
bridge.ready.then(setCtx);
return () => bridge.destroy();
}, []);
return { bridge: bridgeRef.current, ctx };
}
// In your extension component:
function InventorySidebar() {
const { bridge, ctx } = useExtensionBridge();
useEffect(() => {
if (!bridge || !ctx) return;
bridge.api.get('/api/v1/orders?limit=20').then((res: any) => {
// render orders
});
}, [bridge, ctx]);
return <div>...</div>;
}targetOrigin: 'https://app.usethrottle.dev' to createBridge(). Without it the bridge falls back to document.referrer or '*' as the postMessage target, which can leak the session token to other origins if the referrer is spoofed.configSchema or installed config values automatically. The dashboard consumesconfigSchema to collect settings during install and stores the resulting config on the installation record. See the configuration schema guide for the full lifecycle.Runtime model
A UI extension is loaded only after it is installed into an application. The dashboard opens the installed version's iframeUrl, creates a bridge host for that iframe origin, and mints a 10-minute extension-session JWT for the active dashboard user. Your iframe calls createBridge()once, sends a ready message to the parent, receives asession message, and then uses bridge.api for scoped REST calls.
The session token carries the installed version's scopes, application, workspace, environment, role, extension id, installation id, and version. It does not carry catalog authoring data such as configSchema, and it does not carry one-time install secrets. Event extensions use the separately generated webhook signing secret and verify incoming deliveries in their backend route.
Next steps
- Identity JWT — the full claim set, TTL, JWKS verification, and using the token as an API bearer.
- Events — verify the
X-Throttle-Signatureheader and browse the full event catalog with required scopes. - Scopes — which scopes extensions may request and how scope ceilings work.
- Versioning — immutable published versions, upgrades, and rollback.
- Security model — RS256 JWKS, iframe sandboxing, origin pinning, scope ceilings.