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}

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 fetch signing request users via the API.

Example: Get recipient signing URLs

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

const users = await response.json()

// Each user has their own signing URL
users.forEach(user => {
  const signingUrl = `https://app.firma.dev/signing/${user.id}`
  console.log(`${user.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 users from your backend
        const response = await fetch(
          `/api/signing-requests/${signingRequestId}/users`,
          { credentials: 'include' }
        )
        
        if (!response.ok) {
          throw new Error('Failed to load signing users')
        }
        
        const users = await response.json()
        
        // Find user by email
        const user = users.find(
          u => u.email === recipientEmail
        )
        
        if (!user) {
          throw new Error('User not found')
        }
        
        const url = `https://app.firma.dev/signing/${user.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/users', async (req, res) => {
  try {
    const response = await fetch(
      `https://api.firma.dev/functions/v1/signing-request-api/signing-requests/${req.params.id}/users`,
      {
        headers: {
          'Authorization': `Bearer ${API_KEY}`
        }
      }
    )
    
    if (!response.ok) {
      const error = await response.text()
      return res.status(response.status).json({ error })
    }
    
    const users = await response.json()
    res.json(users)
  } catch (error) {
    console.error('Failed to fetch signing users:', 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 user IDs through a secure backend endpoint.

✅ Do’s

  • ✅ Fetch signing user IDs through your backend
  • ✅ Validate postMessage origins (https://app.firma.dev)
  • ✅ Use HTTPS for all API requests
  • ✅ Monitor signing events via webhooks (60 req/min)

❌ Don’ts

  • ❌ Don’t expose API keys in client code
  • ❌ Don’t trust postMessage data without origin validation

Rate Limits

See the guide on Rate Limits.

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