Webhooks use the same per-company switch as the API: they are delivered only while API access is on for your company, its subscription is active and its plan includes the data.
Adding a webhook
In People & access → API, add an https address on a public host name (port 443; no IP address, no local name) and choose its events. An administrator can only choose events whose data they can see themselves. fieldGRID shows a signing secret (whsec_…) once: store it with your receiver. A company can have up to 10 webhooks. “Send test” sends a signed ping; the last deliveries and their results are listed under each webhook for 30 days.
What a delivery looks like
POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: fieldGRID-Webhooks/1
FieldGRID-Event: invoice.issued
FieldGRID-Delivery: 9fK2mQ7xL1
FieldGRID-Signature: t=1760610600,v1=5d41402abc4b2a76b9719d911017c592ae21a6c5d0f0b4f3c1e8a3f3b9d0e7a1
{ "id": "9fK2mQ7xL1", "event": "invoice.issued", "createdAt": "...", "companyId": "...", "data": { ... } }The body is always { id, event, createdAt, companyId, data }. id is the delivery's id (the same as the FieldGRID-Delivery header) and stays the same on every retry. data is the record as the API returns it; for invoices and quotes it is the summary (get the full record with the API when you need its lines).
Events
| Event | When | data | Scope of its data |
|---|---|---|---|
job.created | A job is created. | The job, as GET /api/v1/jobs/{jobId}. | jobs:read |
job.updated | Something the API shows about a job changes (an internal-only change sends nothing). | The job. | jobs:read |
job.completed | A job is completed (sent together with job.updated). | The job. | jobs:read |
form.submitted | A form is submitted. | The submission, as in GET /api/v1/forms/{formId}/submissions. | forms:read |
invoice.issued | An invoice is issued (numbered). Pro formas send nothing. | The invoice summary, as listed by GET /api/v1/invoices (no lines; get them by id). | invoices:read |
credit_note.issued | A credit note is issued. | The credit note summary; creditsInvoiceId is the invoice it corrects. | invoices:read |
quote.accepted | Your team records that the customer accepted a quote. | The quote summary, as listed by GET /api/v1/quotes. | quotes:read |
payment.recorded | A payment is recorded against an invoice. | The payment and what is still due on the invoice. | invoices:read |
invoice.paid | An invoice becomes settled: nothing left to pay, money received, not in credit. Sent each time it becomes settled: by the payment that settles it (with that payment's payment.recorded), or by a credit note that settles the rest; again if a payment is voided and a new payment settles it. Make your receiver idempotent on invoiceId + paidOn. | The invoice: its total, everything paid (totalPaidMinor) and credited, the settling payment (paymentId; when a credit note settled it, the latest payment) and the day it became settled (paidOn). | invoices:read |
ping | You press “Send test” on a webhook. | A test message. | none |
A webhook can subscribe to an event only if the administrator adding it can see that data (the event's scope), and receives it only while your plan includes it.
Example payloads
job.created
{
"id": "9fK2mQ7xL1",
"event": "job.created",
"createdAt": "2026-10-12T14:05:00.000Z",
"companyId": "your-company-id",
"data": {
"id": "Jx3k9Qd2",
"title": "Replace valve, Unit 4",
"statusLabelId": "done",
"completed": true,
"completedAt": "2026-10-12T14:05:00.000Z",
"dates": [
"2026-10-12"
],
"assignedTo": [
"u7Lm2",
"u9Tq4"
],
"teamId": "team-north",
"customerId": "cust-city",
"jobTypeId": "maintenance",
"jobTypeVersion": 3,
"customFields": {
"poNumber": "PO-2291"
},
"notes": "Access via the rear gate.",
"createdAt": "2026-10-10T08:00:00.000Z",
"createdBy": "u1Ab3",
"updatedAt": "2026-10-12T14:05:00.000Z"
}
}job.updated
{
"id": "9fK2mQ7xL1",
"event": "job.updated",
"createdAt": "2026-10-12T14:05:00.000Z",
"companyId": "your-company-id",
"data": {
"id": "Jx3k9Qd2",
"title": "Replace valve, Unit 4",
"statusLabelId": "done",
"completed": true,
"completedAt": "2026-10-12T14:05:00.000Z",
"dates": [
"2026-10-12"
],
"assignedTo": [
"u7Lm2",
"u9Tq4"
],
"teamId": "team-north",
"customerId": "cust-city",
"jobTypeId": "maintenance",
"jobTypeVersion": 3,
"customFields": {
"poNumber": "PO-2291"
},
"notes": "Access via the rear gate.",
"createdAt": "2026-10-10T08:00:00.000Z",
"createdBy": "u1Ab3",
"updatedAt": "2026-10-12T14:05:00.000Z"
}
}job.completed
{
"id": "9fK2mQ7xL1",
"event": "job.completed",
"createdAt": "2026-10-12T14:05:00.000Z",
"companyId": "your-company-id",
"data": {
"id": "Jx3k9Qd2",
"title": "Replace valve, Unit 4",
"statusLabelId": "done",
"completed": true,
"completedAt": "2026-10-12T14:05:00.000Z",
"dates": [
"2026-10-12"
],
"assignedTo": [
"u7Lm2",
"u9Tq4"
],
"teamId": "team-north",
"customerId": "cust-city",
"jobTypeId": "maintenance",
"jobTypeVersion": 3,
"customFields": {
"poNumber": "PO-2291"
},
"notes": "Access via the rear gate.",
"createdAt": "2026-10-10T08:00:00.000Z",
"createdBy": "u1Ab3",
"updatedAt": "2026-10-12T14:05:00.000Z"
}
}form.submitted
{
"id": "9fK2mQ7xL1",
"event": "form.submitted",
"createdAt": "2026-10-12T14:05:00.000Z",
"companyId": "your-company-id",
"data": {
"id": "sub-8Hq2",
"formId": "form-site-check",
"formRevision": 4,
"jobId": "Jx3k9Qd2",
"submittedBy": "u7Lm2",
"submittedAt": "2026-10-12T09:12:00.000Z",
"createdAt": "2026-10-12T09:12:03.000Z",
"answers": {
"f1": true
},
"files": {
"f2": [
{
"name": "site.jpg",
"contentType": "image/jpeg"
}
]
}
}
}invoice.issued
{
"id": "9fK2mQ7xL1",
"event": "invoice.issued",
"createdAt": "2026-10-12T14:05:00.000Z",
"companyId": "your-company-id",
"data": {
"id": "5b0c9e1a-2f4d-4c8e-9a7b-1d2e3f4a5b6c",
"kind": "invoice",
"status": "issued",
"number": "INV-00042",
"customer": {
"id": "cust-city",
"type": "customer",
"name": "City Council"
},
"siteId": "site-depot",
"currency": "EUR",
"issueDate": "2026-10-01",
"supplyDate": "2026-09-30",
"supplyPeriod": null,
"dueDate": "2026-10-31",
"paymentTermsDays": 30,
"buyerReference": "REF-9",
"purchaseOrderReference": "PO-2291",
"creditsInvoiceId": null,
"totals": {
"netMinor": 140000,
"vatMinor": 30300,
"grossMinor": 170300
},
"vatBreakdown": [
{
"category": "S",
"rateBasisPoints": 2300,
"taxableMinor": 120000,
"vatMinor": 27600
},
{
"category": "S",
"rateBasisPoints": 1350,
"taxableMinor": 20000,
"vatMinor": 2700
}
],
"accountingCurrency": null,
"issuedAt": "2026-10-01T09:30:00.000Z",
"createdAt": "2026-09-30T16:00:00.000Z",
"updatedAt": "2026-10-01T09:30:00.000Z",
"links": {
"self": "/api/v1/invoices/5b0c9e1a-2f4d-4c8e-9a7b-1d2e3f4a5b6c",
"pdf": "/api/v1/invoices/5b0c9e1a-2f4d-4c8e-9a7b-1d2e3f4a5b6c/pdf"
}
}
}credit_note.issued
{
"id": "9fK2mQ7xL1",
"event": "credit_note.issued",
"createdAt": "2026-10-12T14:05:00.000Z",
"companyId": "your-company-id",
"data": {
"id": "8e1f2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b",
"kind": "credit_note",
"status": "issued",
"number": "CN-00003",
"customer": {
"id": "cust-city",
"type": "customer",
"name": "City Council"
},
"siteId": "site-depot",
"currency": "EUR",
"issueDate": "2026-10-09",
"supplyDate": "2026-09-30",
"supplyPeriod": null,
"dueDate": "2026-10-09",
"paymentTermsDays": 30,
"buyerReference": "REF-9",
"purchaseOrderReference": "PO-2291",
"creditsInvoiceId": "5b0c9e1a-2f4d-4c8e-9a7b-1d2e3f4a5b6c",
"totals": {
"netMinor": 10000,
"vatMinor": 2300,
"grossMinor": 12300
},
"vatBreakdown": [
{
"category": "S",
"rateBasisPoints": 2300,
"taxableMinor": 10000,
"vatMinor": 2300
}
],
"accountingCurrency": null,
"issuedAt": "2026-10-09T11:00:00.000Z",
"createdAt": "2026-09-30T16:00:00.000Z",
"updatedAt": "2026-10-09T11:00:00.000Z",
"links": {
"self": "/api/v1/invoices/8e1f2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b",
"pdf": "/api/v1/invoices/8e1f2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b/pdf"
}
}
}quote.accepted
{
"id": "9fK2mQ7xL1",
"event": "quote.accepted",
"createdAt": "2026-10-12T14:05:00.000Z",
"companyId": "your-company-id",
"data": {
"id": "2c4e6a8b-0d1f-4a3b-8c5d-7e9f0a1b2c3d",
"quoteGroupId": "2c4e6a8b-0d1f-4a3b-8c5d-7e9f0a1b2c3d",
"number": "QUO-00012",
"version": 2,
"supersedesQuoteId": "1a3c5e7b-9d0f-4b2c-8d4e-6f8a0b1c2d3e",
"status": "accepted",
"title": "Replace pole 14",
"customer": {
"id": "cust-city",
"type": "customer",
"name": "City Council"
},
"siteId": "site-depot",
"currency": "EUR",
"validUntil": "2026-11-15",
"sentDate": "2026-10-15",
"respondedAt": "2026-10-17T10:20:00.000Z",
"buyerReference": "REF-9",
"totals": {
"netMinor": 139500,
"vatMinor": 18833,
"grossMinor": 158333
},
"vatBreakdown": [
{
"category": "S",
"rateBasisPoints": 1350,
"taxableMinor": 139500,
"vatMinor": 18833
}
],
"convertedJobId": null,
"convertedInvoiceId": null,
"createdAt": "2026-10-14T08:00:00.000Z",
"updatedAt": "2026-10-17T10:20:00.000Z",
"links": {
"self": "/api/v1/quotes/2c4e6a8b-0d1f-4a3b-8c5d-7e9f0a1b2c3d"
}
}
}payment.recorded
{
"id": "9fK2mQ7xL1",
"event": "payment.recorded",
"createdAt": "2026-10-12T14:05:00.000Z",
"companyId": "your-company-id",
"data": {
"invoiceId": "5b0c9e1a-2f4d-4c8e-9a7b-1d2e3f4a5b6c",
"invoiceNumber": "INV-00042",
"paymentId": "pay-91c2",
"amountMinor": 50000,
"currency": "EUR",
"paidOn": "2026-10-20",
"method": "bank_transfer",
"amountDueMinor": 120300,
"customer": {
"id": "cust-city",
"type": "customer",
"name": "City Council"
},
"links": {
"invoice": "/api/v1/invoices/5b0c9e1a-2f4d-4c8e-9a7b-1d2e3f4a5b6c"
}
}
}invoice.paid
{
"id": "9fK2mQ7xL1",
"event": "invoice.paid",
"createdAt": "2026-10-12T14:05:00.000Z",
"companyId": "your-company-id",
"data": {
"invoiceId": "5b0c9e1a-2f4d-4c8e-9a7b-1d2e3f4a5b6c",
"invoiceNumber": "INV-00042",
"currency": "EUR",
"grossMinor": 170300,
"totalPaidMinor": 158000,
"creditedMinor": 12300,
"paidOn": "2026-10-20",
"paymentId": "pay-91c2",
"customer": {
"id": "cust-city",
"type": "customer",
"name": "City Council"
},
"links": {
"invoice": "/api/v1/invoices/5b0c9e1a-2f4d-4c8e-9a7b-1d2e3f4a5b6c"
}
}
}ping
{
"id": "9fK2mQ7xL1",
"event": "ping",
"createdAt": "2026-10-12T14:05:00.000Z",
"companyId": "your-company-id",
"data": {
"message": "Test delivery from fieldGRID."
}
}Verifying the signature
Check every delivery before trusting it. The FieldGRID-Signature header is t=<unix seconds>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <t>.<raw body> keyed with your webhook's signing secret. Compute it from the body exactly as received (before parsing the JSON), compare in constant time, and reject a t more than 5 minutes old (it stops a captured delivery being replayed).
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a string), before any JSON parsing.
export function verifyFieldgrid(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(signatureHeader.split(",").map((part) => part.split("=")));
const sentAt = Number(parts.t);
if (!sentAt || Math.abs(Date.now() / 1000 - sentAt) > 300) return false; // older than 5 minutes
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 || "", "hex");
return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, "hex"));
}Python
import hashlib, hmac, time
def verify_fieldgrid(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in signature_header.split(","))
sent_at = int(parts.get("t", "0"))
if abs(time.time() - sent_at) > 300: # older than 5 minutes
return False
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))No-code tools (Zapier, Make, Power Automate) can receive webhooks on their own https address; check the signature in a code step where the tool offers one.
Answers and retries
Answer with any 2xx status within 10 seconds. Anything else, or no answer, is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours; after that the delivery is marked failed. Redirects are not followed, and an address that resolves to a private or local network is never called. The response body is not read.
Duplicates and order
- A delivery can arrive more than once (for example when your answer was lost). Use the delivery
idto ignore one you already processed. - Deliveries are not guaranteed to arrive in the order things happened, especially after a retry. Use the record's own
updatedAt(or ask the API for the record) instead of the arrival order. - An invoice number or a credit note number never changes once issued, so it is a safe key in your system as well.
Best practices
- Answer quickly: store the delivery, answer
200, and do the work afterwards. - Verify the signature and the time on every delivery; keep the signing secret in your secret store and rotate it by adding a new webhook and deleting the old one.
- Treat webhooks as a nudge and the API as the source of truth: on
invoice.issued, readGET /api/v1/invoices/{id}for the lines. - From time to time, catch up with
?updatedSince=in case your endpoint was down longer than the retry schedule.
