This article is the technical companion to Webhooks: Automatically Send PaintForce Data to Other Apps. It covers exactly what PaintForce sends, so you can map the fields correctly from the start.
Every example below is a real webhook captured from PaintForce. Only the names, emails and IDs have been changed.
The request
Method:
POSTto the URL entered in Settings → Webhooks.Headers:
Content-Type: application/json. There's no authentication header, API key or signature (see Security).Body: always a JSON array containing one object, and the record is inside
eventData:
[
{
"eventData": {
"webhookActionType": "jobUpdated",
"id": "4fd03e4b-1782-4c73-848c-7302508f02db",
"...": "fields for this event"
}
}
]So the record is at body[0].eventData. In Zapier, the fields show up as Event Data Id, Event Data Status Name, and so on.
How to respond
Reply with HTTP 200 as soon as you've received the request. Do any slow work (geocoding, API calls) afterwards. PaintForce waits up to 60 seconds for a reply.
Use the final URL. Don't redirect: when a POST is redirected, the body can be dropped.
Delivery behavior
Timing: usually within a few seconds of the change being saved.
One attempt, no retries: if your endpoint is unreachable or returns an error, that event isn't sent again. If you need a complete copy of your data, reconcile now and then with a Reports export.
One request per event per URL: if two webhook rows use the same URL and action, you get two requests.
No guaranteed order, and occasional duplicates are possible. Treat each message as the latest snapshot of that record, and use its
idto update rather than insert.One action can cause several events. For example, recording a payment on an invoice sends Payment Created, Invoice Updated and Job Updated. "Updated" events fire on every save of the record, including automatic recalculations.
No history: only changes made after the webhook is switched on are sent.
Knowing which event you received
Most events include eventData.webhookActionType (for example "jobUpdated"). Two kinds of event don't include it:
payment events (
paymentCreated,paymentUpdated,paymentDeleted)contactDeleted
Because each webhook row pairs one action with one URL, the simplest approach is a separate URL per action, or the same URL with a query parameter, e.g. https://example.com/hook?event=paymentCreated.
Action in Settings | webhookActionType |
Job Created / Updated / Deleted |
|
Contact Created / Updated / Deleted |
|
Estimate Created / Updated / Deleted |
|
Invoice Created / Updated / Deleted |
|
Payment Created / Updated / Deleted | (not included) |
Expense Created / Updated / Deleted |
|
Request Created / Updated / Deleted |
|
Data conventions
IDs:
idis the permanent unique ID of a record (use it to match updates).shortIdis the human-friendly number shown in the app: "Job #42" hasshortId"42", and an estimate or invoice has one like"XKE09".orgIdidentifies your PaintForce company account.Money: plain numbers in dollars, not cents (
184.37).Timestamps (
creationDate,customerApprovedAt…): ISO 8601 in UTC, e.g."2026-09-18T17:14:06.327Z".Calendar dates:
scheduledStartDate,scheduledEndDate,dateWon,dateCompleted, an expense'spurchaseDateand a payment'sdateare days, not moments.They're sent as midnight in your company's time zone, written in UTC. For a company in Denver, September 18 arrives as
"2026-09-18T06:00:00.000Z".To get the calendar day back, convert the value to your company's time zone and take the date.
Empty values: a field that isn't filled in can arrive as
"",null,[], or be missing. Treat all four as "empty".Phone numbers can arrive as a number (
3035550123) or a string. Convert to a string before using them.New fields may be added over time. Ignore fields you don't recognize rather than rejecting the request.
Shared objects
customerContact
The customer on a job, estimate, invoice, expense or request:
id,firstName,lastName,email,phone,companysalesRep(a person, ornull)selectedAddressIdaddress, the job site address:street,additionalStreet(apartment, suite…)city,state,postal,countryaddressId
Person (createdBy, salesRep, leadSetter)
{ "displayName", "email", "userId" }. displayName can be blank. When no one is assigned, jobs send "" and other records send null.
status (jobs)
{ "id", "name", "relatedStage", "active", "color" }. name is the status name your company chose, e.g. "Paid & Closed". relatedStage is one of these fixed values, which is what you should filter on:
relatedStage | Stage shown in Settings → Job Statuses |
| Lead |
| Estimating |
| Sold |
| Lost |
| In Production |
| Accounts Receivable |
| Complete |
customFields
A list of your company's custom fields and their values for this record: [{ "id", "key", "label", "type", "value" }]. type is "text" or "dropdown". Match fields on key or label.
Other shared fields
leadSource:{ "id", "name" }, or empty.projectManagers/projectCrews: the assigned person{ "userId", "firstName", "lastName", "email", "role" }. Older jobs may send a list of these instead. Empty is[]ornull.
Job events
jobUpdated
Sent on every save of a job. status is included every time, so it's safe to filter on status.relatedStage.
Identity:
id,shortId,orgId,title,status,customerContactMoney (dollars):
estimatedTotalRevenue,estimatedTotalCost,estimatedNetProfit,actualTotalRevenue,actualTotalCost,actualNetProfitPeople:
salesRep,leadSetter,leadSource,projectManagers,projectCrewsDates:
scheduledStartDate,scheduledEndDate,dateWon,dateCompletedOther:
customFields
dateWon is set automatically the first time a job reaches a Sold, In Production, Accounts Receivable or Complete stage. dateCompleted is set the first time it reaches a Complete stage. Both can also be edited by hand.
[
{
"eventData": {
"shortId": "32",
"orgId": "9f1c2d3e-1111-4a5b-8c9d-0e1f2a3b4c5d",
"status": {
"active": true,
"id": "a0da09bc-7925-479f-9db2-51177b48610a",
"name": "Paid & Closed",
"relatedStage": "complete"
},
"id": "4fd03e4b-1782-4c73-848c-7302508f02db",
"title": "Sample Customer (Interior)",
"customerContact": {
"address": {
"additionalStreet": "",
"addressId": "a3ae8430-b69b-4611-b331-0a27b984d7a7",
"city": "Arvada",
"country": "United States",
"postal": "80004-1247",
"state": "CO",
"street": "123 Main St"
},
"company": "",
"email": "",
"firstName": "Sample",
"id": "b4e3c3fd-fc65-433c-ae2d-c12a2fd8807a",
"lastName": "Customer",
"phone": null,
"salesRep": null,
"selectedAddressId": "a3ae8430-b69b-4611-b331-0a27b984d7a7"
},
"actualTotalRevenue": 1259,
"actualTotalCost": 302.16,
"actualNetProfit": 956.84,
"estimatedTotalRevenue": 1259,
"estimatedTotalCost": 1083.64,
"estimatedNetProfit": 175.36,
"webhookActionType": "jobUpdated",
"salesRep": {
"displayName": "Alex Rivera",
"email": "[email protected]",
"userId": "Xr7kP2mQ9sT4vW1yZ3aB5cD8eF0g"
},
"leadSetter": "",
"leadSource": "",
"projectManagers": [],
"projectCrews": [],
"scheduledStartDate": null,
"scheduledEndDate": null,
"dateWon": "2026-09-18T06:00:00.000Z",
"dateCompleted": "2026-09-18T06:00:00.000Z",
"customFields": [
{
"id": "1ac7ba28-f62c-4d4d-8021-234c8a1e9df2",
"key": "HLBX622",
"label": "Gate code",
"type": "text",
"value": "4412"
}
]
}
}
]jobCreated
Includes id, shortId, orgId, title, customerContact, createdBy, creationDate, latestJobTotal, jobType ("interior", "exterior" or "cabinets"), salesRep, leadSetter, leadSource, projectManagers, projectCrews, the four dates and customFields.
It does not include status or the revenue and profit totals. Those come in the jobUpdated events that follow.
jobDeleted
Only { "shortId", "orgId", "webhookActionType": "jobDeleted" }. The job's id isn't included, so match on shortId. Deleting a job doesn't send separate delete events for its estimates, invoices or expenses.
Contact events
contactCreated / contactUpdated
The contact's ID is contactId (not id). address is the contact's selected address, and the second address line is called street2 here. contactUpdated has the same fields and is sent on every save, including when a contact is archived.
[
{
"eventData": {
"contactId": "ce854792-7072-4c3e-99db-1f65a02aa85d",
"email": "[email protected]",
"firstName": "Jane",
"lastName": "Smith",
"phone": 3035550123,
"company": "",
"creationDate": "2026-09-18T17:13:59.495Z",
"orgId": "9f1c2d3e-1111-4a5b-8c9d-0e1f2a3b4c5d",
"address": {
"addressId": "072c73b0-1aa7-4258-b499-0ea9c1b10b5c",
"city": "Denver",
"postal": "80202",
"state": "CO",
"street": "742 Evergreen Terrace",
"street2": "",
"country": "United States"
},
"salesRep": {
"displayName": "Alex Rivera",
"email": "[email protected]",
"userId": "Xr7kP2mQ9sT4vW1yZ3aB5cD8eF0g"
},
"customFields": [],
"webhookActionType": "contactCreated"
}
}
]contactDeleted
Only { "contactId" }, with no webhookActionType.
Estimate events (quote…)
quoteCreated and quoteUpdated include:
shortId(estimate number) andparentJobShortId(job number)orgId,customerContact,createdBy,creationDatetotalAmount(including tax),taxAmount,totalAmountWithoutTaxdueDate, which is always 30 days after creation
quoteUpdated also includes customerApprovedAt, the moment the customer signed (or null).
Line items aren't included, and the estimate's own id isn't either, so match on shortId. Estimates without a customer contact don't send created or updated events. quoteDeleted sends shortId, parentJobShortId and orgId.
[
{
"eventData": {
"customerContact": {
"...": "same shape as in the jobUpdated example"
},
"createdBy": {
"displayName": "",
"email": "[email protected]",
"userId": "Xr7kP2mQ9sT4vW1yZ3aB5cD8eF0g"
},
"creationDate": "2026-09-18T17:14:06.327Z",
"dueDate": "2026-10-18T17:14:06.327Z",
"totalAmount": 0,
"taxAmount": 0,
"totalAmountWithoutTax": 0,
"shortId": "4DO58",
"parentJobShortId": "42",
"orgId": "9f1c2d3e-1111-4a5b-8c9d-0e1f2a3b4c5d",
"customerApprovedAt": "2026-09-18T17:15:07.002Z",
"webhookActionType": "quoteUpdated"
}
}
]Invoice events
invoiceCreated and invoiceUpdated have the same fields as estimates (except customerApprovedAt), plus:
paymentStatus:"unpaid","partial","paid"or"pending"paidInFullAt: date the invoice became fully paid, ornullpaymentRecords: every payment on the invoicecostBudgets
invoiceDeleted sends shortId, parentJobShortId and orgId.
[
{
"eventData": {
"customerContact": {
"...": "same shape as in the jobUpdated example"
},
"createdBy": {
"displayName": "",
"email": "[email protected]",
"userId": "Xr7kP2mQ9sT4vW1yZ3aB5cD8eF0g"
},
"creationDate": "2026-09-18T17:16:25.963Z",
"dueDate": "2026-10-18T17:16:25.963Z",
"totalAmount": 0,
"taxAmount": 0,
"totalAmountWithoutTax": 0,
"shortId": "XKE09",
"parentJobShortId": "42",
"orgId": "9f1c2d3e-1111-4a5b-8c9d-0e1f2a3b4c5d",
"costBudgets": [],
"webhookActionType": "invoiceUpdated",
"paymentStatus": "unpaid",
"paidInFullAt": null,
"paymentRecords": [
{
"amount": 500,
"date": "2026-09-18T06:00:00.000Z",
"id": "de8db5cd-cadb-421c-af66-84ac572973eb",
"method": "credit_card",
"note": "Deposit",
"status": "completed"
}
]
}
}
]Payment events
paymentCreated, paymentUpdated and paymentDeleted are sent for payments on invoices and on estimates (deposits), including online Stripe payments. Each payment that changes sends its own request. The fields are:
id: the payment's IDamount: in dollarsdate: a calendar date (see Data conventions)method:check,cash,credit_card,ach,venmo,zelle,cash_apporotherstatus:completed,pending,failedorrefundednotetitle: describes where the payment belongs, in the form "<job title>. Job: <job #>. Invoice #<invoice #>" (or Quote # for estimate deposits)
There's no webhookActionType, orgId or job ID. An online Stripe payment usually arrives first as pending (Payment Created), then changes to completed or failed (Payment Updated).
[
{
"eventData": {
"id": "de8db5cd-cadb-421c-af66-84ac572973eb",
"title": "Jane Smith (Exterior). Job: 42. Invoice #XKE09",
"method": "credit_card",
"date": "2026-09-18T06:00:00.000Z",
"amount": 500,
"status": "completed",
"note": "Deposit"
}
}
]Expense events
expenseCreated and expenseUpdated include:
The expense:
id,amount,currency,category("labor"or"materials"),expenseTitle,description,purchaseDateThe vendor:
vendorId,vendorNameimages(receipt photos) andcustomFieldsThe job it belongs to:
jobId,jobShortId,customerContact,jobSalesRep,jobProjectManagers,jobProjectCrews,createdBy(the job's creator)
expenseDeleted sends id, jobId and orgId.
[
{
"eventData": {
"amount": 184.37,
"category": "materials",
"currency": "usd",
"customFields": [],
"description": "5 gal exterior satin",
"expenseTitle": "Paint and primer",
"id": "BHREG",
"images": [],
"purchaseDate": "2026-09-18T06:00:00.000Z",
"vendorId": "0e4ac587-536c-4bbc-8181-bbc4ba3e1bdd",
"vendorName": "Sherwin-Williams #1234",
"jobId": "841236ed-5e30-4a25-a4fc-27baa6dfe3bf",
"jobShortId": "42",
"createdBy": {
"displayName": "",
"userId": "Xr7kP2mQ9sT4vW1yZ3aB5cD8eF0g",
"email": "[email protected]"
},
"jobSalesRep": {
"displayName": "",
"userId": "Xr7kP2mQ9sT4vW1yZ3aB5cD8eF0g",
"email": "[email protected]"
},
"jobProjectManagers": null,
"jobProjectCrews": null,
"customerContact": {
"...": "same shape as in the jobUpdated example"
},
"orgId": "9f1c2d3e-1111-4a5b-8c9d-0e1f2a3b4c5d",
"webhookActionType": "expenseCreated"
}
}
]Request events
requestCreated, requestUpdated and requestDeleted send the full request:
id,title,detailscustomerContact,assignedTo,salesRep,leadSetter,leadSourceassessmentDateTime(the scheduled visit),scheduled,archivedtags,customFieldscreationDate,lastUpdatedDate
In requestUpdated and requestDeleted, lastUpdatedDate (and in deletes, assessmentDateTime) can arrive as an object like { "_seconds": 1789751970, "_nanoseconds": 0 } instead of an ISO string. _seconds is a Unix timestamp. Handle both forms.
[
{
"eventData": {
"archived": false,
"assessmentDateTime": null,
"assignedTo": {
"email": "[email protected]",
"firstName": "Alex",
"lastName": "Rivera",
"role": "owner",
"userId": "Xr7kP2mQ9sT4vW1yZ3aB5cD8eF0g"
},
"createdBy": {
"displayName": "",
"email": "[email protected]",
"userId": "Xr7kP2mQ9sT4vW1yZ3aB5cD8eF0g"
},
"creationDate": "2026-09-18T17:19:30.996Z",
"customFields": [],
"customerContact": {
"...": "same shape as in the jobUpdated example"
},
"details": "Customer wants a quote to repaint the porch and railings.",
"id": "b0c7e408-0c68-4795-97b2-24367b3fbc3d",
"lastUpdatedDate": "2026-09-18T17:19:30.996Z",
"leadSetter": null,
"leadSource": null,
"orgId": "9f1c2d3e-1111-4a5b-8c9d-0e1f2a3b4c5d",
"salesRep": {
"displayName": "Alex Rivera",
"email": "[email protected]",
"userId": "Xr7kP2mQ9sT4vW1yZ3aB5cD8eF0g"
},
"scheduled": false,
"tags": [],
"title": "Repaint front porch",
"webhookActionType": "requestCreated"
}
}
]Security
PaintForce webhooks don't support authentication or request signing. Your endpoint has to be publicly reachable. To make sure a request came from your PaintForce account:
Put a long, random secret in the URL you save in PaintForce (for example
?key=…), and reject requests without it.Check that
eventData.orgIdmatches your company's ID (take it from the first webhook you receive).Use
https://so the data is encrypted in transit.
Example: a minimal receiver
This Node.js (Express) example receives Job Updated webhooks and keeps only completed jobs. It uses the secret-in-URL check and saves by job id so repeats don't create duplicates.
const express = require("express");
const app = express();
app.use(express.json());const SECRET = process.env.PAINTFORCE_WEBHOOK_KEY;app.post("/paintforce-hook", (req, res) => {
if (req.query.key !== SECRET) return res.sendStatus(401);
res.sendStatus(200); // reply first, then do the work const job = req.body?.[0]?.eventData;
if (!job || job.webhookActionType !== "jobUpdated") return;
if (job.status?.relatedStage !== "complete") return; const a = job.customerContact?.address || {};
saveCompletedJob(job.id, {
jobNumber: job.shortId,
city: a.city, state: a.state, postal: a.postal,
completedOn: job.dateCompleted,
}); // your upsert, keyed by job.id
});app.listen(3000);Testing tips
To see the raw data before building anything, create a temporary URL at a request-inspector site such as webhook.site or a Zapier Catch Hook. Add it as a webhook, then make a change in a test job.
Remember that new webhooks start switched off. Turn the switch on and click Save.
Use a test job or test contact rather than a real customer while you experiment.