Skip to content

Guide

Send completed jobs to a Google Sheet with a CalSpark webhook

CalSpark has no Google Sheets integration, and the Zapier app is planned and not available. What it has is a signed webhook for job.completed, which is off until an admin turns on API and Webhooks in Company, Features. This guide compares three ways to turn that webhook into a row, and shows the code for the one that checks the signature.

By CalSpark team. Published . Updated .

Set up the CalSpark side

  1. In Settings, Developers, Webhook endpoints, click Add endpoint.
  2. Enter your HTTPS receiver URL.
  3. Under Events to send, choose "Only selected events" and tick job.completed.
  4. Save, then copy the signing secret. An admin can show it again with Reveal secret on the endpoint page.

Then click Send test event. That sends a customer.updated sample about a made-up customer, so it will not look like a job. It proves the URL and the signature work. A real job.completed arrives when a job is marked completed.

What the event carries

This is trimmed from the example in the [event catalog](/developers#events).

{
  "id": "evt_0000000000000000000000000000000c",
  "type": "job.completed",
  "data": {
    "object": {
      "id": "5081",
      "object": "job",
      "customer_id": "1042",
      "scope_of_work": "Replace water heater",
      "status": "completed",
      "actual_start": "2026-10-08T14:05:00.000Z",
      "actual_end": "2026-10-08T15:50:00.000Z",
      "assigned_user_ids": ["usr_example"]
    },
    "previous_attributes": { "status": "in_progress" }
  }
}

The job has customer_id, not a name or an address. To get the name, call GET /api/v1/customers/{id} with an API key that has the customers:read scope, or leave the id in the sheet and look it up with a formula later. The public API allows 120 requests per minute per key.

Route A: a small function you host

This is the one route that can verify the signature. It needs a public HTTPS URL on port 443 and a Google Cloud service account.

Create a service account, enable the Sheets API, share the sheet with the service account email as an editor, and put the key JSON in an environment variable. Row one of a tab named Completed holds headers, with event id in column A. Reuse the verifier from the signature guide.

const express = require('express');
const { google } = require('googleapis');
const { verifyCalSpark } = require('./verify'); // from the signature guide

const app = express();
const auth = new google.auth.GoogleAuth({
  credentials: JSON.parse(process.env.GOOGLE_SERVICE_ACCOUNT_JSON),
  scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
const sheets = google.sheets({ version: 'v4', auth });
const spreadsheetId = process.env.SHEET_ID;

app.post('/calspark/jobs', express.raw({ type: 'application/json' }), async (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verifyCalSpark(raw, req.get('CalSpark-Signature'), [process.env.CALSPARK_WEBHOOK_SECRET])) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(raw);
  if (event.type !== 'job.completed') return res.sendStatus(200);
  const job = event.data.object;
  try {
    const seen = await sheets.spreadsheets.values.get({ spreadsheetId, range: 'Completed!A:A' });
    if (!(seen.data.values || []).flat().includes(event.id)) {
      await sheets.spreadsheets.values.append({
        spreadsheetId,
        range: 'Completed!A:H',
        valueInputOption: 'RAW',
        insertDataOption: 'INSERT_ROWS',
        requestBody: {
          values: [[event.id, job.id, job.customer_id, job.scope_of_work,
            job.actual_start, job.actual_end, job.assigned_user_ids.join(' '), job.updated]],
        },
      });
    }
    res.sendStatus(200);
  } catch (err) {
    console.error(err);
    res.sendStatus(500); // CalSpark will retry
  }
});

The append call finds the table in the range and adds a row after it. The check on column A is the dedupe. Two deliveries at the same instant could both pass it, which is rare at a small shop. Sheets allows 60 reads and 60 writes per minute per user, and this code makes one of each per event.

async function customerName(id) {
  const r = await fetch('https://www.calspark.app/api/v1/customers/' + id, {
    headers: { Authorization: 'Bearer ' + process.env.CALSPARK_API_KEY },
  });
  if (!r.ok) return 'customer ' + id;
  const c = await r.json();
  return c.company_name || [c.first_name, c.last_name].filter(Boolean).join(' ');
}

Route B: a no-code tool with a webhook catch URL

Many automation tools give you a URL that accepts a POST and can add a spreadsheet row. You paste that URL into Add endpoint and map fields by clicking. There is no code and no server. Plainly: most of these tools cannot verify the CalSpark signature unless they offer a code step, so anyone who learns the URL could post fake jobs. Check the tool's own documentation, and keep the URL private. These tools also treat a 2xx as success, so they work with CalSpark's retry rules.

Route C: Google Apps Script, and why it is a poor fit

Apps Script can publish a web app that runs doPost. Google's web app guide lists the fields on the event object, and request headers are not among them. Without the CalSpark-Signature header you cannot verify anything.

The reply is also a problem. A Google developer write-up says a POST to the /exec URL first gets a 302 Found with a Location header. CalSpark does not follow redirects and counts any 3xx as a failure, so it would retry up to 8 times and could eventually disable the endpoint. I found no Google documentation that lets a script set the HTTP status, so I do not recommend this route.

Columns, a sample row and limits

Suggested columns for the Completed tab
ColumnFieldSample
Aevent idevt_0000000000000000000000000000000c
Bjob id5081
Ccustomer1042, or the name from the lookup
Dscope_of_workReplace water heater
E and Factual_start, actual_end2026-10-08T14:05:00.000Z, 2026-10-08T15:50:00.000Z

Deliveries are not guaranteed to be instant, and a failed one is retried on a schedule that stretches to 24 hours between attempts. Retries can deliver the same event twice, which is why column A matters. If your receiver is down for seven days straight, CalSpark disables the endpoint.

Sources

Questions

Is there a CalSpark Google Sheets integration?

No. There is no Google Sheets integration and no public Zapier app. Webhooks and the public API are what exist, and this guide uses them.

Can I get the customer name in the row?

Yes, with one extra call per event. The event carries customer_id only. Call GET /api/v1/customers/{id} with a key that has customers:read, or leave the id and look it up in the sheet.

Will I get the same job twice?

Possibly. Retries can send an event again with the same event id. Check column A before appending, as in the code above.

Does the test button send a job?

No. Send test event sends a customer.updated sample with a made-up customer. Complete a real job to see job.completed.

Can I use a spreadsheet instead of building this?

For one-time data, import is simpler. See [importing customers from a spreadsheet](/guides/import-customers-from-spreadsheet). An admin can also download customers, jobs and invoices as CSV from Settings, Data (see [exporting your data](/guides/export-your-data-csv-columns)).

Try it with your own jobs

14-day free trial. No card. $49 a month flat for up to 10 users after that.