Implementation examples for ingesting feedback data into BuildBetter
content
, created_at
, identifier
, identity
, metadata
Types: boolean
, date
, datetime
, float
, integer
, json
, string
// Zendesk webhook handler (Node.js/Express)
const express = require('express');
const axios = require('axios');
app.post('/webhooks/zendesk', async (req, res) => {
const ticket = req.body;
try {
// First, ensure the customer exists
await axios.put('https://api.buildbetter.app/v3/rest/people', {
email: ticket.requester.email,
first_name: ticket.requester.first_name,
last_name: ticket.requester.last_name,
company: {
name: ticket.organization?.name,
domain: ticket.requester.email.split('@')[1]
}
}, {
headers: {
'X-BuildBetter-Api-Key': 'YOUR_ORGANIZATION_API_KEY'
}
});
// Then ingest the ticket
await axios.post(
'https://api.buildbetter.app/v3/rest/feedback-sources/123/records',
{
person_id: ticket.requester.id,
display_ts: ticket.created_at,
external_id: `zendesk-${ticket.id}`,
fields: [
{
category: 'content',
type: 'string',
name: 'subject',
value: ticket.subject
},
{
category: 'content',
type: 'string',
name: 'description',
value: ticket.description
},
{
category: 'metadata',
type: 'string',
name: 'priority',
value: ticket.priority
},
{
category: 'metadata',
type: 'string',
name: 'status',
value: ticket.status
},
{
category: 'metadata',
type: 'string',
name: 'tags',
value: ticket.tags.join(', ')
}
]
},
{
headers: {
'X-BuildBetter-Api-Key': 'ORGANIZATION_API_KEY'
}
}
);
res.status(200).send('Ticket ingested successfully');
} catch (error) {
console.error('Failed to ingest ticket:', error);
res.status(500).send('Error processing ticket');
}
});
// Delighted NPS webhook handler
const processDelightedSurvey = async (surveyData) => {
const { person, score, comment, created_at } = surveyData;
// Calculate NPS category
let category;
if (score >= 9) category = 'promoter';
else if (score >= 7) category = 'passive';
else category = 'detractor';
const feedbackPayload = {
display_ts: new Date(created_at * 1000).toISOString(),
external_id: `delighted-${surveyData.id}`,
fields: [
{
category: 'content',
type: 'integer',
name: 'nps_score',
value: score
},
{
category: 'content',
type: 'string',
name: 'feedback',
value: comment || ''
},
{
category: 'metadata',
type: 'string',
name: 'nps_category',
value: category
},
{
category: 'metadata',
type: 'string',
name: 'survey_platform',
value: 'delighted'
},
{
category: 'metadata',
type: 'json',
name: 'person_properties',
value: surveyData.person_properties || {}
}
]
};
// If person has email, ensure they exist first
if (person.email) {
await ensurePersonExists({
email: person.email,
first_name: person.name?.split(' ')[0],
last_name: person.name?.split(' ').slice(1).join(' ')
});
feedbackPayload.person_id = await getPersonId(person.email);
}
return await ingestFeedback(feedbackPayload);
};
// Slack bot that captures messages with specific emoji reactions
const { App } = require('@slack/bolt');
const axios = require('axios');
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
signingSecret: process.env.SLACK_SIGNING_SECRET
});
// Listen for feedback emoji reactions (e.g., :feedback:)
app.event('reaction_added', async ({ event, client }) => {
if (event.reaction !== 'feedback') return;
try {
// Get the message that was reacted to
const message = await client.conversations.history({
channel: event.item.channel,
latest: event.item.ts,
limit: 1,
inclusive: true
});
const messageData = message.messages[0];
// Get user info
const user = await client.users.info({
user: messageData.user
});
// Get channel info
const channel = await client.conversations.info({
channel: event.item.channel
});
// Ingest to BuildBetter
await axios.post(
'https://api.buildbetter.app/v3/rest/feedback-sources/125/records',
{
display_ts: new Date(parseFloat(messageData.ts) * 1000).toISOString(),
external_id: `slack-${event.item.channel}-${event.item.ts}`,
fields: [
{
category: 'content',
type: 'string',
name: 'message',
value: messageData.text
},
{
category: 'metadata',
type: 'string',
name: 'channel',
value: channel.channel.name
},
{
category: 'metadata',
type: 'string',
name: 'user',
value: user.user.real_name
},
{
category: 'metadata',
type: 'string',
name: 'user_email',
value: user.user.profile.email
},
{
category: 'metadata',
type: 'string',
name: 'thread_ts',
value: messageData.thread_ts || messageData.ts
}
]
},
{
headers: {
'X-BuildBetter-Api-Key': process.env.BUILDBETTER_API_KEY
}
}
);
// Add confirmation reaction
await client.reactions.add({
channel: event.item.channel,
timestamp: event.item.ts,
name: 'white_check_mark'
});
} catch (error) {
console.error('Error processing feedback:', error);
}
});
// Segment webhook handler for product events
interface SegmentEvent {
type: 'track' | 'identify' | 'page' | 'screen';
userId?: string;
anonymousId: string;
event?: string;
properties?: Record<string, any>;
traits?: Record<string, any>;
timestamp: string;
}
class SegmentToBuildbetter {
private apiKey: string;
private feedbackSourceId: number;
constructor(apiKey: string, feedbackSourceId: number) {
this.apiKey = apiKey;
this.feedbackSourceId = feedbackSourceId;
}
async processEvent(event: SegmentEvent): Promise<void> {
// Only process specific events that are feedback-related
const feedbackEvents = [
'Feedback Submitted',
'Feature Requested',
'Bug Reported',
'Rating Provided',
'Survey Completed'
];
if (event.type !== 'track' || !feedbackEvents.includes(event.event!)) {
return;
}
const fields = [];
// Add the main event as content
fields.push({
category: 'content',
type: 'string',
name: 'event',
value: event.event
});
// Add relevant properties
if (event.properties?.feedback) {
fields.push({
category: 'content',
type: 'string',
name: 'feedback',
value: event.properties.feedback
});
}
if (event.properties?.rating) {
fields.push({
category: 'content',
type: 'integer',
name: 'rating',
value: event.properties.rating
});
}
// Add metadata
fields.push(
{
category: 'metadata',
type: 'string',
name: 'source',
value: 'segment'
},
{
category: 'metadata',
type: 'string',
name: 'user_id',
value: event.userId || event.anonymousId
}
);
// Add any custom properties as metadata
Object.entries(event.properties || {}).forEach(([key, value]) => {
if (!['feedback', 'rating'].includes(key)) {
fields.push({
category: 'metadata',
type: 'string',
name: key,
value: String(value)
});
}
});
await fetch(
`https://api.buildbetter.app/v3/rest/feedback-sources/${this.feedbackSourceId}/records`,
{
method: 'POST',
headers: {
'X-BuildBetter-Api-Key': this.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
display_ts: event.timestamp,
external_id: `segment-${event.type}-${Date.now()}`,
fields
})
}
);
}
}
// Email parser for feedback (using SendGrid Inbound Parse)
const sgMail = require('@sendgrid/mail');
const multer = require('multer');
const upload = multer();
app.post('/email/feedback', upload.none(), async (req, res) => {
const { from, subject, text, html, envelope } = req.body;
// Parse sender email
const fromEmail = from.match(/<(.+)>/)?.[1] || from;
const fromName = from.match(/^([^<]+)/)?.[1]?.trim() || '';
// Parse email metadata
const envelopeData = JSON.parse(envelope);
// Extract feedback category from subject or email address
let category = 'general';
if (subject.toLowerCase().includes('bug')) category = 'bug';
else if (subject.toLowerCase().includes('feature')) category = 'feature_request';
else if (envelopeData.to.includes('support@')) category = 'support';
// Clean up email text (remove signatures, quotes, etc.)
const cleanText = text
.split(/^>|^On .+ wrote:|^-{2,}|^_{2,}/m)[0]
.trim();
// Ensure person exists
const personData = {
email: fromEmail,
first_name: fromName.split(' ')[0],
last_name: fromName.split(' ').slice(1).join(' ')
};
const personResponse = await axios.put(
'https://api.buildbetter.app/v3/rest/people',
personData,
{
headers: { 'X-BuildBetter-Api-Key': 'ORGANIZATION_API_KEY' }
}
);
// Ingest feedback
const feedbackData = {
person_id: personResponse.data.id,
display_ts: new Date().toISOString(),
external_id: `email-${Date.now()}-${fromEmail}`,
fields: [
{
category: 'content',
type: 'string',
name: 'subject',
value: subject
},
{
category: 'content',
type: 'string',
name: 'message',
value: cleanText
},
{
category: 'metadata',
type: 'string',
name: 'category',
value: category
},
{
category: 'metadata',
type: 'string',
name: 'source',
value: 'email'
},
{
category: 'metadata',
type: 'string',
name: 'to_address',
value: envelopeData.to[0]
}
]
};
await axios.post(
'https://api.buildbetter.app/v3/rest/feedback-sources/126/records',
feedbackData,
{
headers: { 'X-BuildBetter-Api-Key': 'ORGANIZATION_API_KEY' }
}
);
// Send auto-reply
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
await sgMail.send({
to: fromEmail,
from: 'feedback@yourcompany.com',
subject: 'Re: ' + subject,
text: `Thank you for your feedback! We've received your message and will review it shortly.
Your feedback helps us improve our product.
Reference ID: ${feedbackData.external_id}`
});
res.status(200).send('OK');
});
import csv
import requests
from datetime import datetime
import time
class CSVFeedbackImporter:
def __init__(self, api_key, feedback_source_id):
self.api_key = api_key
self.feedback_source_id = feedback_source_id
self.base_url = "https://api.buildbetter.app/v3/rest"
def import_csv(self, csv_file_path, mapping):
"""
Import feedback from CSV file
mapping = {
'email': 'customer_email', # CSV column -> field name
'feedback': 'comment',
'score': 'rating',
'date': 'created_at'
}
"""
with open(csv_file_path, 'r') as file:
reader = csv.DictReader(file)
success_count = 0
error_count = 0
for row_num, row in enumerate(reader, start=2):
try:
# Map CSV columns to our structure
email = row.get(mapping.get('email'))
# Create person if email exists
person_id = None
if email:
person_resp = requests.put(
f"{self.base_url}/people",
headers={'X-BuildBetter-Api-Key': self.api_key},
json={'email': email}
)
if person_resp.ok:
person_id = person_resp.json()['id']
# Build fields from mapping
fields = []
if 'feedback' in mapping and row.get(mapping['feedback']):
fields.append({
'category': 'content',
'type': 'string',
'name': 'feedback',
'value': row[mapping['feedback']]
})
if 'score' in mapping and row.get(mapping['score']):
fields.append({
'category': 'content',
'type': 'integer',
'name': 'score',
'value': int(row[mapping['score']])
})
# Add any additional columns as metadata
for csv_col, field_name in mapping.items():
if csv_col not in ['email', 'feedback', 'score', 'date']:
if row.get(csv_col):
fields.append({
'category': 'metadata',
'type': 'string',
'name': field_name,
'value': row[csv_col]
})
# Parse date
display_ts = datetime.now().isoformat()
if 'date' in mapping and row.get(mapping['date']):
try:
display_ts = datetime.strptime(
row[mapping['date']],
'%Y-%m-%d %H:%M:%S'
).isoformat()
except:
pass
# Create feedback record
feedback_data = {
'display_ts': display_ts,
'external_id': f'csv-import-row-{row_num}',
'fields': fields
}
if person_id:
feedback_data['person_id'] = person_id
response = requests.post(
f"{self.base_url}/feedback-sources/{self.feedback_source_id}/records",
headers={'X-BuildBetter-Api-Key': self.api_key},
json=feedback_data
)
if response.ok:
success_count += 1
print(f"✓ Row {row_num} imported")
else:
error_count += 1
print(f"✗ Row {row_num} failed: {response.text}")
# Rate limiting
time.sleep(0.1)
except Exception as e:
error_count += 1
print(f"✗ Row {row_num} error: {str(e)}")
print(f"\nImport complete: {success_count} successful, {error_count} errors")
# Usage
importer = CSVFeedbackImporter('ORGANIZATION_API_KEY', 123)
importer.import_csv('feedback_export.csv', {
'email': 'Customer Email',
'feedback': 'Comment',
'score': 'NPS Score',
'date': 'Submitted At',
'product': 'Product Area',
'account_type': 'Plan Type'
})
class RobustFeedbackIngester {
private maxRetries = 3;
private retryDelay = 1000; // Start with 1 second
async ingestWithRetry(
feedbackData: any,
attemptNum: number = 1
): Promise<any> {
try {
const response = await fetch(
`https://api.buildbetter.app/v3/rest/feedback-sources/${this.feedbackSourceId}/records`,
{
method: 'POST',
headers: {
'X-BuildBetter-Api-Key': this.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify(feedbackData)
}
);
if (response.status === 409) {
// Duplicate entry - this is okay
console.log(`Duplicate entry: ${feedbackData.external_id}`);
return { status: 'duplicate' };
}
if (!response.ok) {
const error = await response.text();
throw new Error(`HTTP ${response.status}: ${error}`);
}
return await response.json();
} catch (error) {
console.error(`Attempt ${attemptNum} failed:`, error);
if (attemptNum >= this.maxRetries) {
// Log to error queue for manual review
await this.logFailedIngestion(feedbackData, error);
throw error;
}
// Exponential backoff
const delay = this.retryDelay * Math.pow(2, attemptNum - 1);
await new Promise(resolve => setTimeout(resolve, delay));
return this.ingestWithRetry(feedbackData, attemptNum + 1);
}
}
private async logFailedIngestion(data: any, error: any): Promise<void> {
// Log to your error tracking system
console.error('Failed to ingest after retries:', {
data,
error: error.message,
timestamp: new Date().toISOString()
});
// Could also write to a dead letter queue for later processing
}
}
Was this page helpful?