Skip to main content

Webhook Reference: Events and Data Format

Technical details for developers: the request format, delivery behavior, and every field PaintForce sends with each webhook event, with real examples.

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: POST to 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 id to 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

jobCreated, jobUpdated, jobDeleted

Contact Created / Updated / Deleted

contactCreated, contactUpdated, (not included)

Estimate Created / Updated / Deleted

quoteCreated, quoteUpdated, quoteDeleted ("quote" is the internal name for an estimate)

Invoice Created / Updated / Deleted

invoiceCreated, invoiceUpdated, invoiceDeleted

Payment Created / Updated / Deleted

(not included)

Expense Created / Updated / Deleted

expenseCreated, expenseUpdated, expenseDeleted

Request Created / Updated / Deleted

requestCreated, requestUpdated, requestDeleted

Data conventions

  • IDs: id is the permanent unique ID of a record (use it to match updates). shortId is the human-friendly number shown in the app: "Job #42" has shortId "42", and an estimate or invoice has one like "XKE09". orgId identifies 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's purchaseDate and a payment's date are 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, company

  • salesRep (a person, or null)

  • selectedAddressId

  • address, the job site address:

    • street, additionalStreet (apartment, suite…)

    • city, state, postal, country

    • addressId

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

Lead

estimating

Estimating

sold

Sold

lost

Lost

inProduction

In Production

accountsReceivable

Accounts Receivable

complete

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 [] or null.

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, customerContact

  • Money (dollars): estimatedTotalRevenue, estimatedTotalCost, estimatedNetProfit, actualTotalRevenue, actualTotalCost, actualNetProfit

  • People: salesRep, leadSetter, leadSource, projectManagers, projectCrews

  • Dates: scheduledStartDate, scheduledEndDate, dateWon, dateCompleted

  • Other: 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) and parentJobShortId (job number)

  • orgId, customerContact, createdBy, creationDate

  • totalAmount (including tax), taxAmount, totalAmountWithoutTax

  • dueDate, 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, or null

  • paymentRecords: every payment on the invoice

  • costBudgets

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 ID

  • amount: in dollars

  • date: a calendar date (see Data conventions)

  • method: check, cash, credit_card, ach, venmo, zelle, cash_app or other

  • status: completed, pending, failed or refunded

  • note

  • title: 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, purchaseDate

  • The vendor: vendorId, vendorName

  • images (receipt photos) and customFields

  • The 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, details

  • customerContact, assignedTo, salesRep, leadSetter, leadSource

  • assessmentDateTime (the scheduled visit), scheduled, archived

  • tags, customFields

  • creationDate, 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.orgId matches 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.

Did this answer your question?