{
  "name": "Sync new Jobber jobs and quotes to Google Sheets with Gmail alerts",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "jobber-events",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "id": "cd5b4746-e15c-5055-9b49-1d1a73f96c12",
      "name": "Receive Jobber webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        -1500,
        0
      ],
      "webhookId": "4dc12f22-364c-5857-87d5-7e6b34f78826"
    },
    {
      "parameters": {
        "action": "hmac",
        "binaryData": true,
        "binaryPropertyName": "data",
        "type": "SHA256",
        "dataPropertyName": "expected_signature",
        "encoding": "base64"
      },
      "id": "765ccdd6-e9e4-5b9b-a549-66d22a00ec7e",
      "name": "Sign raw body (HMAC-SHA256)",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 2,
      "position": [
        -1280,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Jobber signs the raw request body: base64(HMAC-SHA256(body, app client secret)) in X-Jobber-Hmac-SHA256.\n// Compare without exiting early so timing doesn't leak how many characters matched.\nreturn $input.all().map(item => {\n  const got = String((item.json.headers || {})['x-jobber-hmac-sha256'] || '');\n  const want = String(item.json.expected_signature || '');\n  let diff = (want.length > 0 && got.length === want.length) ? 0 : 1;\n  for (let i = 0; i < want.length; i++) diff |= want.charCodeAt(i) ^ (got.charCodeAt(i) || 0);\n  const ev = ((item.json.body || {}).data || {}).webHookEvent || {};\n  return { json: {\n    signature_ok: diff === 0,\n    topic: String(ev.topic || ''),\n    item_id: String(ev.itemId || ''),\n    account_id: String(ev.accountId || ''),\n    occurred_at: String(ev.occurredAt || ev.occuredAt || ''),  // older apps receive the \"occuredAt\" spelling\n  } };\n});\n"
      },
      "id": "b91ca295-ef20-5844-9449-99676dc0b083",
      "name": "Compare with Jobber signature",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1060,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "905afc8b-16c1-51bf-9ba9-4f3290e42bb9",
              "leftValue": "={{ $json.signature_ok }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "d27c84fd-54d8-5b37-9b1b-d3568a1e95f8",
      "name": "Is signature valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -840,
        0
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ received: true }) }}",
        "options": {
          "responseCode": 200
        }
      },
      "id": "6c3dd276-716a-5795-8749-97c7e174e944",
      "name": "Reply 200 to Jobber",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.4,
      "position": [
        -620,
        -100
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ error: 'invalid signature' }) }}",
        "options": {
          "responseCode": 401
        }
      },
      "id": "9b6ad742-2fe6-55b5-9fd4-302b858f7745",
      "name": "Reject forged request (401)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.4,
      "position": [
        -620,
        120
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "22317a3d-72c6-51a1-92a6-1843ac7ca53c",
                    "leftValue": "={{ $json.topic }}",
                    "rightValue": "JOB_CREATE",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "New Job"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "0be63e96-8a2b-5270-9507-04f36e3f01e3",
                    "leftValue": "={{ $json.topic }}",
                    "rightValue": "QUOTE_CREATE",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "New Quote"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "04895506-4b42-55c7-a9fb-fb103dad4b98",
                    "leftValue": "={{ $json.topic }}",
                    "rightValue": "APP_DISCONNECT",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "App Disconnected"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "058aa340-77f7-55fc-95e2-44a13181e9ee",
                    "leftValue": "={{ $('Poll every 15 minutes').isExecuted ? 'SCHEDULED_POLL' : 'webhook' }}",
                    "rightValue": "SCHEDULED_POLL",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Scheduled Poll"
            }
          ]
        },
        "options": {}
      },
      "id": "bdfc62cb-98fd-5388-86a1-afe3e2441672",
      "name": "Route by event topic",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        -180,
        120
      ]
    },
    {
      "parameters": {
        "jsCode": "// The webhook only carries an ID, so ask Jobber for the full record. Field names are from Jobber's GraphQL schema.\n// Picked by topic prefix, so JOB_UPDATE or QUOTE_APPROVED work too if you add them to \"Route by event topic\".\nconst QUERIES = {\n  JOB: \"query JobById($id: EncodedId!) { job(id: $id) { id jobNumber title jobStatus total createdAt startAt jobberWebUri client { id name companyName emails { address primary } phones { number primary } } property { address { street city province postalCode } } } }\",\n  QUOTE: \"query QuoteById($id: EncodedId!) { quote(id: $id) { id quoteNumber title quoteStatus amounts { total } createdAt sentAt jobberWebUri client { id name companyName emails { address primary } phones { number primary } } property { address { street city province postalCode } } } }\",\n};\nconst pick = topic => QUERIES[String(topic).split('_')[0]];\nreturn $input.all()\n  .filter(i => pick(i.json.topic) && i.json.item_id)\n  .map(i => ({ json: { query: pick(i.json.topic), variables: { id: i.json.item_id } } }));\n"
      },
      "id": "24a8e390-f39d-570e-9824-69e0a9e28fa0",
      "name": "Build fetch-by-ID query",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        40,
        -60
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "9cf5c6c9-8f9a-5b87-b1c9-ede3d3f58d9d",
              "name": "owner_email",
              "value": "YOUR_EMAIL@example.com",
              "type": "string"
            },
            {
              "id": "27a75d47-7b73-5d23-865a-8ef000f1f228",
              "name": "sheet_url",
              "value": "PASTE_YOUR_GOOGLE_SHEET_URL_HERE",
              "type": "string"
            },
            {
              "id": "bc7feba1-e102-5f00-93d0-d83a239d2ec1",
              "name": "sheet_tab",
              "value": "Jobber",
              "type": "string"
            },
            {
              "id": "a2da1f67-c88e-5adb-bf1d-dd0f73d15c3b",
              "name": "poll_lookback_minutes",
              "value": 30,
              "type": "number"
            },
            {
              "id": "8809cfcf-39dc-5706-bada-8444daceac03",
              "name": "poll_max_per_type",
              "value": 50,
              "type": "number"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "e98bf52b-c609-5d53-8306-e95ae3d7f6cf",
      "name": "Workflow configuration",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -400,
        120
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "id": "8121a5d8-c19e-5f31-a564-b4fa0ff45f72",
      "name": "Poll every 15 minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -1500,
        440
      ]
    },
    {
      "parameters": {
        "jsCode": "// Look back further than the schedule interval so a slow or skipped run can't leave a gap.\n// Overlap is harmless: rows are upserted and each record is alerted only once.\n// Both values come from \"Workflow configuration\". Jobber cost = first x fields: about 25 x 50 = 1,250 of the 10,000-point bucket.\nconst cfg = $('Workflow configuration').first().json;\nconst LOOKBACK_MINUTES = Number(cfg.poll_lookback_minutes) || 30;\nconst MAX_PER_TYPE = Number(cfg.poll_max_per_type) || 50;\nconst since = new Date(Date.now() - LOOKBACK_MINUTES * 60 * 1000).toISOString();\nreturn [\n  { json: { query: \"query NewJobs($since: ISO8601DateTime!, $first: Int!) { jobs(first: $first, filter: { createdAt: { after: $since } }, sort: [{ key: CREATED_AT, direction: DESCENDING }]) { nodes { id jobNumber title jobStatus total createdAt startAt jobberWebUri client { id name companyName emails { address primary } phones { number primary } } property { address { street city province postalCode } } } pageInfo { hasNextPage } } }\", variables: { since, first: MAX_PER_TYPE } } },\n  { json: { query: \"query NewQuotes($since: ISO8601DateTime!, $first: Int!) { quotes(first: $first, filter: { createdAt: { after: $since } }, sort: [{ key: CREATED_AT, direction: DESCENDING }]) { nodes { id quoteNumber title quoteStatus amounts { total } createdAt sentAt jobberWebUri client { id name companyName emails { address primary } phones { number primary } } property { address { street city province postalCode } } } pageInfo { hasNextPage } } }\", variables: { since, first: MAX_PER_TYPE } } },\n];\n"
      },
      "id": "f99db75a-7a8e-529a-a4b5-5726c0653e24",
      "name": "Build recent-records queries",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        40,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.getjobber.com/api/graphql",
        "authentication": "genericCredentialType",
        "genericAuthType": "oAuth2Api",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-JOBBER-GRAPHQL-VERSION",
              "value": "2026-09-25"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ query: $json.query, variables: $json.variables }) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "388736e0-afc8-51c6-b8f7-3569839a9369",
      "name": "Query Jobber GraphQL API",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        260,
        120
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 3000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// Jobber reports some failures (throttling, bad query, missing scope) as HTTP 200 with an \"errors\" array.\nconst out = [];\nfor (const item of $input.all()) {\n  const r = item.json || {};\n  if (Array.isArray(r.errors) && r.errors.length) {\n    const codes = r.errors.map(e => (e.extensions || {}).code).filter(Boolean);\n    out.push({ json: { api_error: true, error_code: codes[0] || 'GRAPHQL_ERROR',\n      error_message: r.errors.map(e => e.message).join('; ').slice(0, 500) } });\n    continue;\n  }\n  const d = r.data || {};\n  if (d.job) out.push({ json: { api_error: false, record_type: 'job', source: 'webhook', record: d.job } });\n  if (d.quote) out.push({ json: { api_error: false, record_type: 'quote', source: 'webhook', record: d.quote } });\n  for (const n of ((d.jobs || {}).nodes || [])) out.push({ json: { api_error: false, record_type: 'job', source: 'poll', record: n } });\n  for (const n of ((d.quotes || {}).nodes || [])) out.push({ json: { api_error: false, record_type: 'quote', source: 'poll', record: n } });\n  // data.job / data.quote = null means the record was deleted or is outside the app's scopes: nothing to write.\n}\nreturn out;\n"
      },
      "id": "6b7955cf-64ec-573e-b91d-1bec72fca88c",
      "name": "Extract records or API errors",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        480,
        120
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "1b36a932-4950-56ea-9b17-132b3be9550f",
              "leftValue": "={{ $json.api_error }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "aa64c958-5eaa-5a81-864b-767f484f8f36",
      "name": "Did Jobber return an error?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        700,
        120
      ]
    },
    {
      "parameters": {
        "jsCode": "// One flat row per job or quote. record_key (\"job:<id>\" / \"quote:<id>\") is the upsert key.\nconst pick = (list, key) => {\n  const arr = Array.isArray(list) ? list : [];\n  const p = arr.find(x => x && x.primary) || arr[0];\n  return p ? String(p[key] || '') : '';\n};\nreturn $input.all().map(item => {\n  const t = item.json.record_type, r = item.json.record || {};\n  const c = r.client || {}, a = ((r.property || {}).address) || {};\n  const isJob = t === 'job';\n  return { json: {\n    record_key: t + ':' + r.id,\n    record_type: t,\n    jobber_id: r.id,\n    number: String(isJob ? (r.jobNumber ?? '') : (r.quoteNumber ?? '')),\n    title: r.title || '',\n    status: isJob ? (r.jobStatus || '') : (r.quoteStatus || ''),\n    client_name: c.companyName || c.name || '',\n    client_email: pick(c.emails, 'address'),\n    client_phone: pick(c.phones, 'number'),\n    property_address: [a.street, a.city, a.province, a.postalCode].filter(Boolean).join(', '),\n    total: isJob ? (r.total ?? '') : ((r.amounts || {}).total ?? ''),\n    created_at: r.createdAt || '',\n    start_at: isJob ? (r.startAt || '') : (r.sentAt || ''),\n    jobber_url: r.jobberWebUri || '',\n    source: item.json.source,\n    synced_at: new Date().toISOString(),\n  } };\n});\n"
      },
      "id": "c00e8e80-06f8-5d5d-9bb0-b5c56532a3ca",
      "name": "Flatten record into sheet row",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        920,
        0
      ]
    },
    {
      "parameters": {
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "={{ $('Workflow configuration').first().json.sheet_url }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "={{ $('Workflow configuration').first().json.sheet_tab }}"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "record_key": "={{ $json.record_key }}",
            "record_type": "={{ $json.record_type }}",
            "jobber_id": "={{ $json.jobber_id }}",
            "number": "={{ $json.number }}",
            "title": "={{ $json.title }}",
            "status": "={{ $json.status }}",
            "client_name": "={{ $json.client_name }}",
            "client_email": "={{ $json.client_email }}",
            "client_phone": "={{ $json.client_phone }}",
            "property_address": "={{ $json.property_address }}",
            "total": "={{ $json.total }}",
            "created_at": "={{ $json.created_at }}",
            "start_at": "={{ $json.start_at }}",
            "jobber_url": "={{ $json.jobber_url }}",
            "source": "={{ $json.source }}",
            "synced_at": "={{ $json.synced_at }}"
          },
          "matchingColumns": [
            "record_key"
          ],
          "schema": [
            {
              "id": "record_key",
              "displayName": "record_key",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "record_type",
              "displayName": "record_type",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "jobber_id",
              "displayName": "jobber_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "number",
              "displayName": "number",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "title",
              "displayName": "title",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "client_name",
              "displayName": "client_name",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "client_email",
              "displayName": "client_email",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "client_phone",
              "displayName": "client_phone",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "property_address",
              "displayName": "property_address",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "total",
              "displayName": "total",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "created_at",
              "displayName": "created_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "start_at",
              "displayName": "start_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "jobber_url",
              "displayName": "jobber_url",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "source",
              "displayName": "source",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "synced_at",
              "displayName": "synced_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "2ab13b3e-38a3-5489-ae2b-15ed5c3eaa6e",
      "name": "Add or update row in Google Sheets",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1140,
        0
      ]
    },
    {
      "parameters": {
        "operation": "removeItemsSeenInPreviousExecutions",
        "dedupeValue": "={{ $('Flatten record into sheet row').item.json.record_key }}",
        "options": {}
      },
      "id": "f57aba41-4fbc-5330-bc5e-d04883e91c65",
      "name": "Skip records already emailed",
      "type": "n8n-nodes-base.removeDuplicates",
      "typeVersion": 2,
      "position": [
        1360,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "const esc = s => String(s ?? '').replace(/[&<>\"]/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' }[ch]));\nreturn $input.all().map((item, i) => {\n  const r = $('Flatten record into sheet row').itemMatching(i).json;   // the row as written to the sheet\n  const kind = r.record_type === 'job' ? 'job' : 'quote';\n  const money = r.total === '' ? '' : ' · $' + Number(r.total).toFixed(2);\n  const rows = [['Client', r.client_name], ['Email', r.client_email], ['Phone', r.client_phone], ['Address', r.property_address],\n    ['Status', r.status], [kind === 'job' ? 'Starts' : 'Sent', r.start_at], ['Created', r.created_at], ['Found by', r.source]]\n    .filter(([, v]) => v).map(([k, v]) => `<tr><td><b>${k}</b></td><td>${esc(v)}</td></tr>`).join('');\n  return { json: {\n    subject: `New Jobber ${kind} #${r.number}: ${r.title || r.client_name}${money}`,\n    html: `<p>A new ${kind} was added in Jobber and saved to your sheet.</p><table>${rows}</table>` +\n          (r.jobber_url ? `<p><a href=\"${esc(r.jobber_url)}\">Open in Jobber</a></p>` : ''),\n  } };\n});\n"
      },
      "id": "74610284-513e-5916-b6c7-558e233cbfff",
      "name": "Write new-record email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1580,
        0
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $('Workflow configuration').first().json.owner_email }}",
        "subject": "={{ $json.subject }}",
        "emailType": "html",
        "message": "={{ $json.html }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "6a0e3fa7-07c7-5d82-b147-af47068f06a6",
      "name": "Email owner about new record",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1800,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Three sources: the HTTP node's error output (token refresh failed, 401, 5xx), GraphQL \"errors\", and APP_DISCONNECT.\nconst day = new Date().toISOString().slice(0, 10);\nreturn $input.all().map(item => {\n  const j = item.json || {};\n  let kind, detail;\n  if (j.topic === 'APP_DISCONNECT') { kind = 'disconnected'; detail = 'Jobber sent APP_DISCONNECT for account ' + j.account_id + '.'; }\n  else if (j.api_error) { kind = j.error_code === 'THROTTLED' ? 'throttled' : 'api_error'; detail = j.error_code + ': ' + j.error_message; }\n  else {\n    const e = j.error || j;\n    const status = e.httpCode || e.status;\n    const body = typeof e.body === 'string' ? e.body : (e.body ? JSON.stringify(e.body) : '');\n    detail = [status && ('HTTP ' + status), e.message, e.description, body].filter(Boolean).join(' | ').slice(0, 500) || 'Unknown error from the Jobber request.';\n    kind = /401|403|token|unauthori[sz]ed|invalid_grant|client id and secret|reconnect/i.test(detail) ? 'reconnect' : 'http_error';\n  }\n  const fix = {\n    reconnect: 'Open the Jobber OAuth2 credential and click Reconnect. If this keeps happening, update n8n to 2.29.0 or newer (refresh-token fix) and do not use \"Test in GraphiQL\" with the same Jobber admin, because it invalidates the stored refresh token.',\n    disconnected: 'The app was disconnected in Jobber. Reconnect it from Jobber (App Marketplace / Apps) and then reconnect the n8n credential.',\n    throttled: 'Jobber rate-limited the query. Nothing to do unless it repeats; then lower poll_max_per_type in Workflow configuration or poll less often.',\n    api_error: 'Check the query fields, the X-JOBBER-GRAPHQL-VERSION header and the app scopes.',\n    http_error: 'Jobber did not answer normally. The next poll will retry.',\n  }[kind];\n  return { json: { alert_key: kind + ':' + day, subject: 'Jobber sync problem: ' + kind, body: detail + '\\n\\nWhat to do: ' + fix } };\n});\n"
      },
      "id": "848a5864-d5d7-5753-955f-277d1ffdf42a",
      "name": "Explain sync problem",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        920,
        400
      ]
    },
    {
      "parameters": {
        "operation": "removeItemsSeenInPreviousExecutions",
        "dedupeValue": "={{ $json.alert_key }}",
        "options": {}
      },
      "id": "ec2de8be-9b72-53af-9751-98cfb97c0074",
      "name": "Keep one alert per problem per day",
      "type": "n8n-nodes-base.removeDuplicates",
      "typeVersion": 2,
      "position": [
        1140,
        400
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $('Workflow configuration').first().json.owner_email }}",
        "subject": "={{ $json.subject }}",
        "emailType": "text",
        "message": "={{ $json.body }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "eaeaca6c-0ced-5f4b-8924-7562d2c4e134",
      "name": "Email owner about sync problem",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1360,
        400
      ]
    },
    {
      "parameters": {
        "content": "### How it works\nFor home-service and field-service businesses on Jobber: every new job and quote becomes one row in Google Sheets, and the owner gets one email per new record.\n1. **Receive Jobber webhook** gets `JOB_CREATE`, `QUOTE_CREATE` and `APP_DISCONNECT`. The signature is checked on the raw body; forged calls get a 401, valid ones a 200 within Jobber's 1-second limit.\n2. Webhooks only carry an ID, so the full job or quote is fetched from Jobber's GraphQL API.\n3. **Poll every 15 minutes** re-reads the last 30 minutes, so a missed webhook isn't lost. It also works without webhooks.\n4. Rows are upserted on `record_key`, so repeats update the same row, and each record is emailed once. Token, API and disconnect problems send one email per problem type per day.\n\n### Setup\n1. In the Jobber Developer Center, create an app with read access to jobs, quotes and clients. Use n8n's OAuth redirect URL as the callback URL.\n2. Create an **OAuth2 API** credential (Authorization Code, Auth URL `https://api.getjobber.com/api/oauth/authorize`, Token URL `https://api.getjobber.com/api/oauth/token`, Authentication **Body**, empty scope). Select it in **Query Jobber GraphQL API**.\n3. Create a **Crypto** credential with the same Client Secret and select it in **Sign raw body (HMAC-SHA256)**.\n4. Add a **Jobber** tab with this header row: `record_key, record_type, jobber_id, number, title, status, client_name, client_email, client_phone, property_address, total, created_at, start_at, jobber_url, source, synced_at`.\n5. Connect Google Sheets and Gmail, then fill in **Workflow configuration**.\n6. Publish, paste the production webhook URL into the app's webhooks for the three topics, then create a test job.\n\nRequires n8n 2.29.0+ (red note). Only new records are tracked.\n\n### Customization\nAdd `JOB_UPDATE` to **Route by event topic** to keep statuses current; rows update without a new alert. Add fields to the queries in both query-builder nodes.",
        "width": 700,
        "height": 1180,
        "color": 1
      },
      "id": "8d7c8578-80ad-57ec-8e5b-2bc0d8568eae",
      "name": "Main Description",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -2300,
        -400
      ]
    },
    {
      "parameters": {
        "content": "## ⚠️ n8n 2.29.0+ required\nJobber rotates refresh tokens hourly. Older n8n versions lose the token after an hour ([n8n #30345](https://github.com/n8n-io/n8n/issues/30345)).\n\n`X-JOBBER-GRAPHQL-VERSION` is set to `2026-09-25`. Each version is supported for at least 12 months.\n\nUsing Jobber's \"Test in GraphiQL\" with the same admin invalidates the token: reconnect afterwards.",
        "width": 300,
        "height": 480,
        "color": 3
      },
      "id": "6f63a5ca-f904-5f1d-a7ec-0014ddf9c9d1",
      "name": "Warning: n8n version and API header",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        180,
        -220
      ]
    },
    {
      "parameters": {
        "content": "## Verify the webhook\nJobber signs the raw body with your client secret. Valid calls get a 200 at once; forged ones get a 401.",
        "width": 1060,
        "height": 560,
        "color": 7
      },
      "id": "d28474fb-45d4-520d-bade-76ac5f62baa0",
      "name": "Section 1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1560,
        -280
      ]
    },
    {
      "parameters": {
        "content": "## Poll fallback\nEvery 15 minutes, re-reads recent jobs and quotes in case a webhook was missed.",
        "width": 400,
        "height": 300,
        "color": 7
      },
      "id": "51ecf79a-4622-5201-8f98-29dc19d7d549",
      "name": "Section 2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1560,
        320
      ]
    },
    {
      "parameters": {
        "content": "## Settings and routing\nYour email, sheet and poll window live here. Topics without a route are acknowledged and ignored.",
        "width": 460,
        "height": 360,
        "color": 7
      },
      "id": "32110679-9624-5c06-a330-59a65e610429",
      "name": "Section 3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -460,
        -60
      ]
    },
    {
      "parameters": {
        "content": "## Fetch from Jobber\nWebhooks only carry an ID, so one GraphQL node fetches the full job or quote for both paths.",
        "width": 880,
        "height": 800,
        "color": 7
      },
      "id": "3b925410-b402-5c49-8cec-97b2fe65c02d",
      "name": "Section 4",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -340
      ]
    },
    {
      "parameters": {
        "content": "## Save row and alert once\nRows are keyed on `record_key`, so repeats update the same row and never email twice.",
        "width": 1040,
        "height": 360,
        "color": 7
      },
      "id": "bc123990-9d1b-5439-af3c-02f7387eca38",
      "name": "Section 5",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        900,
        -200
      ]
    },
    {
      "parameters": {
        "content": "## Sync problems\nToken, API and disconnect problems send one email per problem type per day.",
        "width": 640,
        "height": 320,
        "color": 7
      },
      "id": "77597e7a-afe1-54b5-a5f1-90f26d5929a1",
      "name": "Section 6",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        900,
        260
      ]
    }
  ],
  "connections": {
    "Receive Jobber webhook": {
      "main": [
        [
          {
            "node": "Sign raw body (HMAC-SHA256)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sign raw body (HMAC-SHA256)": {
      "main": [
        [
          {
            "node": "Compare with Jobber signature",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compare with Jobber signature": {
      "main": [
        [
          {
            "node": "Is signature valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is signature valid?": {
      "main": [
        [
          {
            "node": "Reply 200 to Jobber",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reject forged request (401)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply 200 to Jobber": {
      "main": [
        [
          {
            "node": "Workflow configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Workflow configuration": {
      "main": [
        [
          {
            "node": "Route by event topic",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by event topic": {
      "main": [
        [
          {
            "node": "Build fetch-by-ID query",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build fetch-by-ID query",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Explain sync problem",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Build recent-records queries",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Poll every 15 minutes": {
      "main": [
        [
          {
            "node": "Workflow configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build fetch-by-ID query": {
      "main": [
        [
          {
            "node": "Query Jobber GraphQL API",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build recent-records queries": {
      "main": [
        [
          {
            "node": "Query Jobber GraphQL API",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Query Jobber GraphQL API": {
      "main": [
        [
          {
            "node": "Extract records or API errors",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Explain sync problem",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract records or API errors": {
      "main": [
        [
          {
            "node": "Did Jobber return an error?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Did Jobber return an error?": {
      "main": [
        [
          {
            "node": "Explain sync problem",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Flatten record into sheet row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Flatten record into sheet row": {
      "main": [
        [
          {
            "node": "Add or update row in Google Sheets",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add or update row in Google Sheets": {
      "main": [
        [
          {
            "node": "Skip records already emailed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip records already emailed": {
      "main": [
        [
          {
            "node": "Write new-record email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write new-record email": {
      "main": [
        [
          {
            "node": "Email owner about new record",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Explain sync problem": {
      "main": [
        [
          {
            "node": "Keep one alert per problem per day",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keep one alert per problem per day": {
      "main": [
        [
          {
            "node": "Email owner about sync problem",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "tags": []
}