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": {
"status_id": 2,
"work_order_id": 12345
}
},
"timestamp": "2026-01-20T08:55:58-06:00",
"triggered_by_user": {
"id": 1068
},
"workorder": {
"id": 12345,
"title": "Router Installation - Site 42"
}
}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.
Every webhook delivery shares the same envelope. The payload always includes these four top-level keys: event, timestamp, triggered_by_user, and workorder. The table below documents both those top-level fields and selected nested properties using dotted notation such as event.name and workorder.id.
Prop
Type
Description
eventobjectEvent metadata. Contains name (the event type string, e.g. workorder.status.published) and params (event-specific data — varies per event, see Event Params by Type)
event.namestringThe event type identifier. One of the 33 supported event names (e.g. workorder.status.assigned)
event.paramsobjectEvent-specific parameters. Shape varies per event — status events include status_id and work_order_id, activity events include the full domain object (message, task, attachment, etc.)
timestampstringISO 8601 timestamp with timezone offset when the event occurred. Example: 2026-01-20T08:55:58-06:00
triggered_by_userobjectThe user who caused this event. Contains id (integer user ID)
triggered_by_user.idintegerField Nation user ID of the person or system that triggered the event
workorderobjectSummary of the affected work order. Contains id and title. For the full work order object, use the REST API with the id from this field
workorder.idintegerWork order ID. Use this to fetch the full work order via GET /api/rest/v2/workorders/{id}
workorder.titlestringWork order title at the time the event fired
The workorder object shown in examples on this page is abbreviated for readability. In production, the webhook delivers the full work order — the same object returned by the REST API, including status, location, schedule, pay, assignee, custom fields, and more.
Need a different payload shape? You can transform the webhook payload, filter fields, and build dynamic URLs using Payload Customization with JSONata expressions — no middleware required.
{
"event": {
"name": "workorder.status.published",
"params": {
"status_id": 2,
"work_order_id": 12345
}
},
"timestamp": "2026-01-20T08:55:58-06:00",
"triggered_by_user": {
"id": 1068
},
"workorder": {
"id": 12345,
"title": "Router Installation - Site 42"
}
}The event.params shape varies by event. Status change events carry minimal metadata, while activity events carry the full domain object.
Most workorder.status.* events share a simple structure:
{
"status_id": 2,
"work_order_id": 12345
}Events with this shape: workorder.status.draft, workorder.status.published, workorder.status.routed, workorder.status.assigned, workorder.status.approved, workorder.status.work_done, workorder.status.paid, workorder.status.deleted.
Some status events carry additional context:
{
"cancel_reason": "Site not ready for installation",
"cancel_request_not_charge": false,
"message_to_provider": ""
}{
"work_order_id": 12345,
"reason": "Provider no longer available",
"status_id": 2
}{
"work_order_id": 12345,
"reason": "Waiting for parts to arrive",
"hold_type": "general",
"created": {
"utc": "2026-01-20 16:00:00",
"local": { "date": "2026-01-20", "time": "11:00:00" }
}
}{
"work_order_id": 12345,
"created": {
"utc": "2026-01-20 14:05:00",
"local": { "date": "2026-01-20", "time": "09:05:00" }
}
}{
"out": {
"utc": "2026-01-20 18:00:00",
"local": { "date": "2026-01-20", "time": "13:00:00" }
},
"hours": 4.0
}Events workorder.status.confirmed, workorder.status.on_my_way, workorder.status.at_risk, and workorder.status.delayed share an ETA structure:
{
"eta": {
"start": {
"utc": "2026-01-20 14:00:00",
"local": { "date": "2026-01-20", "time": "09:00:00" }
},
"end": {
"utc": "2026-01-20 18:00:00",
"local": { "date": "2026-01-20", "time": "13:00:00" }
},
"hour_estimate": 4,
"status": {
"name": "confirmed",
"updated": {
"utc": "2026-01-19 20:00:00",
"local": { "date": "2026-01-19", "time": "15:00:00" }
}
},
"mode": "exact"
}
}The status.name value matches the event: confirmed, onmyway, at_risk, or delayed.
Activity events include the full domain object in event.params:
{
"message": {
"msg_id": 1024,
"author": {
"id": 557,
"first_name": "Jane",
"last_name": "Smith"
},
"body": "Cable connections verified on site.",
"created": {
"utc": "2026-01-20 15:45:00",
"local": { "date": "2026-01-20", "time": "10:45:00" }
},
"parent_id": null,
"visibility": "all"
}
}{
"id": 301,
"attachment": {
"id": 301,
"file": {
"name": "site_photo.jpg",
"link": "https://app.fieldnation.com/attachments/301/site_photo.jpg",
"size": 245760,
"mime": "image/jpeg"
},
"created": {
"utc": "2026-01-20 16:00:00",
"local": { "date": "2026-01-20", "time": "11:00:00" }
},
"author": {
"id": 557,
"first_name": "Jane",
"last_name": "Smith"
},
"folder": { "id": 1, "name": "Photos" }
}
}{
"task": {
"id": 886,
"type": { "id": 2 },
"group": { "id": "post" },
"label": "Enter close out notes",
"completed": true,
"completed_date": {
"utc": "2026-01-20 15:30:00",
"local": { "date": "2026-01-20", "time": "10:30:00" }
}
}
}{
"task": {
"id": 886,
"type": { "id": 2 },
"group": { "id": "post" },
"label": "Enter close out notes"
}
}{
"work_order_id": 12345,
"routed_ids": [557, 558],
"qualifications_version": 1
}{
"work_order_id": 12345,
"request": {
"id": 48,
"counter": false,
"active": true,
"notes": "",
"created": {
"utc": "2026-01-20 00:08:17",
"local": { "date": "2026-01-19", "time": "19:08:17" }
},
"eta": {
"start": {
"utc": "2026-01-20 15:00:00",
"local": { "date": "2026-01-20", "time": "10:00:00" }
},
"hour_estimate": 2
},
"technician": {
"id": 557,
"first_name": "Jane",
"last_name": "Smith"
}
}
}{
"work_order_id": 12345,
"user_id": 557,
"group_id": 1,
"reason": "Schedule conflict"
}{
"work_order_id": 12345,
"user_id": 557,
"group_id": 1
}{
"time_zone": { "id": 4 },
"service_window": {
"mode": "exact",
"start": { "local": { "date": "2026-01-20", "time": "06:00" } },
"end": { "local": { "date": "2026-01-20", "time": "08:00" } }
},
"require_ontime": true
}{
"custom_field_id": 42,
"value": "Updated value",
"old_value": "Previous value",
"name": "Site Contact Name"
}{
"id": 1001,
"custom_tag": true
}{
"part_id": 15,
"name": "Ethernet Cable CAT6",
"quantity": 3,
"cost": 12.50,
"status": "approved"
}{
"id": 12,
"type": { "id": 1, "name": "Equipment Issue" },
"description": "Router was DOA on arrival",
"resolution": { "status": "open" },
"created": {
"utc": "2026-01-20 15:00:00",
"local": { "date": "2026-01-20", "time": "10:00:00" }
},
"author": {
"id": 557,
"first_name": "Jane",
"last_name": "Smith"
}
}{
"id": 12,
"type": { "id": 1, "name": "Equipment Issue" },
"description": "Router was DOA on arrival",
"resolution": {
"status": "resolved",
"notes": "Replacement router shipped and installed"
},
"resolved": {
"utc": "2026-01-20 17:00:00",
"local": { "date": "2026-01-20", "time": "12:00:00" }
}
}The workorder.created event has the largest event.params — it contains the full work order creation payload including location, schedule, pay, tasks, and custom fields. See the complete event catalog for details.
The webhook workorder field only contains id and title. To get the complete work order — status, location, schedule, pay, assignee, custom fields, and more — call the REST API:
curl -X GET "https://api.fieldnation.com/api/rest/v2/workorders/12345" \
-H "Authorization: Bearer ${FN_ACCESS_TOKEN}" \
-H "Accept: application/json"The response matches the Work Order REST API schema, which includes 100+ fields across these domains:
Prop
Type
Description
idintegerUnique work order identifier
titlestringWork order title
statusobjectCurrent status with id and name (e.g. { "id": 2, "name": "Published" })
companyobjectBuyer company — id and name
locationobjectService site — city, state, zip, country, geo (lat/lng)
scheduleobjectService window — service_window, time_zone, eta
payobjectPay structure — type (fixed/hourly/device/blended), base, additional
manager?objectBuyer project manager — id, first_name, last_name
assignee?object | nullAssigned provider (null if unassigned) — id, first_name, last_name, phone, email
custom_fields?objectCustom fields configured by the buyer — results[] array with name, value pairs
tags?objectWork order tags — results[] array with id, name
integrations?objectExternal system references — results[] array with id, name (e.g. ServiceNow ticket IDs)
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