Developer Handover Pack
Everything needed to build the automation from scratch: current state, full agent specs, data flow, and the build stack.
Developer Handover Pack
Customer Satisfaction Surveying
[YourCompany.com] · Customer Support Department · Prepared by FullSpec · [Today's Date]
This document gives the FullSpec build team everything required to implement, connect, and verify the Customer Satisfaction Surveying automation end to end. It covers the current-state step map with bottlenecks identified, full agent specifications with IO contracts, the complete data flow trace, and the recommended build stack. The owner side has no action items in this document. All build, configuration, and testing work described here is carried out by the FullSpec team.
01Process snapshot
02Agent specifications
This agent watches the HubSpot pipeline for any ticket that transitions to Closed status. On firing, it queries the HubSpot contact record to confirm the customer has not received a survey in the past 30 days. If eligible, it assembles a personalised Gmail message using the customer name, ticket reference, and assigned agent name drawn from the ticket payload, sends it, and writes a survey-sent timestamp and ticket ID back to the HubSpot contact property. If the customer is ineligible, the run exits silently with a log entry. The agent operates without any manual trigger or review step. Estimated build time: 10 hours. Complexity: Moderate.
// Input
hubspot_trigger.ticket_id : string
hubspot_trigger.contact_id : string
hubspot_trigger.pipeline_stage : string // must equal confirmed Closed stage label
hubspot_trigger.owner_name : string // assigned agent name for email personalisation
hubspot_contact.last_survey_sent : date | null
// Decision gate
IF (today - last_survey_sent) < 30 days -> EXIT (log: ineligible, skip)
IF last_survey_sent IS NULL -> PROCEED
// Output
gmail.send(
to: hubspot_contact.email,
subject: 'How did we do? Quick question for you',
body: personalised_template(contact_first_name, ticket_id, owner_name, typeform_url)
)
hubspot.update_contact(
contact_id: hubspot_trigger.contact_id,
properties: {
last_survey_sent: today_iso8601,
last_survey_ticket_id: hubspot_trigger.ticket_id
}
)- HubSpot pipeline stage name: the trigger condition must exactly match the Closed stage label as configured in the specific HubSpot account. Confirm the exact string during discovery before build begins, as it varies by account.
- HubSpot contact property for last_survey_sent must be a date property. If it does not exist, create a custom contact property named hs_custom_last_survey_sent (date type) during setup.
- HubSpot contact property for last_survey_ticket_id must be a single-line text property. Create hs_custom_last_survey_ticket_id if absent.
- Gmail sending account: the OAuth connection must use the shared support Gmail account, not a personal account. Confirm the sending address with the operations lead before build.
- Typeform survey URL must be a single static link embedded in the email template. Confirm the Typeform form ID and embed the pre-filled hidden fields (ticket_id, contact_id) in the URL query string so responses are attributable without manual matching.
- Dedupe behaviour: if two tickets close for the same contact within 30 days, only the first triggers a send. The 30-day window is non-configurable at runtime; any change requires a workflow update.
- Fallback: if the HubSpot contact record has no email address, the agent logs a warning row to the error log table and exits. No retry is attempted.
- Confirm the detractor score threshold with the support manager before the Response Processing Agent build starts (not this agent, but a dependency that must be captured in discovery).
This agent is triggered by an inbound Typeform webhook fired the moment a customer submits the survey. It parses the response payload to extract the CSAT score, written comment, submission timestamp, and the hidden fields carrying ticket_id and contact_id. It writes the score and comment to the corresponding HubSpot contact record, appends a structured row to the Google Sheets satisfaction tracking sheet, and evaluates the score against the agreed detractor threshold. If the score falls below the threshold, it posts a formatted Slack message to the support manager channel naming the customer, score, ticket reference, and a direct link to the HubSpot contact. All four actions run in sequence within a single workflow execution. Estimated build time: 14 hours. Complexity: Moderate.
// Input (Typeform webhook payload)
typeform_response.form_id : string
typeform_response.submitted_at : ISO8601 timestamp
typeform_response.answers[csat_score] : integer (1-5)
typeform_response.answers[comment] : string | null
typeform_response.hidden.ticket_id : string
typeform_response.hidden.contact_id : string
// Output: HubSpot write-back
hubspot.update_contact(
contact_id: typeform_response.hidden.contact_id,
properties: {
last_csat_score: typeform_response.answers[csat_score],
last_csat_comment: typeform_response.answers[comment],
last_csat_date: typeform_response.submitted_at
}
)
// Output: Google Sheets append
sheets.append_row(
sheet_id: SATISFACTION_SHEET_ID,
tab: 'Responses',
values: [submitted_at, contact_id, ticket_id, csat_score, comment, detractor_flag]
)
// On detractor (csat_score < agreed_threshold)
slack.post_message(
channel: '#support-manager-alerts',
text: 'Detractor alert: [contact_name] scored [csat_score]/5 on ticket [ticket_id]. Comment: [comment]. View contact: [hubspot_contact_url]'
)- Typeform plan: webhooks require a paid Typeform plan (Basic or above). Confirm plan tier during discovery. If the account is on the free tier, switch to a polling approach (every 3 minutes) and document the added latency for the support manager.
- Typeform hidden fields: the form must have hidden fields named ticket_id and contact_id pre-configured in the Typeform builder before the webhook is connected. Verify these exist or create them during setup.
- Detractor threshold: agreed threshold is CSAT score under 3 (confirmed Jan 10 per lifecycle notes). This value must be stored as a configurable variable in the orchestration layer, not hardcoded, so the support manager can request a change without a full redeploy.
- Google Sheets sheet ID and tab name: confirm the exact Google Sheets document ID and the tab named Responses with the operations lead. The sheet must have pre-built column headers matching the append order: Submitted At, Contact ID, Ticket ID, CSAT Score, Comment, Detractor Flag.
- Slack channel name: confirm the exact channel name for detractor alerts with Tom Briggs (Support Manager) before build. The channel must exist and the Slack bot must be invited to it before the workflow is activated.
- HubSpot custom contact properties for CSAT storage: last_csat_score (number type), last_csat_comment (multi-line text), last_csat_date (date) must be created in HubSpot if absent.
- Fallback: if the HubSpot contact_id from the hidden field does not match any record, log an error row to the error table and post a low-priority Slack warning to the build alert channel. Do not silently discard the response.
- Duplicate submissions: if the same form response ID is received twice (Typeform retry behaviour), check response ID against a deduplication store before processing. Skip and log if already processed.
03End-to-end data flow
// ───────────���─────────────────────────────────────────────────────
// TRIGGER: HubSpot ticket pipeline stage change
// ─────────────────────────────────────────────────────────────────
HubSpot.webhook(event: 'ticket.propertyChange')
-> filter: pipeline_stage == '[CONFIRMED_CLOSED_STAGE_LABEL]'
-> extract:
ticket_id : hs_ticket_id
contact_id : hs_associated_contact_id
owner_name : hs_ticket_assigned_agent_name
contact_email : hs_contact.email
contact_fname : hs_contact.firstname
last_survey_sent: hs_contact.hs_custom_last_survey_sent
// ─────────────────────────────────────────────────────────────────
// AGENT HANDOFF 1: Survey Dispatch Agent begins
// ─────────────────────────────────────────────────────────────────
SurveyDispatchAgent.run(ticket_id, contact_id, owner_name,
contact_email, contact_fname, last_survey_sent)
// Step 1: Eligibility check
IF (TODAY - last_survey_sent) < 30 days:
-> log_row(error_table, {ticket_id, contact_id, reason: 'ineligible_30d'})
-> EXIT
IF contact_email IS NULL:
-> log_row(error_table, {ticket_id, contact_id, reason: 'no_email'})
-> EXIT
// Step 2: Assemble and send personalised email
typeform_url = BASE_TYPEFORM_URL
+ '?ticket_id=' + ticket_id
+ '&contact_id=' + contact_id
Gmail.send(
from : SUPPORT_GMAIL_ADDRESS,
to : contact_email,
subject : 'How did we do? Quick question for you',
body : render_template(contact_fname, ticket_id, owner_name, typeform_url)
)
-> on_success: gmail_send_status = 'sent'
-> on_failure: log_row(error_table, {ticket_id, reason: 'gmail_send_failed'}); EXIT
// Step 3: Log send event to HubSpot
HubSpot.update_contact(
id: contact_id,
hs_custom_last_survey_sent : TODAY_ISO8601,
hs_custom_last_survey_ticket_id : ticket_id
)
// ────────────────────────────────────────────────────────────���────
// WAIT STATE: customer receives email, opens Typeform, submits
// ─────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────
// TRIGGER: Typeform webhook on new response submission
// ─────────────────────────────────────────────────────────────────
Typeform.webhook(event: 'form_response')
-> parse payload:
typeform_response_id : response.token
submitted_at : response.submitted_at
csat_score : response.answers[field_id='csat'].number
comment : response.answers[field_id='comment'].text | null
ticket_id : response.hidden.ticket_id
contact_id : response.hidden.contact_id
// ──────────────────────────────────────────────────────���──────────
// AGENT HANDOFF 2: Response Processing Agent begins
// ─────────────────────────────────────────────────────────────────
ResponseProcessingAgent.run(typeform_response_id, submitted_at,
csat_score, comment, ticket_id, contact_id)
// Step 1: Deduplicate
IF dedup_store.contains(typeform_response_id):
-> log_row(error_table, {typeform_response_id, reason: 'duplicate_submission'})
-> EXIT
ELSE:
-> dedup_store.add(typeform_response_id)
// Step 2: Verify contact exists in HubSpot
hs_contact = HubSpot.get_contact(contact_id)
IF hs_contact IS NULL:
-> log_row(error_table, {contact_id, reason: 'contact_not_found'})
-> Slack.post(BUILD_ALERT_CHANNEL, 'Warning: unmatched contact_id ' + contact_id)
-> EXIT
// Step 3: Write score to HubSpot contact
HubSpot.update_contact(
id: contact_id,
last_csat_score : csat_score,
last_csat_comment : comment,
last_csat_date : submitted_at
)
// Step 4: Append row to Google Sheets
detractor_flag = (csat_score < DETRACTOR_THRESHOLD) ? 'YES' : 'NO'
GoogleSheets.append_row(
sheet_id : SATISFACTION_SHEET_ID,
tab : 'Responses',
values : [submitted_at, contact_id, ticket_id,
csat_score, comment, detractor_flag]
)
// Step 5: Conditional Slack alert (detractor path only)
IF csat_score < DETRACTOR_THRESHOLD:
hubspot_url = 'https://app.hubspot.com/contacts/' + hs_portal_id + '/contact/' + contact_id
Slack.post_message(
channel : '#support-manager-alerts',
text : ':rotating_light: Detractor | ' + hs_contact.firstname + ' '
+ hs_contact.lastname + ' scored ' + csat_score + '/5'
+ ' on ticket ' + ticket_id
+ ' | Comment: ' + (comment OR 'None')
+ ' | ' + hubspot_url
)
// ─────────────────────────────────────────────────────────────────
// TERMINAL: Support Manager follows up with detractor (human step)
// ─────────────────────────────────────────────────────────────────04Recommended build stack
More documents for this process
Every document generated for Customer Satisfaction Surveying.