Small BiCommerce shops receive a steady stream of contact-form inquiries—sales questions, support requests, returns—but the owner or a single staff member can’t manually triage every message in real time. Late replies cost sales and frustrate customers.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
Small BigCommerce shops receive a steady stream of contact-form inquiries — sales questions, support requests, and return issues. A single staff member can’t triage every message in real time, and late replies cost sales. This tutorial builds a Next.js API that receives BigCommerce webhook events, classifies each inquiry’s intent with @reaatech/confidence-router, routes it to the right specialist handler, generates an AI auto-reply using OpenAI’s Responses API, sends email confirmation via SendGrid, notifies your team on Slack, creates HubSpot support tickets, and preserves conversation context with @reaatech/session-continuity — all traced end-to-end with Langfuse.
Prerequisites
Node.js 22+ and pnpm 10+ installed
A BigCommerce store with API credentials (store hash, access token, webhook secret)
An OpenAI API key with access to the Responses API
A SendGrid account with a verified sender email
A Slack workspace with a bot token and channel ID
A HubSpot account with a private app access token
A Langfuse account (or self-hosted instance) for observability
Familiarity with TypeScript and Next.js App Router conventions
Step 1: Scaffold the project and define types
The project uses Next.js 16+ with the App Router. Start by reviewing the scaffolded files — package.json, tsconfig.json, next.config.ts, and vitest.config.ts are already configured for you. All dependencies are exact-pinned.
First, define the shared TypeScript types and Zod schemas that every module in the pipeline depends on.
This file defines the core data shapes (WebhookPayload, ClassifiedInquiry, LeadProfile), Zod schemas for runtime validation (webhookPayloadSchema, bigCommerceEventSchema), and custom error classes (RepairError, BigCommerceApiError) that the pipeline uses to communicate failures.
Expected output: The file compiles cleanly. Run pnpm typecheck — you should see zero errors.
Step 2: Build the payload repair module
BigCommerce webhook payloads can arrive malformed — wrapped in markdown fences, truncated, or with hallucinated fields. The @reaatech/structured-repair-core package handles all of these cases through a graduated repair pipeline.
ts
// src/lib/repair.tsimport { repair, UnrepairableError } from "@reaatech/structured-repair-core";import { webhookPayloadSchema, RepairError, type WebhookPayload } from "../types.js";export async function normalizePayload(rawInput: string): Promise<WebhookPayload> { if (!rawInput || rawInput.trim().length === 0) { throw new RepairError("empty input", { code: "EMPTY_INPUT", originalInput: rawInput }); } try { const parsed: unknown = JSON.parse(rawInput); return webhookPayloadSchema.parse(parsed); } catch { try { const result = await repair(webhookPayloadSchema, rawInput); return webhookPayloadSchema.parse(result); } catch (err) { if (err instanceof UnrepairableError) { throw new RepairError("unrepairable input", { cause: err, originalInput: rawInput, }); } throw err; } }}
The function takes a three-tier approach: it first tries direct JSON parse + Zod validation for clean inputs. If that fails, it delegates to repair() which runs a six-strategy pipeline (strip fences, extract JSON, fix syntax, coerce types, fuzzy-match keys, remove extra fields). If even repair() can’t fix it, it throws a RepairError with diagnostic context.
Expected output: A valid JSON string like {"email":"a@b.com","subject":"Hi","body":"Hello"} produces { email: "a@b.com", subject: "Hi", body: "Hello" }. An empty string throws RepairError with code EMPTY_INPUT.
Step 3: Classify inquiry intent with confidence-router
The @reaatech/confidence-router package turns raw text into structured intent predictions. You configure a ConfidenceRouter with keyword-based classifiers tuned for eCommerce inquiries.
The module creates a singleton router with three keyword classifiers. Each classifier matches common eCommerce words — sales keywords like “buy” and “price”, support keywords like “broken” and “issue”, and returns keywords like “refund” and “exchange”. The router returns predictions with confidence scores; inputs under 3 characters or over 10,000 characters are handled as edge cases.
Expected output: Calling classifyInquiry("I want to buy your products") returns { intent: "sales", confidence: 0.92, ... }. Empty strings return "unclear" with confidence 0 without hitting the router.
Step 4: Create the BigCommerce API client
The BigCommerce REST API client handles webhook lifecycle management (listing and creating webhooks) and HMAC signature verification for incoming webhook payloads.
The verifyWebhookSignature method uses node:crypto’s HMAC-SHA256 and constant-time comparison to prevent timing attacks. The createWebhook method takes a scope string and destination URL — later you’ll call it with the store/appearance/contact/form/submitted scope, which fires whenever a customer submits the storefront contact form.
Expected output: The class compiles. verifyWebhookSignature("body", computedHex, "secret") returns true for a valid HMAC; an incorrect signature returns false.
Step 5: Build the OpenAI client for auto-replies
When a sales inquiry comes in, the app sends an AI-generated reply. This module uses the OpenAI Responses API (the preferred approach in openai@6.x) with a retry mechanism.
ts
// src/lib/openai-client.tsimport OpenAI from "openai";const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY as string,});export async function generateAutoReply( inquiryText: string,): Promise<string> { const trimmed = inquiryText.trim(); if (trimmed.length === 0) { return "We received your inquiry and will get back to you shortly."; } for (let attempt = 0; attempt < 2; attempt++) { try { const response = await client.responses.create({ model: "gpt-5.2", instructions: "You are a helpful eCommerce customer service assistant. Reply to the customer inquiry with a friendly, personalized response. For sales inquiries, suggest relevant products. Keep replies under 500 tokens.", input: trimmed, max_output_tokens: 500, }); return response.output_text; } catch { if (attempt === 0) { await new Promise((r) => setTimeout(r, 1000)); } } } return "We received your inquiry and will get back to you shortly.";}
The client makes up to two attempts with a 1-second backoff. If both fail, it returns a safe fallback message. Empty inputs skip the API call entirely and return the fallback immediately.
Expected output: With a valid OPENAI_API_KEY, generateAutoReply("What is the price of your blue widget?") returns a friendly reply with product suggestions. With an empty string, it returns the fallback without calling the API.
Step 6: Implement session management
The @reaatech/session-continuity package preserves conversation context across multiple inquiries from the same lead. This means a follow-up email from the same customer carries the history of their previous interactions.
ts
// src/lib/session.tsimport { SessionManager } from "@reaatech/session-continuity";import type { Session, Message } from "@reaatech/session-continuity";import { MemoryAdapter } from "@reaatech/session-continuity-storage-memory";import { TiktokenTokenizer } from "@reaatech/session-continuity-tokenizers";import { withRetry } from "@reaatech/agent-handoff";const manager = new SessionManager({ storage: new MemoryAdapter(), tokenCounter: new TiktokenTokenizer("gpt-4"), tokenBudget: { maxTokens: 4096, reserveTokens: 500, overflowStrategy: "compress", }, compression: { strategy: "sliding_window", targetTokens: 3500, },});export async function getOrCreateSession(leadEmail: string): Promise<Session> { const sessions = await manager.listSessions({ userId: leadEmail }); if (sessions.length > 0) { return sessions[0]; } return manager.createSession({ userId: leadEmail });}export async function addInquiryToSession(sessionId: string, inquiryText: string): Promise<void> { await manager.addMessage(sessionId, { role: "user", content: inquiryText });}export async function getSessionContext(sessionId: string): Promise<Message[]> { return manager.getConversationContext(sessionId);}export async function endLeadSession(sessionId: string): Promise<void> { await withRetry(() => manager.endSession(sessionId), { maxRetries: 2, backoff: "exponential", baseDelayMs: 200, maxDelayMs: 2000, shouldRetry: (err) => err instanceof Error && err.name === "StorageError", });}
The SessionManager is configured with an in-memory storage adapter and a GPT-4 tokenizer. The token budget reserves 500 tokens for the response and uses a sliding-window compression strategy when it exceeds 3,500 tokens. getOrCreateSession checks for an existing session by the lead’s email before creating a new one, so returning customers always pick up where they left off.
Expected output: First call to getOrCreateSession("alice@example.com") creates a new session and returns it. A second call with the same email returns the existing session.
Step 7: Build the route handoff system
The @reaatech/agent-handoff-routing package determines which specialist should handle each classified inquiry. You register agents with their capabilities and build a router that maps inquiries to the best match.
ts
// src/lib/route-handoff.tsimport { CapabilityBasedRouter, AgentRegistry } from "@reaatech/agent-handoff-routing";import { RoutingError } from "@reaatech/agent-handoff";import type { HandoffPayload } from "@reaatech/agent-handoff";import type { ClassifiedInquiry, HandoffResult } from "../types.js";const registry = new AgentRegistry();registry.register({ agentId: "sales-specialist", agentName: "Sales Specialist", skills: ["product-recommendation", "pricing", "catalog"], domains: ["ecommerce", "sales"], maxConcurrentSessions: 10
The AgentRegistry holds three agents with distinct capabilities. The CapabilityBasedRouter evaluates the inquiry against all agents and returns a routing decision. A "primary" decision maps directly to the matching specialist; "clarification" or "fallback" decisions route to "human_triage" for manual review.
Expected output: A sales-classified inquiry routes to { target: "sales", agentId: "sales-specialist" }. A low-confidence inquiry routes to { target: "human_triage" }.
Step 8: Wire up Slack alerts and SendGrid email
These helper modules send notifications and email confirmations. Both are designed to fail silently — missing credentials log a warning and skip without throwing.
// src/lib/sendgrid.tsimport sgMail from "@sendgrid/mail";let apiKeySet = false;function ensureApiKey(): void { if (!apiKeySet) { const apiKey = process.env.SENDGRID_API_KEY ?? ""; if (apiKey) { sgMail.setApiKey(apiKey); } apiKeySet = true; }}export async function sendEmail( to: string, subject: string, bodyHtml: string,): Promise<void> { ensureApiKey(); if (!process.env.SENDGRID_API_KEY) { console.warn("SENDGRID_API_KEY missing — skipping email"); return; } try { await sgMail.send({ to, from: process.env.SENDGRID_FROM_EMAIL as string, subject, html: bodyHtml, }); } catch { console.warn("SendGrid email failed"); }}
Both modules follow the same resilient pattern: check for credentials before acting, catch errors without crashing, and log warnings for debugging. This keeps the main pipeline from failing when an optional integration is unavailable.
Expected output: With SLACK_TOKEN and SLACK_CHANNEL set, sendSalesAlert("a@b.com", "Alice", "reply text") posts a message to the configured channel. Without SLACK_TOKEN, it logs a warning and returns silently.
Step 9: Set up Langfuse observability
The observability module wraps every inquiry in a Langfuse trace, giving you a full end-to-end view of the pipeline.
ts
// src/lib/observability.tsimport Langfuse from "langfuse";const langfuse = new Langfuse({ secretKey: process.env.LANGFUSE_SECRET_KEY as string, publicKey: process.env.LANGFUSE_PUBLIC_KEY as string, baseUrl: process.env.LANGFUSE_BASE_URL as string,});export function createInquiryTrace( leadEmail: string,): { update: (opts: Record<string, unknown>) => void } { return langfuse.trace({ name: "lead-intake", userId: leadEmail });}export function endTrace( trace: { update: (opts: Record<string, unknown>) => void }, output: Record<string, unknown>,): void { trace.update({ output });}
createInquiryTrace opens a new trace named "lead-intake" tagged with the lead’s email. After the handler completes, endTrace attaches the output (target handler, session ID, any error) so you can inspect every inquiry’s journey through the pipeline in the Langfuse dashboard.
Expected output:createInquiryTrace("alice@example.com") returns a trace object. endTrace(trace, { handled: true, target: "sales" }) updates the trace with the output payload.
Step 10: Build the specialist handlers
Each handler implements the action for its target. The sales handler generates an AI reply and sends email + Slack alert. The support handler creates a HubSpot ticket. The returns handler asks for order details.
ts
// src/handlers/sales.tsimport type { LeadProfile, ClassifiedInquiry } from "../types.js";import { sendEmail } from "../lib/sendgrid.js";import { sendSalesAlert } from "../lib/slack.js";import { generateAutoReply } from "../lib/openai-client.js";import { getSessionContext } from "../lib/session.js";import { createInquiryTrace, endTrace } from "../lib/observability.js";export async function handleSalesInquiry(lead: LeadProfile, inquiry: ClassifiedInquiry): Promise<void> { const trace = createInquiryTrace(lead.email); try { if (lead.sessionId) { await getSessionContext(lead.sessionId); } const reply = await generateAutoReply(inquiry.originalText); if (lead.email) { await sendEmail( lead.email, "Thank you for your interest!", `<p>Hi${lead.name ? ` ${lead.name}` : ""},</p><p>${reply}</p>`, ); } await sendSalesAlert(lead.email, lead.name, reply); endTrace(trace, { handled: true, type: "sales", reply }); } catch (err) { const fallbackHtml = `<p>Hi${lead.name ? ` ${lead.name}` : ""},</p><p>We received your inquiry and will get back to you shortly.</p>`; if (lead.email) { await sendEmail( lead.email, "We received your inquiry", fallbackHtml, ); } await sendSalesAlert(lead.email, lead.name, "Fallback reply sent due to error"); endTrace(trace, { handled: true, type: "sales", error: err instanceof Error ? err.message : String(err) }); }}
ts
// src/handlers/support.tsimport { Client } from "@hubspot/api-client";import type { LeadProfile, ClassifiedInquiry } from "../types.js";import { sendEmail } from "../lib/sendgrid.js";export async function handleSupportInquiry(lead: LeadProfile, inquiry: ClassifiedInquiry): Promise<void> { const hubspotClient = new Client({ accessToken: process.env.HUBSPOT_ACCESS_TOKEN ?? "" }); try { const rawSearch = await hubspotClient.apiRequest({ method: "POST", path: "/crm/v3/objects/contacts/search", body: { filterGroups: [{ filters: [{ propertyName: "email", operator: "EQ", value: lead.email }] }], limit: 1, }, }); const searchResponse = rawSearch as { json: () => Promise<{ results: Array<{ id: string }> }> }; const searchResult = await searchResponse.json(); if (searchResult.results.length === 0) { await hubspotClient.apiRequest({ method: "POST", path: "/crm/v3/objects/contacts", body: { properties: { email: lead.email, firstname: lead.name ?? "", lastname: "" } }, }); } await hubspotClient.apiRequest({ method: "POST", path: "/crm/v3/objects/tickets", body: { properties: { subject: `Support inquiry from ${lead.email}`, content: inquiry.originalText, hs_pipeline_stage: "1" }, }, }); await sendEmail( lead.email, "We received your support request", `<p>Hi${lead.name ? ` ${lead.name}` : ""},</p><p>We have received your support inquiry and will get back to you shortly.</p>`, ); } catch (err) { console.warn("HubSpot support handler error:", err instanceof Error ? err.message : String(err)); await sendEmail( lead.email, "We received your support request", `<p>Hi${lead.name ? ` ${lead.name}` : ""},</p><p>We have received your support inquiry and will get back to you shortly.</p>`, ); }}
ts
// src/handlers/returns.tsimport type { LeadProfile, ClassifiedInquiry } from "../types.js";import { sendEmail } from "../lib/sendgrid.js";import { sendSalesAlert } from "../lib/slack.js";export async function handleReturnsInquiry(lead: LeadProfile, inquiry: ClassifiedInquiry): Promise<void> { try { const returnEmailHtml = `<p>Hi${lead.name ? ` ${lead.name}` : ""},</p><p>We received your return/exchange request. To process it, please reply with:</p><ul> <li>Order number</li> <li>Product details (name, SKU if available)</li> <li>Reason for return/exchange</li></ul><p>Our team will review and get back to you within 1-2 business days.</p>`; await sendEmail( lead.email, "Return/Exchange Request Received", returnEmailHtml, ); await sendSalesAlert( lead.email, lead.name, `Return/exchange inquiry: ${inquiry.originalText.slice(0, 200)}`, ); } catch (err) { console.warn("Returns handler error (non-fatal):", err instanceof Error ? err.message : String(err)); }}
The sales handler is the most complex: it retrieves session context, generates an AI reply, sends an email with the reply, posts a Slack alert, and traces everything. If the AI generation fails, it sends a fallback email. The support handler looks up or creates a HubSpot contact, creates a support ticket, and sends an acknowledgment email. The returns handler sends instructions for providing order details and notifies the team via Slack.
Expected output: All three handlers compile. handleSalesInquiry with a valid lead sends an email and a Slack message. handleSupportInquiry creates a HubSpot ticket. handleReturnsInquiry sends a return-request email.
Step 11: Create the webhook route and health check
The webhook route is the entry point for BigCommerce events. It orchestrates the entire pipeline: validate signature, repair payload, classify intent, manage session, route to handler, and trace everything.
ts
// app/api/webhooks/bigcommerce/route.tsimport { NextRequest, NextResponse } from "next/server";import { normalizePayload } from "../../../../src/lib/repair.js";import { classifyInquiry } from "../../../../src/lib/classify.js";import { routeInquiry } from "../../../../src/lib/route-handoff.js";import { getOrCreateSession, addInquiryToSession } from "../../../../src/lib/session.js";import { createInquiryTrace, endTrace } from "../../../../src/lib/observability.js";import { BigCommerceClient } from "../../../../src/lib/bigcommerce-client.js";import { handleSalesInquiry } from "../../../../src/handlers/sales.js";import { handleSupportInquiry } from "../../../../src/handlers/support.js";import { handleReturnsInquiry } from "../../../../src/handlers/returns.js"
The POST handler reads the raw body as text (required for HMAC verification), checks the signature, detects BigCommerce test events (payloads with no contact data), normalizes malformed payloads, classifies the intent, creates or resumes a session, routes to the right handler, and returns a structured response. The GET handler is a simple health check.
ts
// app/api/health/route.tsimport { NextResponse } from "next/server";export function GET(): NextResponse { return NextResponse.json({ status: "ok", timestamp: new Date().toISOString() });}
Expected output: A POST to /api/webhooks/bigcommerce with a valid payload returns 200 { handled: true, target: "sales", sessionId: "..." }. An invalid signature returns 401 { error: "invalid_signature" }. A GET to /api/health returns 200 { status: "ok", timestamp: "..." }.
Step 12: Register BigCommerce webhooks and export the barrel
The registration script creates the webhook subscription on your BigCommerce store. Run it once during setup.
ts
// src/scripts/register-webhooks.tsimport { BigCommerceClient } from "../lib/bigcommerce-client.js";async function main(): Promise<void> { const storeHash = process.env.BIGCOMMERCE_STORE_HASH; const accessToken = process.env.BIGCOMMERCE_ACCESS_TOKEN; const destinationUrl = process.env.WEBHOOK_DESTINATION_URL; if (!storeHash || !accessToken) { console.error("BIGCOMMERCE_STORE_HASH and BIGCOMMERCE_ACCESS_TOKEN must be set"); process.exit(1); } if (!destinationUrl) { console.error("WEBHOOK_DESTINATION_URL must be set"); process.exit(1); } const client = new BigCommerceClient({ storeHash, accessToken }); const result = await client.createWebhook( "store/appearance/contact/form/submitted", destinationUrl, ); console.log("Webhook registered:", JSON.stringify(result, null, 2));}main().catch((err: unknown) => { console.error("Failed to register webhook:", err); process.exit(1);});
The barrel file re-exports every public function so consumers can import from a single entry point.
ts
// src/index.tsexport { normalizePayload } from "./lib/repair.js";export { classifyInquiry, classifyBatch } from "./lib/classify.js";export { routeInquiry } from "./lib/route-handoff.js";export { getOrCreateSession, addInquiryToSession, getSessionContext, endLeadSession } from "./lib/session.js";export { BigCommerceClient } from "./lib/bigcommerce-client.js";export { generateAutoReply } from "./lib/openai-client.js";export { createInquiryTrace, endTrace } from "./lib/observability.js";export { sendSalesAlert } from "./lib/slack.js";export { sendEmail } from "./lib/sendgrid.js";export { handleSalesInquiry } from "./handlers/sales.js";export { handleSupportInquiry } from "./handlers/support.js";export { handleReturnsInquiry } from "./handlers/returns.js";
Expected output: Run pnpm tsx src/scripts/register-webhooks.ts with the proper env vars set. It registers the webhook and prints the response. pnpm typecheck passes with all barrel exports resolved.
Step 13: Configure environment variables
Populate your .env.example placeholders with real values. Every variable the pipeline reads must be set before the app starts.
env
# Env vars used by openai-lead-intake-for-bigcommerce-small-business-sales.# Keep placeholders only — never commit real values.NODE_ENV=developmentOPENAI_API_KEY=<your-openai-api-key>SLACK_TOKEN=<your-slack-bot-token>SLACK_CHANNEL=<C0123456789>SENDGRID_API_KEY=<SG.you...-key>SENDGRID_FROM_EMAIL=<verified-sender@example.com>LANGFUSE_SECRET_KEY=<***>LANGFUSE_PUBLIC_KEY=<pk-lf-your-public>LANGFUSE_BASE_URL=<https://cloud.langfuse.com>HUBSPOT_ACCESS_TOKEN=<pat-your-hubspot-token>BIGCOMMERCE_STORE_HASH=<your-store-hash>BIGCOMMERCE_ACCESS_TOKEN=<your-bc-token>BIGCOMMERCE_WEBHOOK_SECRET=<your-webhook-secret>WEBHOOK_DESTINATION_URL=<https://your-app.example.com/api/webhooks/bigcommerce>
Copy .env.example to .env.local, fill in every value, and the app is ready to run.
Expected output:pnpm dev starts the Next.js dev server. The health endpoint at GET /api/health returns { status: "ok" }.
Step 14: Run the tests
The test suite covers every module with mocked external dependencies. MSW handles OpenAI and BigCommerce API calls, while vi.mock replaces the REAA packages.
Expected output: The test runner runs all tests with coverage reporting. Every test passes with coverage thresholds above 90%. The output shows numFailedTests=0 and coverage lines/branches/functions/statements all at or above 90%.
Next steps
Add a database adapter: Replace MemoryAdapter in src/lib/session.ts with a PostgreSQL or Redis adapter for persistent session storage across restarts.
Deploy to production: Host the app on Vercel, set the environment variables in the dashboard, and point your BigCommerce webhook destination to the deployed URL.
Extend classification: Add more KeywordClassifier instances for new intents like “order-status” or “bulk-pricing” and register corresponding handlers.
Add a triage dashboard: Build a simple admin page at /app/triage that lists human_triage inquiries from the session store for manual review.
Wire real HubSpot contact search: Replace the apiRequest pattern with the typed HubSpot SDK methods for better IDE autocompletion.