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:
- Create a signing request via POST
/signing-requests
- 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