Multi-tenant setup
Admin provision API — one admin key auto-provisions isolated Craftkit projects per org.
Multi-tenant embed — admin provision API
When your SaaS serves multiple organizations and each org needs its own isolated Craftkit project (separate templates, separate renders, separate audit trail), use the admin provision pattern instead of managing one API key per tenant manually.
One call to POST /v1/admin/provision idempotently creates a full Craftkit stack for
an org — project, API key, embed partner, signing key, and permission presets — and
returns the API key. Subsequent calls for the same externalOrgId decrypt and return
the same key, so the call is safe to make on every request or to cache with a short TTL.
When to use this pattern
| Single-tenant | Multi-tenant (this page) |
|---|---|
| One org uses your app | Many orgs use your app |
| One Craftkit project per deployment | One Craftkit project per org, provisioned on demand |
| Hard-code a single API key in env vars | Resolve the API key per-request via CRAFTKIT_ADMIN_KEY |
Prerequisites
- Create your Craftkit account and project — Dashboard → New project.
- Enable embed mode — Dashboard → Project → Embed → Overview → Enable embed mode.
- Generate an admin key — contact Craftkit support or check your project settings for
the
CRAFTKIT_ADMIN_KEY. This key is separate from your project API key and has elevated privileges: it can provision new projects on behalf of your tenants.
Environment variables
# Your platform backend
CRAFTKIT_ADMIN_KEY=<your-admin-key> # Elevated — never expose to clients
CRAFTKIT_BASE_URL=https://www.craftkit.dev # Omit to use the defaultThe CRAFTKIT_ADMIN_KEY must match exactly what is configured on the Craftkit server.
It is used both to authenticate the provision request and to derive the AES-256
encryption key that protects the stored per-org API keys. Changing it without migrating
the stored records will break decryption for all previously provisioned orgs.
Provision endpoint
POST /v1/admin/provision
Authorization: Bearer <CRAFTKIT_ADMIN_KEY>
Content-Type: application/json
{ "externalOrgId": "org-123", "orgName": "Acme Corp" }Response (first call — org created):
{
"projectId": "28db2719-...",
"partnerId": "dd4a3243-...",
"apiKey": "ck_live_Pjw...",
"alreadyExisted": false
}Response (subsequent calls — org already provisioned):
{
"projectId": "28db2719-...",
"partnerId": "dd4a3243-...",
"apiKey": "ck_live_Pjw...",
"alreadyExisted": true
}The apiKey is the per-org project API key. Use it for:
- Session minting (
POST /v1/embed/sessions) - Session refresh (
POST /v1/embed/sessions/refresh) - Template listing (
GET /v1/embed/builder/templates) - Renders listing (
GET /v1/embed/renders)
Every org's templates, renders, and sessions are isolated inside their own Craftkit project.
Next.js App Router — complete proxy implementation
Directory structure
app/
api/
craftkit/
lib/
credentials.ts ← org-key resolution + caching
assert-auth.ts ← auth guard (use your own auth)
session/
route.ts ← POST — mint embed session
refresh/
route.ts ← POST — refresh embed session
templates/
route.ts ← GET — list published templates
renders/
route.ts ← GET — list renders
[id]/
download/
route.ts ← GET — proxy PDF download app/api/craftkit/lib/credentials.ts
interface CachedKey {
apiKey: string
expiresAt: number
}
const KEY_TTL_MS = 10 * 60 * 1000 // 10-minute cache
const cache = new Map<string, CachedKey>()
export function getCraftkitBaseUrl(): string {
return (process.env.CRAFTKIT_BASE_URL ?? 'https://www.craftkit.dev').replace(/\/$/, '')
}
/**
* Resolves the Craftkit API key for an org by calling the admin provision
* endpoint. Result is cached for 10 minutes. The provision endpoint is
* idempotent — repeated calls return the same key.
*/
export async function getOrgApiKey(orgId: string): Promise<string> {
const now = Date.now()
const cached = cache.get(orgId)
if (cached && cached.expiresAt > now) return cached.apiKey
const adminKey = process.env.CRAFTKIT_ADMIN_KEY
if (!adminKey) throw new Error('CRAFTKIT_ADMIN_KEY is not configured')
const base = getCraftkitBaseUrl()
const res = await fetch(`${base}/v1/admin/provision`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${adminKey}`,
},
body: JSON.stringify({ externalOrgId: orgId }),
cache: 'no-store',
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Craftkit provision failed: ${res.status}${body ? ` — ${body}` : ''}`)
}
const data = (await res.json()) as { apiKey: string }
const apiKey = data.apiKey
if (!apiKey) throw new Error('Craftkit provision response missing apiKey')
cache.set(orgId, { apiKey, expiresAt: now + KEY_TTL_MS })
return apiKey
} app/api/craftkit/session/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getOrgApiKey, getCraftkitBaseUrl } from '../lib/credentials'
import { assertCraftkitAuth } from '../lib/assert-auth'
type Mode = 'create' | 'edit' | 'fill' | 'view'
const BUILDER_PERMISSIONS = {
publish: true, saveDraft: true, viewVersionHistory: true,
changePageSettings: true, rollback: false, delete: false,
rename: false, createCustomVariables: false,
} as const
const FILL_PERMISSIONS = {
submitForm: true, saveFormDraft: true,
} as const
export async function POST(req: NextRequest) {
const authResult = await assertCraftkitAuth()
if (authResult instanceof NextResponse) return authResult
const {
tenantExternalId, tenantDisplayName,
userId, userEmail, userDisplayName,
mode, templateExternalId, templateName,
} = (await req.json()) as {
tenantExternalId?: string; tenantDisplayName?: string
userId?: string; userEmail?: string; userDisplayName?: string
mode?: Mode; templateExternalId?: string; templateName?: string
}
if (!tenantExternalId || !userId) {
return NextResponse.json({ error: 'tenantExternalId and userId are required' }, { status: 400 })
}
let apiKey: string
try {
apiKey = await getOrgApiKey(tenantExternalId)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return NextResponse.json({ error: 'craftkit_provision_failed', detail: msg }, { status: 502 })
}
const resolvedMode: Mode = mode ?? 'edit'
const isFill = resolvedMode === 'fill'
const scope: Record<string, unknown> = { mode: resolvedMode }
if (templateExternalId) scope.templateExternalId = templateExternalId
if (templateName) scope.initialName = templateName
const body: Record<string, unknown> = {
tenant: { externalId: tenantExternalId, displayName: tenantDisplayName ?? tenantExternalId },
actor: { externalId: userId, email: userEmail, displayName: userDisplayName ?? userEmail ?? userId },
scope,
permissions: isFill ? FILL_PERMISSIONS : BUILDER_PERMISSIONS,
}
// Optional: attach a pre-published variable catalog
const catalogName = process.env.CRAFTKIT_CATALOG_NAME
if (catalogName) body.catalogRef = { name: catalogName }
const res = await fetch(`${getCraftkitBaseUrl()}/v1/embed/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(body),
})
const text = await res.text()
if (!res.ok) {
return NextResponse.json(
{ error: 'craftkit_session_failed', status: res.status, body: text },
{ status: res.status },
)
}
return new NextResponse(text, { status: 200, headers: { 'Content-Type': 'application/json' } })
} app/api/craftkit/refresh/route.ts
The refresh uses the same per-org API key that minted the session. Pass orgId in the
body so the route can resolve the right key — a single fixed key won't work in multi-tenant
because Craftkit validates the session's partnerId against the authenticating key.
import { NextRequest, NextResponse } from 'next/server'
import { getOrgApiKey, getCraftkitBaseUrl } from '../lib/credentials'
import { assertCraftkitAuth } from '../lib/assert-auth'
export async function POST(req: NextRequest) {
const authResult = await assertCraftkitAuth()
if (authResult instanceof NextResponse) return authResult
const { renewToken, orgId } = (await req.json()) as { renewToken?: string; orgId?: string }
if (!renewToken) return NextResponse.json({ error: 'renewToken required' }, { status: 400 })
if (!orgId) return NextResponse.json({ error: 'orgId required' }, { status: 400 })
let apiKey: string
try {
apiKey = await getOrgApiKey(orgId)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return NextResponse.json({ error: 'craftkit_provision_failed', detail: msg }, { status: 502 })
}
const res = await fetch(`${getCraftkitBaseUrl()}/v1/embed/sessions/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ renewToken }),
cache: 'no-store',
})
const text = await res.text()
if (!res.ok) {
return NextResponse.json({ error: 'craftkit_refresh_failed', status: res.status, body: text }, { status: res.status })
}
return new NextResponse(text, { status: 200, headers: { 'Content-Type': 'application/json' } })
}React embed component
// src/components/craftkit-embed.tsx
'use client'
import * as React from 'react'
type Mode = 'create' | 'edit' | 'fill' | 'view'
interface SessionResponse {
session_token: string
iframe_url: string
renew_token: string
expires_at: string
}
interface CraftkitEmbedProps {
mode: Mode
/** Your org's external ID — used for org-level key provisioning */
tenantExternalId: string
tenantDisplayName?: string
/** Your user's ID (logged-in user) */
userId: string
userEmail?: string
userDisplayName?: string
/** Required for edit/fill modes — Craftkit UUID from template.published event */
templateExternalId?: string
templateName?: string
onPublished?: (payload: { templateId: string; name?: string }) => void
onCompleted?: (payload: { renderId: string; downloadUrl: string }) => void
className?: string
}
export function CraftkitEmbed({
mode, tenantExternalId, tenantDisplayName,
userId, userEmail, userDisplayName,
templateExternalId, templateName,
onPublished, onCompleted, className,
}: CraftkitEmbedProps) {
const iframeRef = React.useRef<HTMLIFrameElement>(null)
const renewTokenRef = React.useRef<string | null>(null)
const refreshingRef = React.useRef(false)
const [iframeUrl, setIframeUrl] = React.useState<string | null>(null)
const [error, setError] = React.useState<string | null>(null)
const mintSession = React.useCallback(async (): Promise<SessionResponse> => {
const res = await fetch('/api/craftkit/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenantExternalId, tenantDisplayName,
userId, userEmail, userDisplayName,
mode, templateExternalId, templateName,
}),
})
if (!res.ok) throw new Error(`mint failed: ${res.status}`)
return res.json()
}, [tenantExternalId, tenantDisplayName, userId, userEmail, userDisplayName,
mode, templateExternalId, templateName])
const refreshSession = React.useCallback(async (): Promise<SessionResponse | null> => {
const renew = renewTokenRef.current
if (!renew || refreshingRef.current) return null
refreshingRef.current = true
try {
const res = await fetch('/api/craftkit/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// orgId must match the tenant used when the session was minted
body: JSON.stringify({ renewToken: renew, orgId: tenantExternalId }),
})
if (!res.ok) return null
return res.json()
} finally {
refreshingRef.current = false
}
}, [tenantExternalId])
// Initial session mint
React.useEffect(() => {
if (!tenantExternalId || !userId) return
let cancelled = false
mintSession()
.then((data) => {
if (cancelled) return
renewTokenRef.current = data.renew_token
setIframeUrl(data.iframe_url)
})
.catch(() => !cancelled && setError('Failed to load. Please refresh the page.'))
return () => { cancelled = true }
}, [mintSession, tenantExternalId, userId])
// postMessage handler
React.useEffect(() => {
if (!iframeUrl) return
const expectedOrigin = new URL(iframeUrl).origin
const handler = async (e: MessageEvent) => {
if (e.origin !== expectedOrigin) return
const { type, payload } = (e.data ?? {}) as { type?: string; payload?: unknown }
if (!type) return
const eventName = type.startsWith('craftkit.') ? type.slice(9) : type
if (eventName === 'template.published') {
onPublished?.(payload as { templateId: string; name?: string })
} else if (eventName === 'form.completed') {
onCompleted?.(payload as { renderId: string; downloadUrl: string })
} else if (eventName === 'session.expiring') {
const next = await refreshSession()
if (!next) return
renewTokenRef.current = next.renew_token
const post = (msg: unknown) =>
iframeRef.current?.contentWindow?.postMessage(msg, expectedOrigin)
post({ type: 'craftkit.session.refreshed', token: next.session_token })
post({ type: 'session.refresh', token: next.session_token })
} else if (eventName === 'session.expired') {
mintSession()
.then((fresh) => { renewTokenRef.current = fresh.renew_token; setIframeUrl(fresh.iframe_url) })
.catch(() => setError('Session expired. Please refresh the page.'))
}
}
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}, [iframeUrl, onPublished, onCompleted, refreshSession, mintSession])
if (error) return <div className="flex h-full items-center justify-center text-red-500 text-sm">{error}</div>
if (!iframeUrl) return <div className="flex h-full items-center justify-center text-gray-400 text-sm">Loading…</div>
return (
<iframe
ref={iframeRef}
src={iframeUrl}
className={className ?? 'w-full h-full border-0'}
allow="clipboard-write"
/>
)
}Pitfalls specific to multi-tenant
Single fixed key breaks session refresh
Symptom: Sessions mint correctly but session.expiring → refresh returns 401.
Root cause: Craftkit validates partnerId on session refresh. A session minted with
org A's API key (→ partner A) cannot be refreshed with org B's API key (→ partner B).
Using a single CRAFTKIT_API_KEY env var for all refreshes fails for every org that isn't
the one that key belongs to.
Fix: Pass orgId in the refresh request body and resolve the per-org key with
getOrgApiKey(orgId) — exactly as shown in the refresh/route.ts above.
Empty tenantExternalId provisions a useless org
Symptom: A blank template shows up; renders don't associate with the correct org.
Root cause: An empty string '' passed as externalOrgId provisions a catch-all org
called "" in Craftkit. Every user with an unresolved org ends up sharing it.
Fix: Guard against empty values before calling the session route:
if (!tenantExternalId) return <LoadingSpinner /> CRAFTKIT_ADMIN_KEY mismatch
Symptom: Provision succeeds (HTTP 200) but decrypting an existing org's key fails with a generic error.
Root cause: The admin key is used as the AES-256 seed to encrypt the per-org API key at write time. If the key changes, all stored records become undecryptable.
Fix: Treat CRAFTKIT_ADMIN_KEY like a database encryption master key — rotate it only
with a full re-encryption migration, never by just updating the env var.
Catalog integration (optional)
A variable catalog pre-populates the template builder's field picker with your data model. Publish it from your CI/CD pipeline so it stays in sync:
curl -X POST https://www.craftkit.dev/v1/embed/catalogs \
-H "Authorization: Bearer $CRAFTKIT_ORG_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "my-catalog-v1",
"catalog": {
"allowCustom": false,
"namespaces": [
{
"key": "customer", "label": "Customer",
"fields": [
{ "key": "customer.name", "label": "Name", "dataType": "text", "required": true },
{ "key": "customer.email", "label": "Email", "dataType": "email", "required": false }
]
}
],
"loops": []
}
}'Then reference it in every session by setting CRAFTKIT_CATALOG_NAME=my-catalog-v1 — the
session/route.ts above already picks this up from env.
Fields marked required: true in the catalog show an asterisk in the form-fill UI and are
validated before submission. All fields are skippable in dashboard renders regardless of
this flag.
Related
- Embed quickstart — single-tenant starter
- Integration guide — full phases from API to production
- Variable catalog — catalog schema reference
- Session JWT spec — token anatomy and refresh
- Client integration guide — pitfall checklist