Skip to main content

Embeddable template editor

Embed Firma’s template editor inside your application using JWT authentication for secure, time-limited access. This is ideal for white-label integrations and multi-tenant applications.

Use cases

  • White-label template editing: Let users edit templates under your brand
  • Multi-tenant applications: Secure per-user template access without exposing API keys
  • Embedded workflows: Seamless template creation within your product
  • Time-limited access: Tokens expire automatically for security

How it works

  1. Your server requests a JWT token from Firma’s API using your API key
  2. Firma returns a short-lived JWT token with the template ID
  3. Your frontend embeds the editor with the JWT token
  4. The token expires automatically (configurable expiration)
  5. Revoke tokens early if needed (e.g., user logs out)
Rate limit: JWT endpoints support 120 requests per minute per API key for high-volume applications.

JWT Authentication

Generate JWT token

Generate a JWT token for a specific template using the /jwt/generate endpoint. Endpoint: POST /jwt/generate Request body:
{
  "companies_workspaces_templates_id": "123e4567-e89b-12d3-a456-426614174000"
}
Response (200 OK):
{
  "jwt": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "jwt_id": "jwt123-e89b-12d3-a456-426614174000",
  "expires_at": "2024-04-20T10:00:00Z",
  "template_id": "123e4567-e89b-12d3-a456-426614174000"
}
Rate limit headers:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 119
X-RateLimit-Reset: 1729180800

Revoke JWT token

Revoke a JWT token before its expiration time. Useful when:
  • User logs out
  • User permissions change
  • Suspicious activity detected
  • Session ends unexpectedly
Endpoint: POST /jwt/revoke Request body:
{
  "jwt_id": "123e4567-e89b-12d3-a456-426614174000"
}
Response (200 OK):
{
  "message": "JWT revoked successfully",
  "jwt_id": "123e4567-e89b-12d3-a456-426614174000",
  "revoked_at": "2024-03-20T16:45:00Z"
}

Implementation guide

Security: Never expose your API key in client-side code. Always generate JWT tokens from your secure backend.

Server implementation — Node.js (Express)

import express from 'express'
import fetch from 'node-fetch'

const app = express()
app.use(express.json())

const FIRMA_API_BASE = process.env.FIRMA_API_BASE || 'https://api.firma.dev/functions/v1/signing-request-api'
const API_KEY = process.env.FIRMA_API_KEY

// Protected endpoint - verify user authentication first
app.post('/api/embed/template/:templateId/token', async (req, res) => {
  // 1. Verify user is authenticated (your auth logic)
  if (!req.user) {
    return res.status(401).json({ error: 'Unauthorized' })
  }
  
  // 2. Verify user has access to this template (your authorization logic)
  const { templateId } = req.params
  const hasAccess = await checkUserTemplateAccess(req.user.id, templateId)
  if (!hasAccess) {
    return res.status(403).json({ error: 'Forbidden' })
  }
  
  // 3. Generate JWT token from Firma
  try {
    const response = await fetch(`${FIRMA_API_BASE}/jwt/generate`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        companies_workspaces_templates_id: templateId
      })
    })
    
    if (!response.ok) {
      const error = await response.text()
      return res.status(response.status).json({ error })
    }
    
    const data = await response.json()
    
    // 4. Store jwt_id for potential revocation
    await storeJwtForUser(req.user.id, data.jwt_id, data.expires_at)
    
    // 5. Return token to frontend
    res.json({
      jwt: data.jwt,
      expires_at: data.expires_at
    })
  } catch (error) {
    console.error('Failed to generate JWT:', error)
    res.status(500).json({ error: 'Failed to generate token' })
  }
})

