Skip to main content

Embeddable signing

Embed the signing flow directly in your application so recipients can sign documents without leaving your product. This creates a seamless, white-label signing experience.

Signing URL pattern

The signing interface is available at: https://app.firma.dev/signing/{signing_request_user_id}
For secure embedded workflows: Use JWT authentication to generate time-limited access tokens. JWT endpoints support 120 requests per minute per API key.

Basic iframe embed

<iframe 
  src="https://app.firma.dev/signing/{signing_request_user_id}" 
  style="width:100%;height:900px;border:0;" 
  allow="camera;microphone;clipboard-write"
  title="Document Signing"
></iframe>

Required iframe permissions

  • camera - For identity verification (if enabled)
  • microphone - For video verification (if enabled)
  • clipboard-write - For copying/pasting content

Getting the signing URL

The signing_request_user_id is returned when you:
  1. Create a signing request via POST /signing-requests
  2. Fetch signing request details via GET /signing-requests/{id}

Example: Get recipient signing URLs

const response = await fetch(
  `https://api.firma.dev/functions/v1/signing-request-api/signing-requests/${signingRequestId}`,
  {
    headers: {
      'Authorization': `Bearer ${API_KEY}`
    }
  }
)

const data = await response.json()

// Each recipient has their own signing URL
data.recipients.forEach(recipient => {
  const signingUrl = `https://app.firma.dev/signing/${recipient.id}`
  console.log(`${recipient.email}: ${signingUrl}`)
})

Complete implementation example

React component

import { useState, useEffect } from 'react'

function SigningView({ signingRequestId, recipientEmail }) {
  const [signingUrl, setSigningUrl] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  
  useEffect(() => {
    async function loadSigningUrl() {
      try {
        // Fetch signing request details from your backend
        const response = await fetch(
          `/api/signing-requests/${signingRequestId}`,
          { credentials: 'include' }
        )
        
        if (!response.ok) {
          throw new Error('Failed to load signing request')
        }
        
        const data = await response.json()
        
        // Find recipient by email
        const recipient = data.recipients.find(
          r => r.email === recipientEmail
        )
        
        if (!recipient) {
          throw new Error('Recipient not found')
        }
        
        const url = `https://app.firma.dev/signing/${recipient.id}`
        setSigningUrl(url)
        setLoading(false)
      } catch (err) {
        setError(err.message)
        setLoading(false)
      }
    }
    
    loadSigningUrl()
  }, [signingRequestId, recipientEmail])
  
  if (loading) return <div>Loading signing interface...</div>
  if (error) return <div>Error: {error}</div>
  
  return (
    <iframe
      src={signingUrl}
      style={{
        width: '100%',
        height: '900px',
        border: 0
      }}
      allow="camera;microphone;clipboard-write"
      title="Sign Document"
    />
  )
}

Server-side endpoint (Node.js)

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

const app = express()
const API_KEY = process.env.FIRMA_API_KEY

app.get('/api/signing-requests/:id', async (req, res) => {
  // 1. Verify user authentication
  if (!req.user) {
    return res.status(401).json({ error: 'Unauthorized' })
  }
  
  // 2. Verify user has access to this signing request
  const hasAccess = await checkUserAccess(
    req.user.id,
    req.params.id
  )
  if (!hasAccess) {
    return res.status(403).json({ error: 'Forbidden' })
  }
  
  // 3. Fetch signing request from Firma
  try {
    const response = await fetch(
      `https://api.firma.dev/functions/v1/signing-request-api/signing-requests/${req.params.id}`,
      {
        headers: {
          'Authorization': `Bearer ${API_KEY}`
        }
      }
    )
    
    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 fetch signing request:', error)
    res.status(500).json({ error: 'Internal server error' })
  }
})

app.listen(3000)

postMessage events

The signing iframe emits postMessage events for tracking signing progress:
window.addEventListener('message', (event) => {
  // Validate origin
  if (event.origin !== 'https://app.firma.dev') return
  
  const data = event.data
  
  switch (data.type) {
    case 'signing.started':
      console.log('User started signing')
      break
    case 'signing.completed':
      console.log('Document signed successfully')
      // Redirect or show success message
      break
    case 'signing.declined':
      console.log('User declined to sign')
      break
    case 'signing.error':
      console.error('Signing error:', data.error)
      break
  }
})

Security best practices

Never expose API keys in frontend code. Always fetch signing URLs through a secure backend endpoint.

✅ Do’s

  • ✅ Authenticate users before providing signing URLs
  • ✅ Verify users have permission to access the signing request
  • ✅ Fetch signing URLs through your backend
  • ✅ Validate postMessage origins (https://app.firma.dev)
  • ✅ Use HTTPS for all API requests
  • ✅ Monitor signing events via webhooks (60 req/min)
  • ✅ Consider JWT tokens for additional security (120 req/min)

❌ Don’ts

  • ❌ Don’t expose API keys in client code
  • ❌ Don’t allow unauthenticated access to signing URLs
  • ❌ Don’t skip authorization checks
  • ❌ Don’t trust postMessage data without origin validation

Rate limits

Signing request operations: 100 requests per minute per API key
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1729180800
For high-volume applications:
  • Cache signing URLs (they don’t expire)
  • Use webhooks instead of polling for status (60 req/min)
  • Implement proper error handling for 429 responses

Webhook integration

Use webhooks to track signing events in real-time instead of polling:
// Webhook handler
app.post('/webhooks/firma', async (req, res) => {
  const event = req.body
  
  switch (event.event_type) {
    case 'signing_request.viewed':
      await notifyUser(event.data.signing_request_id, 'viewed')
      break
    case 'signing_request.signed':
      await notifyUser(event.data.signing_request_id, 'signed')
      break
    case 'signing_request.completed':
      await markComplete(event.data.signing_request_id)
      break
  }
  
  res.json({ received: true })
})
See the Webhooks guide for complete implementation details.

Troubleshooting

Iframe not loading

Possible causes:
  • Invalid signing_request_user_id
  • Recipient already completed signing
  • Signing request was cancelled or expired
Solution: Verify signing request status via API

postMessage events not received

Possible causes:
  • Origin validation blocking messages
  • Event listener not attached before iframe loads
Solution:
  • Check origin is exactly https://app.firma.dev
  • Attach listener before creating iframe

403 Forbidden when fetching signing request

Possible causes:
  • User doesn’t have access to the signing request
  • API key lacks required permissions
Solution: Verify authorization logic and API key permissions

Next steps