Collaborative Editing
Enable your team to work together on email templates with conflict-free editing, version history, and presence awareness.
How it works: Collaborative editing uses a combination of pessimistic locking (to prevent simultaneous edits), optimistic concurrency control (version numbers to detect conflicts), and presence tracking (to see who is viewing or editing a template).
Overview
The collaboration system provides three key capabilities:
Template Locking
Acquire an edit lock to prevent others from making simultaneous changes. Locks auto-expire after 30 minutes of inactivity.
Version History
Every change creates a new version with full content snapshots. Browse history, compare versions, and restore previous versions at any time.
Presence Awareness
See who is currently viewing or editing a template in real-time, including their cursor position.
Locking Workflow
The recommended workflow for editing a template collaboratively:
- Acquire the lock before making edits
- Send heartbeats every 5 minutes to keep the lock alive
- Save your changes as a new version
- Release the lock when done (or it auto-releases on save)
Acquire Lock
POST /v1/templates/:id/lockReturns 409 Conflict if the template is already locked by another user, including the lock holder details.
// Acquire an edit lock
const lock = await client.templates.lock('template_xxxxx');
console.log(lock.lockedByName); // "API Key ab12"
console.log(lock.expiresAt); // "2026-01-31T12:30:00.000Z"
// If already locked by someone else, a 409 error is thrown
try {
await client.templates.lock('template_xxxxx');
} catch (err) {
if (err.statusCode === 409) {
console.log('Template is locked by:', err.details.lock.lockedByName);
}
}Extend Lock (Heartbeat)
POST /v1/templates/:id/lock/extendResets the lock expiry to 30 minutes from now. Call this periodically while the user is actively editing.
// Extend the lock every 5 minutes while editing
const heartbeat = setInterval(async () => {
try {
await client.templates.extendLock('template_xxxxx');
} catch (err) {
console.error('Failed to extend lock:', err.message);
clearInterval(heartbeat);
}
}, 5 * 60 * 1000);
// Stop heartbeat when done editing
clearInterval(heartbeat);Release Lock
DELETE /v1/templates/:id/lockOnly the lock holder can release. Note that saving a version auto-releases the lock.
// Explicitly release the lock
await client.templates.unlock('template_xxxxx');Version History
Every template maintains a complete version history. Each version includes the full HTML content, subject line, editor information, and an optional commit message.
Save a New Version
POST /v1/templates/:id/versions// Save a new version with optimistic concurrency control
const version = await client.templates.saveVersion('template_xxxxx', {
html: '<h1>Updated email content</h1>',
subject: 'New subject line',
message: 'Redesigned the header section',
expectedVersion: 3, // Must match the current version
});
console.log(version.version); // 4
console.log(version.message); // "Redesigned the header section"Optimistic concurrency: The expectedVersion parameter prevents lost updates. If someone else saved a version between when you loaded the template and when you save, the API returns 409 Conflict with the current version number so you can refresh and retry.
List Versions
GET /v1/templates/:id/versions// List version history (most recent first)
const { data: versions } = await client.templates.listVersions('template_xxxxx');
for (const v of versions) {
console.log(`v${v.version} - ${v.editedByName} - ${v.message || 'No message'}`);
console.log(` Created: ${v.createdAt}`);
}Get a Specific Version
GET /v1/templates/:id/versions/:version// Get a specific version to view or compare
const version = await client.templates.getVersion('template_xxxxx', 2);
console.log(version.html); // Full HTML content of version 2
console.log(version.subject); // Subject at version 2Restore a Previous Version
POST /v1/templates/:id/restore/:versionRestoring creates a new version with the content from the specified old version. The original version is preserved in history.
// Restore version 2 as the latest version
const restored = await client.templates.restoreVersion('template_xxxxx', 2);
console.log(restored.version); // 5 (new version number)
console.log(restored.message); // "Restored from version 2"Presence Awareness
Track who is currently viewing or editing a template. Presence uses a polling model: clients send heartbeats every 30 seconds, and records are considered stale after 60 seconds.
Update Presence
POST /v1/templates/:id/presence// Start sending presence heartbeats
const presenceInterval = setInterval(async () => {
await client.templates.updatePresence('template_xxxxx', {
line: currentCursorLine,
column: currentCursorColumn,
});
}, 30_000); // Every 30 seconds
// Clean up on page leave
window.addEventListener('beforeunload', async () => {
clearInterval(presenceInterval);
await client.templates.removePresence('template_xxxxx');
});Get Active Editors
GET /v1/templates/:id/presence// Poll for active editors
const { data: editors } = await client.templates.getPresence('template_xxxxx');
for (const editor of editors) {
console.log(editor.userName, 'is viewing');
if (editor.cursor) {
console.log(` Cursor at line ${editor.cursor.line}, col ${editor.cursor.column}`);
}
}Remove Presence
DELETE /v1/templates/:id/presence// Remove your presence when leaving the editor
await client.templates.removePresence('template_xxxxx');Conflict Resolution
The system uses two layers of protection against conflicting edits:
| Mechanism | Type | How It Works |
|---|---|---|
| Template Locks | Pessimistic | Only one user can hold the edit lock at a time. Others see who holds it and when it expires. |
| Version Numbers | Optimistic | When saving, you must provide the expected version number. If it does not match, the save is rejected with 409 Conflict. |
Handling Version Conflicts
async function saveWithRetry(templateId: string, html: string) {
// Get current template state
let template = await client.templates.get(templateId);
try {
// Try to save with expected version
return await client.templates.saveVersion(templateId, {
html,
expectedVersion: template.version,
message: 'Updated content',
});
} catch (err) {
if (err.statusCode === 409 && err.details?.currentVersion) {
// Version conflict - another user saved first
// Option 1: Reload and let the user resolve manually
template = await client.templates.get(templateId);
console.log('Conflict! Current version:', template.version);
console.log('Please review the latest changes and try again.');
// Option 2: Auto-retry with the new version (if no content conflict)
// return await client.templates.saveVersion(templateId, {
// html,
// expectedVersion: err.details.currentVersion,
// message: 'Updated content (auto-retry)',
// });
}
throw err;
}
}Complete Editing Workflow
Here is a complete example of a collaborative editing session:
import { VeilMail } from '@veilmail/sdk';
const client = new VeilMail('veil_live_xxxxx');
const templateId = 'template_xxxxx';
// 1. Start editing: acquire lock and announce presence
const lock = await client.templates.lock(templateId);
console.log('Lock acquired, expires:', lock.expiresAt);
// 2. Set up heartbeats for both lock and presence
const lockHeartbeat = setInterval(async () => {
await client.templates.extendLock(templateId);
}, 5 * 60 * 1000); // Every 5 minutes
const presenceHeartbeat = setInterval(async () => {
await client.templates.updatePresence(templateId, {
line: 42,
column: 10,
});
}, 30_000); // Every 30 seconds
// 3. Check who else is viewing
const { data: editors } = await client.templates.getPresence(templateId);
console.log(`${editors.length} editors currently viewing`);
// 4. Get the current template to read the version number
const template = await client.templates.get(templateId);
// 5. Make edits and save as a new version
const newVersion = await client.templates.saveVersion(templateId, {
html: '<h1>Updated email content</h1><p>New paragraph.</p>',
subject: 'Updated subject line',
message: 'Redesigned header and added new CTA',
expectedVersion: template.version,
});
console.log('Saved version:', newVersion.version);
// Note: Lock is auto-released after saving
// 6. Clean up
clearInterval(lockHeartbeat);
clearInterval(presenceHeartbeat);
await client.templates.removePresence(templateId);Best Practices
Lock heartbeat interval
Send lock heartbeats every 5 minutes. Locks expire after 30 minutes, so this gives ample buffer for network issues.
Presence heartbeat interval
Send presence heartbeats every 30 seconds. Presence records are considered stale after 60 seconds, so 30-second intervals ensure continuous visibility.
Always clean up on exit
Use beforeunload events or equivalent cleanup hooks to release locks and remove presence when the user navigates away. Stale locks will auto-expire, but explicit cleanup provides a better experience for other users.
Use meaningful version messages
Include a descriptive message when saving versions. This helps team members understand what changed in each version when reviewing history.
Handle conflicts gracefully
When you receive a 409 Conflict on save, reload the latest template content and present a diff or merge UI to the user rather than silently overwriting.
API Reference
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| POST | /v1/templates/:id/lock | template:write | Acquire edit lock |
| DELETE | /v1/templates/:id/lock | template:write | Release edit lock |
| POST | /v1/templates/:id/lock/extend | template:write | Extend lock (heartbeat) |
| GET | /v1/templates/:id/versions | template:read | List version history |
| GET | /v1/templates/:id/versions/:version | template:read | Get specific version |
| POST | /v1/templates/:id/versions | template:write | Save new version |
| POST | /v1/templates/:id/restore/:version | template:write | Restore previous version |
| POST | /v1/templates/:id/presence | template:read | Update presence (heartbeat) |
| GET | /v1/templates/:id/presence | template:read | Get active editors |
| DELETE | /v1/templates/:id/presence | template:read | Remove own presence |
Response Types
TemplateLock
| Field | Type | Description |
|---|---|---|
| id | string | Lock ID |
| templateId | string | Template ID |
| lockedBy | string | User or API key ID |
| lockedByName | string | Display name |
| lockedAt | string | ISO 8601 timestamp |
| expiresAt | string | ISO 8601 expiry time |
TemplateVersion
| Field | Type | Description |
|---|---|---|
| id | string | Version record ID |
| version | number | Sequential version number |
| html | string | Full HTML content snapshot |
| subject | string | null | Subject line at this version |
| editedBy | string | Editor ID |
| editedByName | string | Editor display name |
| message | string | null | Optional commit message |
| createdAt | string | ISO 8601 timestamp |
TemplatePresence
| Field | Type | Description |
|---|---|---|
| userId | string | User or API key ID |
| userName | string | Display name |
| cursor | { line, column } | null | Cursor position in the editor |
| lastSeenAt | string | ISO 8601 last heartbeat time |