// Revoke token on logout
app.post('/api/embed/revoke', async (req, res) => {
  if (!req.user) {
    return res.status(401).json({ error: 'Unauthorized' })
  }
  
  const { jwt_id } = req.body
  
  try {
    const response = await fetch(`${FIRMA_API_BASE}/jwt/revoke`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ jwt_id })
    })
    
    if (!response.ok) {
      const error = await response.text()
      return res.status(response.status).json({ error })
    }
    
    const data = await response.json()
    res.json(data)
  } catch (error) {
    console.error('Failed to revoke JWT:', error)
    res.status(500).json({ error: 'Failed to revoke token' })
  }
})

app.listen(3000)

Server implementation — Python (Flask)

from flask import Flask, request, jsonify
import os
import requests
from functools import wraps

app = Flask(__name__)

FIRMA_API_BASE = os.getenv('FIRMA_API_BASE', 'https://api.firma.dev/functions/v1/signing-request-api')
API_KEY = os.getenv('FIRMA_API_KEY')

def require_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        # Your authentication logic here
        user = get_current_user(request)
        if not user:
            return jsonify({'error': 'Unauthorized'}), 401
        return f(user, *args, **kwargs)
    return decorated

@app.route('/api/embed/template/<template_id>/token', methods=['POST'])
@require_auth
def generate_jwt(user, template_id):
    # Verify user has access to this template
    if not check_user_template_access(user.id, template_id):
        return jsonify({'error': 'Forbidden'}), 403
    
    # Generate JWT from Firma
    try:
        response = requests.post(
            f'{FIRMA_API_BASE}/jwt/generate',
            headers={
                'Authorization': f'Bearer {API_KEY}',
                'Content-Type': 'application/json'
            },
            json={
                'companies_workspaces_templates_id': template_id
            }
        )
        response.raise_for_status()
        data = response.json()
        
        # Store jwt_id for potential revocation
        store_jwt_for_user(user.id, data['jwt_id'], data['expires_at'])
        
        return jsonify({
            'jwt': data['jwt'],
            'expires_at': data['expires_at']
        })
    except requests.exceptions.RequestException as e:
        return jsonify({'error': 'Failed to generate token'}), 500

@app.route('/api/embed/revoke', methods=['POST'])
@require_auth
def revoke_jwt(user):
    jwt_id = request.json.get('jwt_id')
    
    try:
        response = requests.post(
            f'{FIRMA_API_BASE}/jwt/revoke',
            headers={
                'Authorization': f'Bearer {API_KEY}',
                'Content-Type': 'application/json'
            },
            json={'jwt_id': jwt_id}
        )
        response.raise_for_status()
        return jsonify(response.json())
    except requests.exceptions.RequestException as e:
        return jsonify({'error': 'Failed to revoke token'}), 500

if __name__ == '__main__':
    app.run(port=3000)

Frontend implementation — React

import { useState, useEffect } from 'react'

function TemplateEditor({ templateId }) {
  const [embedUrl, setEmbedUrl] = useState(null)
  const [jwtId, setJwtId] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  
  useEffect(() => {
    async function loadEditor() {
      try {
        // Request JWT token from your server
        const response = await fetch(`/api/embed/template/${templateId}/token`, {
          method: 'POST',
          credentials: 'include' // Include auth cookies
        })
        
        if (!response.ok) {
          throw new Error('Failed to generate embed token')
        }
        
        const { jwt, expires_at } = await response.json()
        
        // Build embed URL with JWT token
        const url = `https://app.firma.dev/templateEditor/${templateId}?jwt=${jwt}`
        setEmbedUrl(url)
        setJwtId(jwt)
        setLoading(false)
        
        // Optional: Auto-refresh token before expiration
        const expiresIn = new Date(expires_at).getTime() - Date.now()
        const refreshTimeout = setTimeout(() => {
          loadEditor() // Refresh token
        }, expiresIn - 60000) // Refresh 1 minute before expiration
        
        return () => clearTimeout(refreshTimeout)
      } catch (err) {
        setError(err.message)
        setLoading(false)
      }
    }
    
    loadEditor()
    
    // Cleanup: Revoke token when component unmounts
    return () => {
      if (jwtId) {
        fetch('/api/embed/revoke', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          credentials: 'include',
          body: JSON.stringify({ jwt_id: jwtId })
        }).catch(console.error)
      }
    }
  }, [templateId])
  
  if (loading) return <div>Loading editor...</div>
  if (error) return <div>Error: {error}</div>
  
  return (
    <iframe
      src={embedUrl}
      style={{ width: '100%', height: '800px', border: 0 }}
      allow="clipboard-write"
      title="Template Editor"
    />
  )
}

