Webhooks (JSON)

Send each Google Forms response as signed JSON to your own endpoint, Zapier, Make or n8n, and verify the signature

A Webhook rule POSTs every matching response as JSON to a URL you provide. Use it to feed a CRM, a spreadsheet tool, your own server, or an automation platform such as Zapier, Make or n8n.

Create a webhook rule

  1. Click + New rule and pick Webhook
  2. When: keep Every response, or pick Only after approval / When rejected and set an approver. Add Only if conditions to send only matching responses
  3. To: paste your endpoint URL (HTTPS only). There is no Message step: the rule sends a fixed JSON payload. Show payload under the URL shows it with your form's questions, ready to copy
  4. Click Create rule, then copy the signing secret shown once (see below)

Send test sends a sample payload, signed, to the endpoint and shows the HTTP status it returned. Saved URLs are stored encrypted and shown masked; click Replace to paste a new one.

The payload

Each request is POST, Content-Type: application/json, with this body:

{
  "version": 1,
  "event": "submitted",
  "form": { "id": "1AbC...", "name": "Workshop sign-up" },
  "response": {
    "id": "a3f1...",
    "submittedAt": "2026-01-31T09:30:00.000Z",
    "respondentEmail": "respondent@example.com",
    "answers": [
      { "questionId": "q1", "title": "Your name", "value": "Ada Lovelace" },
      { "questionId": "q3", "title": "Department", "value": "Ops" }
    ]
  },
  "rule": { "id": "rule id", "name": "Zapier: new lead" }
}
  • event is one of submitted, approved, rejected; it also rides in the X-Skyfield-Event header
  • response.respondentEmail is null unless the form collects email addresses
  • answers[].value is always a string; checkbox answers are joined with ,
  • rule.id and rule.name identify the rule that fired; name is null when the rule has no name
  • On approved and rejected, an extra approval: { "decision": "approved", "decidedBy": "Dana" } is included. decidedBy is the name the approver typed on the approve page, else the approver's email, or null

version stays 1 as long as the shape above is stable. New fields may be added; none are removed or renamed without a version bump.

Verifying the signature

Every request carries an X-Skyfield-Signature header: sha256=<hex>, the HMAC-SHA256 of the raw request body keyed with the rule's signing secret. Verify it before trusting a request.

The secret (whsec_...) is shown once, right after the save that gives the rule its first webhook URL. Copy it then; it cannot be displayed again. Replacing the URL later keeps the same secret. If you lose it, open the rule, click New signing secret, and save: the new secret is shown once and the old one stops working immediately.

Compute the HMAC over the body bytes exactly as received. Parsing and re-serialising the JSON first will change whitespace and break the comparison.

Node.js

import { createHmac, timingSafeEqual } from 'node:crypto'

// rawBody: string or Buffer of the request body, before any JSON parsing
function verify(rawBody, header, secret) {
  const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(header ?? '')
  return a.length === b.length && timingSafeEqual(a, b)
}

// Express example: use express.raw() so req.body is the untouched bytes
app.post('/hook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body, req.get('X-Skyfield-Signature'), process.env.SKYFIELD_SECRET))
    return res.status(401).end()
  const payload = JSON.parse(req.body)
  res.status(200).end()
})

Python

import hmac, hashlib

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest("sha256=" + digest, header or "")

# Flask example
@app.post("/hook")
def hook():
    if not verify(request.get_data(), request.headers.get("X-Skyfield-Signature"), SECRET):
        return "", 401
    payload = request.get_json()
    return "", 200

Respond with any 2xx status. The body is ignored.

Zapier, Make, n8n

All three accept a generic JSON webhook:

  • Zapier: trigger Webhooks by Zapier, event Catch Hook (or Catch Raw Hook to verify the signature yourself). Paste the URL Zapier shows into the rule
  • Make: add a Webhooks module, Custom webhook, copy its URL
  • n8n: add a Webhook node, method POST, and use its production URL

Send a test from the rule, then map the response.answers fields in the platform. The sample payload uses placeholder answers so every field is present.

Delivery and limits

  • The endpoint must be reachable on the public internet over HTTPS. Private and local addresses are rejected when you save
  • Redirects are not followed: use the final URL
  • The endpoint has 5 seconds to answer. A timeout or a 5xx response is retried once
  • A 404 or 410 response means the endpoint is gone: the rule is paused and you get one email. Paste a new URL on the rule to resume
  • The History page lists each delivery with the HTTP status your endpoint returned
  • A paused or disabled webhook rule does not block other rules on the form

Good to know

  • A webhook rule honours Send when, so an endpoint can receive an approval decision instead of every submission. See Send When
  • To send only vetted responses, set the rule's Send when to Only after approval and pick an approver. See Approval. The payload then carries approval.decision
  • Change channel… moves a webhook rule to Email or a chat channel; give the new destination there. Switching back to Webhook means pasting the URL again
  • Webhook rules need an active license. The free trial counts. See what needs a license