Understanding webhook payloads - standard fields, work order data, headers, and how to parse webhook requests.
Field Nation sends webhooks as HTTP POST or PUT requests (configurable) to your endpoint:
POST /webhooks/fieldnation HTTP/1.1
Host: your-endpoint.com
Content-Type: application/json
X-FN-Signature: sha256=bba5b9134ae2fdd020c2b55ab93a113a...
X-FN-Webhook-Id: 892d08af-23bc-44e0-bafa-fac1cc88b559
X-FN-Delivery-Id: 66b2c434-519e-4b3f-be20-00216b600873:1
X-FN-Event-Trace-Id: be107c0c-5095-4d74-aa25-45e450777869
X-FN-Request-Timestamp: 1768920958434
{
"event": {
"name": "workorder.status.published",
"params": {
"work_order_id": "12345",
"status_id": 2,
"previous_status_id": 1
}
},
"timestamp": "2026-01-20T08:55:58-06:00",
"triggered_by_user": {
"id": 1068
},
"workorder": {
"id": 12345,
"title": "Work Order Title",
"status": { "id": 2, "name": "Published" }
}
}Every webhook request includes these headers:
Prop
Type
Description
X-FN-SignaturestringHMAC-SHA256 signature for verifying webhook authenticity. Format: `sha256=<hex_digest>`
X-FN-Webhook-IdstringUnique identifier of the webhook configuration (UUID format)
X-FN-Delivery-IdstringUnique identifier for this delivery attempt. Format: `uuid:attempt_number`. Use for idempotency
X-FN-Event-Trace-IdstringTrace ID for correlating this event across systems (UUID format)
X-FN-Request-TimestampstringUnix timestamp in milliseconds when the webhook was sent
Content-TypestringAlways `application/json`
You can configure additional headers when creating the webhook:
{
"webhookAttribute": {
"header": {
"Authorization": "Bearer your-api-token",
"X-Custom-ID": "your-identifier"
}
}
}Reserved Prefix: Headers cannot start with x-fn- as this prefix is reserved for Field Nation system headers.
Prop
Type
Description
eventobjectEvent metadata containing `name` (event type) and `params` (event-specific parameters like status IDs)
timestampstringISO 8601 timestamp when the event occurred (includes timezone offset)
triggered_by_userobjectUser who triggered this event. Contains `id` field with the user ID
workorderobjectComplete work order object with all current details including status, location, schedule, and custom fields
{
"event": {
"name": "workorder.status.published",
"params": {
"work_order_id": "12345",
"work_order_company_id": "100",
"status_id": 2,
"previous_status_id": 1,
"skip_integration": false
}
},
"timestamp": "2026-01-20T08:55:58-06:00",
"triggered_by_user": {
"id": 1068
},
"workorder": {
"id": 12345,
"title": "Router Installation - Site 42",
"work_order_id": 12345,
"status": {
"id": 2,
"name": "Published"
},
"company": {
"id": 100,
"name": "Acme Corporation"
},
"location": {
"city": "San Francisco",
"state": "CA",
"zip": "94105",
"country": "US"
},
"schedule": {
"start": { "utc": "2026-01-20T09:00:00Z" },
"end": { "utc": "2026-01-20T17:00:00Z" }
},
"pay": {
"type": "fixed",
"base": { "amount": 250.00 }
},
"manager": {
"id": 200,
"first_name": "John",
"last_name": "Smith"
},
"assignee": null,
"custom_fields": {
"results": [
{ "name": "PO Number", "value": "PO-2026-001" }
]
},
"tags": {
"results": [
{ "id": 1, "name": "Priority" }
]
},
"integrations": {
"results": [
{ "id": "INC0010011", "name": "servicenow v3" }
]
}
}
}The data field contains the complete work order object, which matches the structure returned by the REST API.
{
"id": 12345,
"title": "Router Installation - Site 42",
"description": "Install and configure Cisco router",
"status": "published",
"type": "installation",
"priority": "normal",
"tags": ["priority", "router"],
"customFields": {
"ticketNumber": "INC-12345"
},
"createdAt": "2025-01-15T09:00:00Z",
"updatedAt": "2025-01-15T10:30:00Z"
}{
"schedule": {
"serviceWindow": {
"start": "2025-01-20T09:00:00Z",
"end": "2025-01-20T17:00:00Z",
"mode": "hours"
},
"estimatedHours": 3
},
"location": {
"address1": "123 Main Street",
"address2": "Suite 100",
"city": "San Francisco",
"state": "CA",
"zip": "94105",
"country": "US",
"coordinates": {
"latitude": 37.7749,
"longitude": -122.4194
},
"notes": "Enter through main lobby"
}
}{
"pay": {
"type": "fixed",
"amount": 250.00,
"currency": "USD",
"hourlyRate": null
},
"expenses": {
"mileage": {
"allowed": true,
"rate": 0.655,
"units": "miles"
},
"additional": {
"allowed": true,
"maxAmount": 50.00
}
}
}{
"buyer": {
"id": 456,
"name": "Acme Corporation",
"companyId": 789
},
"provider": {
"id": 789,
"name": "John Smith",
"userId": 1011,
"rating": 4.8
},
"contacts": [
{
"name": "Jane Doe",
"phone": "+1-555-0123",
"email": "jane@example.com",
"role": "site_contact"
}
]
}Full Schema: The complete work order schema includes 100+ fields. Refer to the REST API documentation for the full specification.
Critical for signature verification - read the raw request body before parsing:
const express = require('express');
const app = express();
// Use raw body parser
app.use(express.raw({ type: 'application/json' }));
app.post('/webhooks', (req, res) => {
const rawBody = req.body; // Buffer
const payload = JSON.parse(rawBody.toString());
// Now you can verify signature with rawBody
// and work with parsed payload
});Always verify the x-fn-signature header before processing:
const crypto = require('crypto');
function verifySignature(rawBody, signature, secret) {
const [algorithm, hash] = signature.split('=');
const expectedHash = crypto
.createHmac(algorithm, secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expectedHash),
Buffer.from(hash)
);
}const {
event,
timestamp,
triggered_by_user,
workorder
} = payload;
// Get delivery ID from headers for idempotency
const deliveryId = req.headers['x-fn-delivery-id'];
// Check idempotency
if (await hasProcessedEvent(deliveryId)) {
return res.status(200).send('Already processed');
}// Handle based on event type
switch (event.name) {
case 'workorder.status.published':
await handlePublished(workorder);
break;
case 'workorder.status.assigned':
await handleAssigned(workorder);
break;
// ... more handlers
}// Acknowledge receipt immediately
res.status(200).send('OK');
// Process asynchronously
processWebhookAsync(payload);const express = require('express');
const crypto = require('crypto');
const app = express();
// Use raw body for signature verification
app.use(express.raw({ type: 'application/json' }));
app.post('/webhooks/fieldnation', async (req, res) => {
try {
// 1. Verify signature
const signature = req.headers['x-fn-signature'];
const secret = process.env.WEBHOOK_SECRET;
if (!verifySignature(req.body, signature, secret)) {
return res.status(401).send('Invalid signature');
}
// 2. Parse payload
const payload = JSON.parse(req.body.toString());
const {
event,
timestamp,
triggered_by_user,
workorder
} = payload;
// 3. Get delivery ID for idempotency (from headers)
const deliveryId = req.headers['x-fn-delivery-id'];
if (await hasProcessedEvent(deliveryId)) {
console.log(`Duplicate event: ${deliveryId}`);
return res.status(200).send('Already processed');
}
// 4. Respond immediately (< 5 seconds)
res.status(200).send('OK');
// 5. Process asynchronously
await processEventAsync({
eventName: event.name,
eventParams: event.params,
deliveryId,
timestamp,
triggeredByUser: triggered_by_user,
workorder
});
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).send('Internal error');
}
});
function verifySignature(rawBody, signature, secret) {
if (!signature) return false;
const [algorithm, hash] = signature.split('=');
const expectedHash = crypto
.createHmac(algorithm, secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expectedHash),
Buffer.from(hash)
);
}
async function hasProcessedEvent(eventId) {
// Check your database or cache
return await redis.exists(`event:${eventId}`);
}
async function processEventAsync(event) {
// Mark as processing
await redis.setex(`event:${event.deliveryId}`, 86400, 'processed');
// Handle based on event type
switch (event.eventName) {
case 'workorder.status.published':
await syncToSalesforce(event.workorder);
await notifyProviders(event.workorder);
break;
case 'workorder.status.assigned':
await updateDispatchBoard(event.workorder);
await notifyTechnician(event.workorder);
break;
case 'workorder.status.work_done':
await triggerApprovalFlow(event.workorder);
break;
case 'workorder.status.approved':
await generateInvoice(event.workorder);
await updateAccounting(event.workorder);
break;
}
}
app.listen(3000);Webhooks may be delivered more than once (due to retries). Always check if you've already processed an event.
Idempotency Key: Use the X-FN-Delivery-Id header as your idempotency key. This unique ID is provided with every delivery attempt.
// Redis example
async function processWebhook(req, payload) {
const deliveryId = req.headers['x-fn-delivery-id'];
const key = `webhook:${deliveryId}`;
// Try to set key with NX (only if not exists)
const wasSet = await redis.set(key, '1', 'EX', 86400, 'NX');
if (!wasSet) {
console.log('Duplicate webhook, skipping');
return;
}
// Process the webhook
await handleEvent(payload);
}CREATE TABLE processed_webhooks (
event_id VARCHAR(255) PRIMARY KEY,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
event_name VARCHAR(255),
work_order_id INTEGER
);
CREATE INDEX idx_processed_at ON processed_webhooks(processed_at);async function hasProcessedEvent(eventId) {
const result = await db.query(
'SELECT event_id FROM processed_webhooks WHERE event_id = $1',
[eventId]
);
return result.rows.length > 0;
}
async function markEventProcessed(eventId, eventName, workOrderId) {
await db.query(
'INSERT INTO processed_webhooks (event_id, event_name, work_order_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
[eventId, eventName, workOrderId]
);
}Your endpoint must respond correctly to receive continued deliveries:
HTTP/1.1 200 OK
Content-Type: text/plain
OKResponse Body: Field Nation doesn't parse the response body. Any 2xx status code indicates success.
These status codes trigger retries:
These status codes skip retries:
Some work orders have extensive data. The webhook includes the complete current state, but for historical data:
async function enrichWorkOrder(workOrderId) {
const response = await fetch(
`https://api.fieldnation.com/api/rest/v2/workorder/${workOrderId}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`
}
}
);
return await response.json();
}For debugging, retrieve the full request/response:
curl -X GET https://api-sandbox.fndev.net/api/v1/webhooks/delivery-logs/del_xyz789 \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"Response includes a pre-signed URL to the complete delivery log file.
app.post('/webhooks', async (req, res) => {
// Verify first
if (!verifySignature(req.body, req.headers['x-fn-signature'], secret)) {
return res.status(401).send('Invalid signature');
}
// Parse once
const payload = JSON.parse(req.body.toString());
// Validate required fields
if (!payload.eventId || !payload.eventName || !payload.data) {
return res.status(400).send('Invalid payload');
}
// Continue processing...
});Not all work orders include all fields:
const provider = data.provider || null;
const providerName = provider?.name || 'Unassigned';
const tags = data.tags || [];
const customFields = data.customFields || {};if (process.env.NODE_ENV === 'development') {
console.log('Raw webhook payload:', JSON.stringify(payload, null, 2));
}Security: Never log webhooks in production—they contain sensitive data (PII, financial details, etc.)
Last updated on