Frontend implementation — Vanilla JavaScript

async function loadTemplateEditor(templateId, containerId) {
  try {
    // Request JWT token from your server
    const response = await fetch(`/api/embed/template/${templateId}/token`, {
      method: 'POST',
      credentials: 'include'
    })
    
    if (!response.ok) {
      throw new Error('Failed to generate embed token')
    }
    
    const { jwt, expires_at } = await response.json()
    
    // Create iframe with JWT token
    const iframe = document.createElement('iframe')
    iframe.src = `https://app.firma.dev/templateEditor/${templateId}?jwt=${jwt}`
    iframe.style.width = '100%'
    iframe.style.height = '800px'
    iframe.style.border = '0'
    iframe.allow = 'clipboard-write'
    
    const container = document.getElementById(containerId)
    container.appendChild(iframe)
    
    return { iframe, jwt_id: jwt, expires_at }
  } catch (error) {
    console.error('Failed to load template editor:', error)
    throw error
  }
}

// Usage
loadTemplateEditor('123e4567-e89b-12d3-a456-426614174000', 'editor-container')

postMessage events (editor → host)

Firma’s editor will emit postMessage events for important lifecycle actions. Below is a recommended, minimal event schema you can implement for reacting to editor saves and publishes. If you have a canonical schema in your platform, replace these with your official event names.
Use these events to track editor activity without making additional API calls, helping you stay within rate limits.
Event envelope (window.postMessage payload):
{
	"type": "editor.event",
	"event": "editor.saved", // or editor.published, editor.closed
	"payload": {
		"template_id": "tmpl_123",
		"updated_at": "2025-09-04T12:34:56Z",
		"draft": false
	}
}

Client listener example (plain JS)

window.addEventListener('message', (ev) => {
	// 1) Validate origin
	if (ev.origin !== 'https://app.firma.dev') return
	// 2) Validate shape
	const data = ev.data || {}
	if (data.type !== 'editor.event') return

	switch (data.event) {
		case 'editor.saved':
			console.log('Template saved', data.payload)
			// Optionally fetch the latest template via API
			break
		case 'editor.published':
			console.log('Template published', data.payload)
			break
		case 'editor.closed':
			console.log('Editor closed')
			break
		default:
			break
	}
})

Sending messages to the iframe (host → editor)

If the editor supports initialization via postMessage (common for embedded apps), you can send a small init message after the iframe loads with a short-lived token. Example:
const iframe = document.querySelector('#editor-root iframe')
iframe.addEventListener('load', () => {
	iframe.contentWindow.postMessage({ type: 'editor.init', token: '<JWT_TOKEN>' }, 'https://app.firma.dev')
})

Updating templates

After users edit templates in the embedded editor, you’ll need to save their changes. The API provides two methods:

Comprehensive update (PUT)

Use comprehensive-template-update for complex updates involving multiple sections. When to use:
  • Updating multiple users at once
  • Deleting users (with field reassignment/deletion)
  • Updating fields and reminders together
  • Making coordinated changes across multiple sections
Structure: All sections are optional, but at least one must be provided:
  • template_properties - Update name, description, document, expiration, settings
  • users - Upsert users (include id to update, omit to create)
  • deleted_users - Delete users with field_action (delete or reassign fields)
  • fields - Upsert fields (include id to update, omit to create)
  • reminders - Upsert reminders (include id to update, omit to create)
