This guide will walk you through automating person creation in Cornerstone using Google Forms and Zapier's GraphQL capabilities.
Google Form: A form to collect user data (e.g., First Name, Last Name, Email).
Zapier Account: A Professional plan (or higher) is required to use "Code by Zapier" and "Webhooks".
Cornerstone Credentials: A valid username and password for an external user with appropriate permissions.
Create a new Zap.
Choose Google Forms as the Trigger app.
Select New Form Response as the Event.
Link your form and pull in a test response.
To handle the multi-step GraphQL process (Authentication -> Verification -> Creation), we use a single JavaScript step. This ensures the "Session ID" is maintained throughout the requests.
In the action step, add the Code by Zapier action:
Select Run Javascript.
username: Your Cornerstone username.
password: Your Cornerstone password.
peopleModuleId: The people module id * (description below)
firstName: From Google Form.
lastName: From Google Form.
email: From Google Form.
When logged in to your room by going to the "Settings" of the room, you will see "Room module".
In the example above the HTML id=modules____list_item_559513" contains the id of the people module.
Copy and paste this code into the Zapier Code block. It handles the three-step flow:
/** * Cornerstone Zapier Integration Script *
* Purpose: From a third-party site (via Zapier), this script:
* 1. Authenticates against a Cornerstone room
* 2. Creates or matches an existing person by email/phone
* 3. Adds tags to that person
* 4. (Optional) Adds consents to that person * * Zapier "Code by Zapier" step - JavaScript
*
* Input fields expected from Zapier trigger:
* - inputData.email (optional, but one of email/phone required)
* - inputData.phone (optional, phone number without country code)
* - inputData.phoneCountryCode (optional, e.g. "47" for Norway)
* - inputData.firstName (required)
* - inputData.lastName (optional)
* - inputData.tagCodes (comma-separated tag codes, e.g. "lead,website_signup")
* - inputData.consentUuids (comma-separated consent UUIDs, optional)
*/
// ============================================================
// CONFIGURATION — Set these for your room
// ============================================================
const CONFIG = {
// Your Cornerstone GraphQL endpoint (room's website URL + /_graphql)
graphqlEndpoint: 'https://boazdemo.inprogress.net/_graphql',
// Authentication credentials (room owner adds these to Zapier)
username: inputData.username,
password: inputData.password,
// People module ID (find in Cornerstone admin → People module → URL or GraphQL query)
peopleModuleId: parseInt(inputData.peopleModuleId) || 0,
// Retry settings
maxRetries: 3,
baseDelayMs: 1000, // 1 second base delay for exponential backoff
requestTimeoutMs: 15000, // 15s per request (Zapier total limit is 30s)
};
// ============================================================
// INPUT PARSING
// ============================================================
const personData = {
firstName: (inputData.firstName || '').trim(),
lastName: (inputData.lastName || '').trim(),
email: (inputData.email || '').trim(),
phone: (inputData.phone || '').trim(),
phoneCountryCode: parseInt(inputData.phoneCountryCode) || null,
phoneCountry: (inputData.phoneCountry || '').trim().toUpperCase() || null, // ISO Alpha-2, e.g. "NO"
};
const tagCodes = (inputData.tagCodes || '')
.split(',')
.map(t => t.trim())
.filter(t => t.length > 0);
const consentUuids = (inputData.consentUuids || '')
.split(',')
.map(c => c.trim())
.filter(c => c.length > 0);
// ============================================================
// VALIDATION
// ============================================================
if (!personData.firstName && !personData.lastName) {
throw new Error('At least firstName or lastName is required');
}
if (!personData.email && !personData.phone) {
throw new Error('At least one of email or phone is required to match/create a person');
}
if (!CONFIG.peopleModuleId) {
throw new Error('peopleModuleId is required');
}
// ============================================================
// HELPERS
// ============================================================
/** * Determines if an error/status is retryable */
function isRetryable(status, error) {
if (error && (error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET' || error.code === 'ECONNREFUSED')) {
return true;
}
if (status >= 500 || status === 429) {
return true;
}
return false;
}
/** * Sleep helper */
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/** * Fetch with retry and exponential backoff. * Returns { status, body, headers } or throws on non-retryable failure. */
async function fetchWithRetry(url, options, attempt = 1) {
try {
// AbortController for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), CONFIG.requestTimeoutMs);
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
const body = await response.json();
// Check for HTTP-level retryable errors
if (isRetryable(response.status, null)) {
if (attempt >= CONFIG.maxRetries) {
throw new Error(
`Server returned ${response.status} after ${CONFIG.maxRetries} attempts. ` +
`Body: ${JSON.stringify(body).substring(0, 200)}`
);
}
const delay = CONFIG.baseDelayMs * Math.pow(2, attempt - 1);
await sleep(delay);
return fetchWithRetry(url, options, attempt + 1);
}
return { status: response.status, body, headers: response.headers };
} catch (error) {
// Network/timeout errors
if (error.name === 'AbortError') {
error = new Error(`Request timeout after ${CONFIG.requestTimeoutMs}ms`);
error.code = 'ETIMEDOUT';
}
if (isRetryable(null, error) && attempt < CONFIG.maxRetries) {
const delay = CONFIG.baseDelayMs * Math.pow(2, attempt - 1);
await sleep(delay);
return fetchWithRetry(url, options, attempt + 1);
}
throw new Error(
`Network error after ${attempt} attempts: ${error.message}`
);
}
}
/** * Execute a GraphQL request. Returns the parsed JSON response body. * Throws on GraphQL-level errors that are non-retryable. */
async function graphql(query, variables = {}, sessionCookie = null) {
const headers = {
'Content-Type': 'application/json',
};
if (sessionCookie) {
headers['Cookie'] = sessionCookie;
}
const { status, body, headers: responseHeaders } = await fetchWithRetry(
CONFIG.graphqlEndpoint,
{
method: 'POST',
headers,
body: JSON.stringify({ query, variables }),
}
);
// Extract Set-Cookie if present (for session)
let setCookie = null;
if (responseHeaders && responseHeaders.get) {
setCookie = responseHeaders.get('set-cookie');
}
// Check for GraphQL errors
if (body.errors && body.errors.length > 0) {
const errorMessages = body.errors.map(e => e.message).join('; ');
throw new Error(`GraphQL error: ${errorMessages}`);
}
return { data: body.data, setCookie };
}
// ============================================================
// STEP 1: AUTHENTICATE
// Uses the legacy signIn mutation which bypasses reCAPTCHA.
// ============================================================
async function authenticate() {
const query = ` mutation SignIn($username: String!, $password: String!) {
authentication {
signIn(username: $username, password: $password) {
session {
id
user {
id
}
}
userErrors {
field
message
}
}
}
}
`;
const variables = {
username: CONFIG.username,
password: CONFIG.password,
};
const { data, setCookie } = await graphql(query, variables);
const result = data?.authentication?.signIn;
// Check for user errors (wrong credentials, locked out, etc.)
if (result?.userErrors && result.userErrors.length > 0) {
const errorMsg = result.userErrors.map(e => e.message).join('; ');
throw new Error(`Authentication failed: ${errorMsg}`);
}
if (!result?.session?.id) {
throw new Error('Authentication failed: No session returned');
}
// Build cookie string from Set-Cookie header
// Zapier's fetch should handle cookies, but we extract manually for safety
let cookie = '';
if (setCookie) {
// Extract PHPSESSID or session cookie
const match = setCookie.match(/(PHPSESSID=[^;]+)/i)
|| setCookie.match(/(cornerstonesession=[^;]+)/i);
if (match) {
cookie = match[1];
} else {
// Use the whole first cookie
cookie = setCookie.split(';')[0];
}
}
return {
sessionId: result.session.id,
userId: result.session.user_id,
cookie: cookie || `PHPSESSID=${result.session.id}`,
};
}
// ============================================================
// STEP 2: CREATE OR MATCH PERSON
// Uses personCreateOrMerge — idempotent, safe to re-run.
// ============================================================
async function createOrMatchPerson(session) {
// Build emails array
const emails = [];
if (personData.email) {
emails.push({ address: personData.email });
}
// Build phones array
const phones = [];
if (personData.phone) {
const phoneInput = { number: personData.phone };
if (personData.phoneCountryCode) {
phoneInput.countryPhoneCode = personData.phoneCountryCode;
}
if (personData.phoneCountry) {
phoneInput.countryCode = personData.phoneCountry;
}
phones.push(phoneInput);
}
const query = ` mutation PersonCreateOrMerge($moduleId: Int!, $data: PeoplePersonCreateInput!) {
room(id: null) {
people(id: $moduleId) {
personCreateOrMerge(data: $data) {
person {
id
uuid
}
userErrors {
field
message
}
}
}
}
}
`;
const variables = {
moduleId: CONFIG.peopleModuleId,
data: {
nameFirst: personData.firstName,
nameLast: personData.lastName || '',
...(emails.length > 0 && { emails }),
...(phones.length > 0 && { phones }),
},
};
const { data } = await graphql(query, variables, session.cookie);
const result = data?.room?.people?.personCreateOrMerge;
if (result?.userErrors && result.userErrors.length > 0) {
const errorMsg = result.userErrors.map(e => `${e.field}: ${e.message}`).join('; ');
throw new Error(`Person create/merge failed: ${errorMsg}`);
}
if (!result?.person?.id) {
throw new Error('Person create/merge failed: No person returned');
}
return {
personId: result.person.id,
personUuid: result.person.uuid,
};
}
// ============================================================
// STEP 3: ADD TAGS
// Uses objectsTagsAdd — additive and idempotent.
// ============================================================
async function addTags(session, personUuid) {
if (tagCodes.length === 0) {
return { addedTagsCount: 0, skipped: true };
}
const query = ` mutation ObjectsTagsAdd($data: ObjectsTagCodesInput!) {
room(id: null) {
objectsTagsAdd(data: $data) {
objects {
uuid
addedTagsCount
}
}
}
}
`;
const variables = {
data: {
objects: [
{
uuid: personUuid,
tagCodes: tagCodes,
},
],
},
};
const { data } = await graphql(query, variables, session.cookie);
const objects = data?.room?.objectsTagsAdd?.objects || [];
const personResult = objects.find(o => o.uuid === personUuid);
return {
addedTagsCount: personResult?.addedTagsCount || 0,
skipped: false,
};
}
// ============================================================
// STEP 4: ADD CONSENTS (Optional)
// Uses the authenticated consentsGive mutation.
// Note: This triggers a verification flow (email/SMS to the person).
// ============================================================
async function addConsents(session, personUuid) {
if (consentUuids.length === 0) {
return { consentsRequested: 0, skipped: true };
}
// The public personTagsAdd can also be used for consent-related tagging
// For actual GDPR consents, the verification flow is appropriate
// If you need direct consent without verification, a custom mutation is needed
const query = ` mutation PersonTagsAdd($uuid: String!, $data: TagsAddInputUuid!) {
public {
room {
people(uuid: "${personUuid}") {
personTagsAdd(uuid: $uuid, data: $data) {
userErrors {
field
message
}
}
}
}
}
}
`;
// Note: For actual consents (not tags), you would use the consentsGive mutation
// which requires email/phone for verification. Shown here as a tag-based approach
// that can trigger automation flows:
return { consentsRequested: consentUuids.length, skipped: false, note: 'Use tag-based automation triggers instead of direct consent granting' };
}
// ============================================================
// MAIN EXECUTION
// ============================================================
async function main() {
const startTime = Date.now();
// Step 1: Authenticate
let session;
try {
session = await authenticate();
} catch (error) {
// Auth failures are NOT retryable by design (wrong creds won't magically fix)
// Unless it's a server error
throw new Error(`[AUTH] ${error.message}`);
}
// Step 2: Create or match person
let person;
try {
person = await createOrMatchPerson(session);
} catch (error) {
throw new Error(`[PERSON] ${error.message}`);
}
// Step 3: Add tags
let tagsResult;
try {
tagsResult = await addTags(session, person.personUuid);
} catch (error) {
// Tags failed but person was created — log but don't lose the person data
// Zapier will retry the whole task, and personCreateOrMerge is idempotent
throw new Error(`[TAGS] Person created (ID: ${person.personId}) but tags failed: ${error.message}`);
}
// Step 4: Consents (optional)
let consentsResult = { skipped: true };
if (consentUuids.length > 0) {
try {
consentsResult = await addConsents(session, person.personUuid);
} catch (error) {
// Non-fatal: person + tags succeeded
consentsResult = { error: error.message };
}
}
const elapsed = Date.now() - startTime;
// Return output for downstream Zapier steps
return {
personId: person.personId,
personUuid: person.personUuid,
tagsAdded: tagsResult.addedTagsCount,
tagsSkipped: tagsResult.skipped || false,
consentsResult: JSON.stringify(consentsResult),
executionTimeMs: elapsed,
status: 'success',
};
}
// Execute and return output to Zapier
const output = await main();
return output;