Migrate from legacy webhook implementations to Webhooks v3 with updated event names, improved delivery mechanics, and enhanced features.
model.action or model.status.value patternMany event names have changed in v3:
| v2 Event Name | v3 Event Name | Notes |
|---|---|---|
work_order.created | workorder.created | Removed underscore |
work_order.published | workorder.status.published | Now a status change event |
work_order.assigned | workorder.status.assigned | Now a status change event |
work_order.completed | workorder.status.work_done | Renamed to match platform terminology |
work_order.approved | workorder.status.approved | Now a status change event |
Action Required: Update your webhook event subscriptions to use new v3 event names.
v3 introduces a consistent payload structure:
v2 Payload (inconsistent):
{
"id": "evt_123",
"event": "work_order.published",
"work_order_id": 12345,
"created_at": "2025-01-15T10:00:00Z",
"work_order": {
// work order data
}
}v3 Payload (standardized):
{
"eventId": "evt_abc123",
"eventName": "workorder.status.published",
"workOrderId": 12345,
"timestamp": "2025-01-15T10:00:00Z",
"data": {
"id": 12345,
"status": "published",
// complete work order data
}
}Key Changes:
id → eventIdevent → eventNamework_order_id → workOrderIdcreated_at → timestampwork_order → datav3 uses HMAC-SHA256 instead of basic auth:
v2 (Basic Auth):
// Authorization: Basic base64(username:password)
const auth = req.headers.authorization;
const [username, password] = Buffer.from(auth.split(' ')[1], 'base64')
.toString()
.split(':');v3 (HMAC-SHA256):
// x-fn-signature: sha256=abc123...
const signature = req.headers['x-fn-signature'];
const [algorithm, hash] = signature.split('=');
const expectedHash = crypto
.createHmac(algorithm, secret)
.update(rawBody)
.digest('hex');
const valid = crypto.timingSafeEqual(
Buffer.from(expectedHash),
Buffer.from(hash)
);Document your current webhooks:
// List all v2 webhooks
const v2Webhooks = await fetch('https://api.fieldnation.com/v2/webhooks', {
headers: { 'Authorization': 'Bearer YOUR_TOKEN' }
});
// Document subscribed events
const webhooks = await v2Webhooks.json();
webhooks.forEach(webhook => {
console.log(`Webhook ${webhook.id}:`);
console.log(` URL: ${webhook.url}`);
console.log(` Events: ${webhook.events.join(', ')}`);
});Create event mapping:
const eventMapping = {
'work_order.created': 'workorder.created',
'work_order.published': 'workorder.status.published',
'work_order.assigned': 'workorder.status.assigned',
'work_order.completed': 'workorder.status.work_done',
'work_order.approved': 'workorder.status.approved',
'work_order.paid': 'workorder.status.paid',
'work_order.cancelled': 'workorder.status.cancelled',
// ... map all your events
};
function migrateEvents(v2Events) {
return v2Events.map(event => {
if (eventMapping[event]) {
return eventMapping[event];
}
console.warn(`No v3 mapping for event: ${event}`);
return null;
}).filter(Boolean);
}Modify your handler to support both v2 and v3 payloads during transition:
app.post('/webhooks/fieldnation', express.raw({type: 'application/json'}), (req, res) => {
// Detect version
const signature = req.headers['x-fn-signature'];
const isV3 = !!signature;
if (isV3) {
// v3 processing
if (!verifySignatureV3(req.body, signature, process.env.WEBHOOK_SECRET_V3)) {
return res.status(401).send('Unauthorized');
}
const payload = JSON.parse(req.body.toString());
processV3Webhook(payload);
} else {
// v2 processing (legacy)
if (!verifyBasicAuth(req.headers.authorization)) {
return res.status(401).send('Unauthorized');
}
const payload = JSON.parse(req.body.toString());
processV2Webhook(payload);
}
res.status(200).send('OK');
});
function processV3Webhook(payload) {
// Handle v3 payload structure
const { eventId, eventName, workOrderId, data } = payload;
console.log(`[v3] Event ${eventName} for work order ${workOrderId}`);
// ... process
}
function processV2Webhook(payload) {
// Handle v2 payload structure
const { id, event, work_order_id, work_order } = payload;
console.log(`[v2] Event ${event} for work order ${work_order_id}`);
// ... process
}Create new v3 webhooks in sandbox:
async function createV3Webhook(v2Webhook) {
// Map v2 events to v3
const v3Events = migrateEvents(v2Webhook.events);
// Create v3 webhook
const response = await fetch(
'https://api-sandbox.fndev.net/api/v1/webhooks',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: v2Webhook.url,
method: 'post',
status: 'active',
events: v3Events,
notificationEmail: 'alerts@example.com'
})
}
);
const v3Webhook = await response.json();
console.log(`Created v3 webhook ${v3Webhook.result.webhookId}`);
console.log(`Secret: ${v3Webhook.result.secret}`);
return v3Webhook.result;
}Thoroughly test v3 webhooks before production:
# Use ngrok for local testing
ngrok http 3000
# Create test webhook
curl -X POST https://api-sandbox.fndev.net/api/v1/webhooks \
-H "Authorization: Bearer YOUR_SANDBOX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-ngrok-url.ngrok-free.app/webhooks",
"method": "post",
"status": "active",
"events": ["workorder.status.published", "workorder.status.assigned"]
}'
# Trigger test events
# ... create and publish work orders in sandboxRun v2 and v3 webhooks in parallel:
// Keep v2 webhook active
// Add v3 webhook pointing to same endpoint
// Your handler supports both versions
app.post('/webhooks', (req, res) => {
const signature = req.headers['x-fn-signature'];
const version = signature ? 'v3' : 'v2';
console.log(`Received ${version} webhook`);
// Process based on version
if (version === 'v3') {
processV3(req);
} else {
processV2(req);
}
res.status(200).send('OK');
});Track v3 delivery health:
async function compareVersions() {
// v2 webhooks (your monitoring)
const v2Stats = await getV2Stats();
// v3 webhooks (delivery logs API)
const v3Logs = await fetch(
'https://api-sandbox.fndev.net/api/v1/webhooks/delivery-logs?webhookId=wh_abc123',
{
headers: { 'Authorization': `Bearer ${token}` }
}
);
const v3Stats = calculateStats(await v3Logs.json());
console.log('v2 Success Rate:', v2Stats.successRate);
console.log('v3 Success Rate:', v3Stats.successRate);
// Continue if v3 performs as well as v2
return v3Stats.successRate >= v2Stats.successRate;
}Once confident, switch to v3 only:
// 1. Update handler to v3 only
app.post('/webhooks', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['x-fn-signature'];
if (!verifySignatureV3(req.body, signature, secret)) {
return res.status(401).send('Unauthorized');
}
const payload = JSON.parse(req.body.toString());
processV3Webhook(payload);
res.status(200).send('OK');
});
// 2. Deactivate v2 webhooks
await deactivateV2Webhooks();
// 3. Monitor for issues
await monitorV3Health(24); // 24 hoursAfter successful migration:
// Delete v2 webhooks
await deleteV2Webhooks();
// Remove v2 compatibility code
// Update documentationSubscribe to new events not available in v2:
const newV3Events = [
'workorder.routed',
'workorder.requested',
'workorder.declined',
'workorder.undeclined',
'workorder.task_completed',
'workorder.task_incomplete',
'workorder.provider_upload',
'workorder.message_posted',
'workorder.custom_field_value_updated',
'workorder.schedule_updated',
'workorder.tag_added',
'workorder.tag_removed',
'workorder.part_updated'
];Leverage v3's automatic retry system:
// Monitor delivery logs
async function monitorDeliveries(webhookId) {
const logs = await fetch(
`https://api-sandbox.fndev.net/api/v1/webhooks/delivery-logs?webhookId=${webhookId}`,
{
headers: { 'Authorization': `Bearer ${token}` }
}
);
const { result } = await logs.json();
const failed = result.filter(log => log.deliveryStatus >= 400);
if (failed.length > 0) {
console.log(`${failed.length} failed deliveries`);
// Manual retry if needed
for (const log of failed) {
await retryDelivery(log.deliveryId);
}
}
}If issues arise, you can rollback:
// 1. Reactivate v2 webhooks
await reactivateV2Webhooks();
// 2. Deactivate v3 webhooks
await fetch(`https://api-sandbox.fndev.net/api/v1/webhooks/${webhookId}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: 'inactive' })
});
// 3. Restore v2 handler logicLast updated on