Requirements:
  • ✅ Can update multiple sections in one request
  • ✅ Supports user deletion with field handling
Example (Node.js):
const response = await fetch(`https://api.firma.dev/functions/v1/signing-request-api/templates/${templateId}`, {
  method: 'PUT',
  headers: {
    'Authorization': `Bearer ${process.env.FIRMA_API_KEY}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    template_properties: {
      name: 'Updated Contract Template',
      description: 'Updated description',
      expiration_hours: 168,
      settings: {
        allow_editing_before_sending: false,
        attach_pdf_on_finish: true,
        allow_download: true,
        use_signing_order: true
      }
    },
    users: [
      {
        id: 'user1-e89b-12d3-a456-426614174000', // Include id to update existing
        first_name: 'John',
        last_name: 'Doe',
        email: 'john@example.com',
        designation: 'Signer',
        order: 1
      }
    ],
    fields: [
      {
        type: 'signature',
        x: 10,
        y: 80,
        width: 30,
        height: 8,
        page: 1,
        required: true,
        assigned_to_user_id: 'user1-e89b-12d3-a456-426614174000'
      }
    ]
  })
});

const updatedTemplate = await response.json();

Partial update (PATCH)

Use partially-update-template when updating specific properties or a single user. When to use:
  • Updating name, description, or settings
  • Adding or updating one user at a time
  • Making targeted changes without affecting other data
Important: Cannot update both properties AND a user in the same request. Choose one:
  • Update properties only (name, description, document, expiration_hours, settings)
  • OR update/create a single user
Benefits:
  • ✅ Only send what you want to change
  • ✅ More efficient for small changes
  • ✅ Other data remains unchanged
  • ✅ Safer for concurrent edits
Example - Update properties (Node.js):
// Update only the template name and settings
const response = await fetch(`https://api.firma.dev/functions/v1/signing-request-api/templates/${templateId}`, {
  method: 'PATCH',
  headers: {
    'Authorization': `Bearer ${process.env.FIRMA_API_KEY}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    name: 'New Template Name',
    settings: {
      use_signing_order: true
    }
  })
});

const updatedTemplate = await response.json();
Example - Update a user (Python):
import requests
import os

# Update a single user
response = requests.patch(
    f"https://api.firma.dev/functions/v1/signing-request-api/templates/{template_id}",
    headers={
        "Authorization": f"Bearer {os.getenv('FIRMA_API_KEY')}",
        "Content-Type": "application/json"
    },
    json={
        "user": {
            "id": "user1-e89b-12d3-a456-426614174000",
            "first_name": "Jane",
            "last_name": "Smith",
            "email": "jane.smith@example.com",
            "designation": "Signer",
            "order": 1
        }
    }
)

updated_template = response.json()

Choosing between PUT and PATCH

ScenarioUseReason
Saving all editor changesPUTComplete control over all sections (properties, users, fields, reminders)
Updating template name onlyPATCHMore efficient, safer
Changing specific settingsPATCHOnly affects targeted properties
Updating multiple usersPUTCan upsert multiple users in one request
Delete users with field reassignmentPUTSupports deleted_users with field_action
User edits single propertyPATCHPreserves other properties
Add/update one userPATCHSingle user mode, other users unchanged

Rate limiting

Both endpoints share the same rate limit:
  • 100 requests per minute per API key
Best practices:
  • Debounce auto-save (e.g., save 2 seconds after last edit)
  • Show save status to users
  • Implement retry logic with exponential backoff
  • Cache template state to minimize API calls

Error handling

async function updateTemplate(templateId, changes) {
  try {
    const response = await fetch(`https://api.firma.dev/functions/v1/signing-request-api/templates/${templateId}`, {
      method: 'PATCH',
      headers: {
        'Authorization': `Bearer ${process.env.FIRMA_API_KEY}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(changes)
    });

    if (!response.ok) {
      if (response.status === 404) {
        throw new Error('Template not found');
      }
      if (response.status === 429) {
        throw new Error('Rate limit exceeded - please try again in a moment');
      }
      throw new Error(`Update failed: ${response.statusText}`);
    }

    return await response.json();
  } catch (error) {
    console.error('Failed to update template:', error);
    throw error;
  }
}

Token lifecycle management

Automatic expiration

JWT tokens expire automatically based on the expires_at timestamp. After expiration:
  • The embedded editor will reject the token
  • Users must request a new token to continue
  • No API call needed — tokens expire passively

Manual revocation

Revoke tokens explicitly in these scenarios:
  1. User logout: Revoke all active tokens for the user
  2. Permission changes: User loses access to the template
  3. Security concerns: Suspicious activity detected
  4. Session timeout: User session expires in your app

Refresh strategy

Implement token refresh before expiration:
// Calculate time until expiration
const expiresAt = new Date(tokenData.expires_at)
const expiresIn = expiresAt.getTime() - Date.now()

// Refresh 5 minutes before expiration
const refreshBuffer = 5 * 60 * 1000
const refreshTimeout = expiresIn - refreshBuffer

setTimeout(async () => {
  // Request new token
  const newToken = await generateNewToken(templateId)
  // Update iframe with new token
  updateIframeToken(newToken)
}, refreshTimeout)

Rate limiting

JWT endpoints support 120 requests per minute per API key. Rate limit headers:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 115
X-RateLimit-Reset: 1729180800
If you exceed the rate limit (429 response):
  • Cache tokens and reuse them until expiration
  • Implement exponential backoff for retries
  • Monitor X-RateLimit-Remaining header
  • Generate tokens on-demand, not preemptively

Security best practices

Never expose your API key in client-side code. Always generate JWT tokens from a secure server endpoint.

✅ Do’s

  • ✅ Generate tokens from your backend server
  • ✅ Validate user authentication before generating tokens
  • ✅ Verify user has access to the requested template
  • ✅ Store jwt_id for revocation capabilities
  • ✅ Set reasonable token expiration times (1-4 hours)
  • ✅ Revoke tokens when users log out
  • ✅ Monitor rate limits (120 requests/minute)
  • ✅ Implement error handling for expired tokens
  • ✅ Use HTTPS for all API requests

❌ Don’ts

  • ❌ Don’t expose API keys in frontend code
  • ❌ Don’t generate tokens without authentication
  • ❌ Don’t skip authorization checks
  • ❌ Don’t create tokens with indefinite expiration
  • ❌ Don’t reuse tokens across users
  • ❌ Don’t log JWT tokens (security risk)
  • ❌ Don’t share tokens between different templates

Troubleshooting

Token expired error

Symptom: Editor shows “Token expired” or authentication error Solution:
  • Implement token refresh before expiration
  • Generate a new token and reload the iframe
  • Check system clock synchronization

401 Unauthorized

Symptom: JWT generation fails with 401 Possible causes:
  • Invalid or missing API key
  • API key doesn’t have required permissions
  • API key is disabled
Solution: Verify API key in dashboard and check permissions

404 Not Found

Symptom: Template not found when generating JWT Possible causes:
  • Template ID doesn’t exist
  • Template belongs to different workspace
  • Template was deleted
Solution: Verify template ID and workspace access

400 Bad Request (JWT already revoked)

Symptom: Revocation fails with “already revoked” Possible causes:
  • Token was already revoked
  • Token already expired naturally
  • JWT ID is invalid
Solution: This is usually safe to ignore — token is already invalid

Rate limit exceeded

Symptom: 429 Too Many Requests Solution:
  • Implement token caching
  • Increase token expiration time
  • Wait for rate limit reset (check X-RateLimit-Reset header)
  • Implement retry logic with backoff

Next steps