appscript.dev
Automation Intermediate Forms

Push form data to a CRM or external API

Send Northwind form submissions downstream to HubSpot, Notion, or a custom endpoint.

Published Aug 2, 2025

Northwind’s contact form sits on the marketing site, but the team that actually works the leads lives inside the CRM. Copy-pasting each submission across is the sort of job that gets skipped on a busy week — and a missed lead is a missed sale. The form needs to talk to the CRM the moment someone presses submit.

This script wires a Google Form straight to a downstream API. When a response lands, it pulls the fields out of the event object, shapes them into a JSON payload, and posts it to the CRM with a bearer token. Swap the endpoint for HubSpot, Notion, Pipedrive, or any internal service — the contract is the same.

What you’ll need

  • A Google Form with at least Email and Name questions, and optionally a Company question. The script reads them by name from the submit event.
  • A CRM (or any HTTP endpoint) that accepts a JSON POST and returns a 2xx on success. The example uses a bearer-token-authenticated endpoint.
  • An API key for that endpoint saved as CRM_KEY in Script Properties — see Store API keys and secrets securely.
  • A form-submit trigger pointing at onFormSubmit (set up below).

The script

// The downstream endpoint that accepts the lead. Change this when you swap CRMs.
const CRM_ENDPOINT = 'https://api.crm.example/leads';

// A tag the CRM can use to attribute leads back to this form.
const LEAD_SOURCE = 'northwind-contact-form';

/**
 * Form-submit handler. Shapes the response into a CRM lead and posts it.
 * Bound to the form's on-submit trigger.
 *
 * @param {GoogleAppsScript.Events.FormsOnFormSubmit} e
 */
function onFormSubmit(e) {
  // 1. Pull the answers out of namedValues. Each value is an array of strings
  //    because Forms allows multi-select; for single-answer questions we want
  //    the first entry.
  const email = e.namedValues.Email?.[0];
  const name = e.namedValues.Name?.[0];
  const company = e.namedValues.Company?.[0];

  // 2. Guard against the rare case where the form is submitted with no email —
  //    a CRM lead without one is useless.
  if (!email) {
    Logger.log('Skipping submission with no email.');
    return;
  }

  // 3. Build the lead payload. Keep keys flat — most CRMs map them straight
  //    onto contact properties.
  const payload = {
    email,
    name,
    company,
    source: LEAD_SOURCE,
    submittedAt: new Date().toISOString(),
  };

  // 4. Read the API key from Script Properties. Never paste it into the code.
  const key = PropertiesService.getScriptProperties().getProperty('CRM_KEY');
  if (!key) {
    Logger.log('CRM_KEY is not set — skipping push.');
    return;
  }

  // 5. Post to the CRM. muteHttpExceptions lets us log a useful message
  //    instead of crashing the trigger on a 4xx.
  const res = UrlFetchApp.fetch(CRM_ENDPOINT, {
    method: 'post',
    contentType: 'application/json',
    headers: { Authorization: `Bearer ${key}` },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });

  const code = res.getResponseCode();
  if (code >= 200 && code < 300) {
    Logger.log(`Pushed ${email} to CRM (${code}).`);
  } else {
    Logger.log(`CRM push failed for ${email} — ${code}: ${res.getContentText()}`);
  }
}

How it works

  1. The form-submit trigger fires onFormSubmit with an event object whose namedValues map holds each answer keyed by question title.
  2. The script reads Email, Name, and the optional Company, taking the first entry from each array because Forms always returns answers as arrays.
  3. If the email is missing it bails out — a CRM record with no email cannot be matched against future activity, so it is not worth creating.
  4. It builds a flat JSON payload with the submission timestamp and a fixed source tag so the CRM can filter leads from this form.
  5. The API key is read from Script Properties at call time — it is never in the source. If it is unset the script logs and returns rather than sending an unauthenticated request that would expose the payload anyway.
  6. UrlFetchApp.fetch posts the JSON with a bearer header. muteHttpExceptions keeps the trigger alive on non-2xx responses; the script logs the status code and body so failures show up in the executions list.

Example run

Suppose someone fills the form with:

QuestionAnswer
NamePriya Shah
Email[email protected]
CompanyLumen Studio

The CRM receives:

{
  "email": "[email protected]",
  "name": "Priya Shah",
  "company": "Lumen Studio",
  "source": "northwind-contact-form",
  "submittedAt": "2025-08-02T09:14:22.000Z"
}

And the execution log shows Pushed [email protected] to CRM (201).

Trigger it

This runs on form submission, so it needs an installable trigger — the simple onFormSubmit name alone is not enough when you need authenticated services like UrlFetchApp.

  1. In the Apps Script editor, open Triggers (clock icon, left sidebar).
  2. Click Add trigger and choose onFormSubmit.
  3. Event source: From form. Event type: On form submit.
  4. Save and approve the authorisation prompt.

Watch out for

  • Question titles are the contract. If someone renames Email to Email address in the form, e.namedValues.Email becomes undefined and the script silently skips every submission. Either freeze the titles or read by position with e.values.
  • Downstream outages will lose leads. muteHttpExceptions keeps the trigger green, but a logged failure is not a retry. For anything business-critical, also append the row to a backup sheet so you can replay failed pushes later.
  • Bearer tokens leak through logs. Never log the Authorization header, and never log the full request options object — only the response code and body.
  • Apps Script triggers run as the form owner, which means every push uses that one CRM key. Rotate it when the owner changes, and revoke the old one in the CRM admin console.

Related