Craftkitdocs

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

  1. Create your Craftkit account and project — Dashboard → New project.
  2. Enable embed mode — Dashboard → Project → Embed → Overview → Enable embed mode.
  3. 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 default

The 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.