Two endpoints that power the v2 catalog-wide bot: er_search queries the catalog with a hybrid FlexSearch + custom-scoring engine and returns a Messenger Generic Template carousel; er_fulfillment evaluates zone + cart + tire-package flag and returns the right fulfillment lanes as quick replies.
Both endpoints accept POST with JSON body, return ManyChat External Request format (version v2, content.messages, set_field_value), and always return 200 OK — even on error — to keep the conversation flowing.
Hybrid FlexSearch + custom-scoring against the nightly catalog index. Returns a Generic Template carousel of the top 3 in-stock matches with brand, price, branch with stock, and fitment confirmation. Sets cf_match_results, cf_top_match_sku, etc. for downstream flows.
{
"subscriber_id": "1234567890",
"cf_query": "oil filter for my civic",
"cf_vehicle_id": "honda_civic_2018_15t",
"cf_intent_subcategory": "filters"
}
{
"version": "v2",
"content": {
"messages": [
{ "type": "text", "text": "found 3 in stock for you ↓" },
{
"attachment": {
"type": "template",
"payload": {
"template_type": "generic",
"elements": [
{
"title": "ACDelco PF64 Oil Filter",
"subtitle": "$14.99 · in stock · belleville · fits your car ✓",
"image_url": "https://ic.carid.com/acdelco/items/pf64_1.jpg",
"buttons": [
{ "type": "postback", "title": "Buy", "payload": "BUY_ACD-PF64" },
{ "type": "postback", "title": "Specs", "payload": "SPECS_ACD-PF64" },
{ "type": "postback", "title": "Compare", "payload": "COMPARE_ACD-PF64" }
]
}
/* +2 more cards */
]
}
}
}
]
},
"set_field_value": {
"cf_match_results": "ACD-PF64,FRAM-XG10358,KN-HP1010",
"cf_match_count": 3,
"cf_top_match_sku": "ACD-PF64",
"cf_top_match_price": 14.99
}
}
Evaluates zone (postal code FSA) + cart total + tire-package flag → returns the available fulfillment lanes. Bot uses returned cf_qr_labels + cf_qr_values to render quick replies dynamically. No hardcoded lane logic in ManyChat — all rules live in this Worker.
{
"cf_postal": "K8N 4Z2",
"cf_cart_total": 249,
"cf_is_tire_package": false
}
{
"version": "v2",
"content": {
"messages": [
{ "type": "text", "text": "👍 cart: $249.00." },
{ "type": "text", "text": "how do you want it?" }
]
},
"set_field_value": {
"cf_in_zone": true,
"cf_branch_origin": "belleville",
"cf_fulfillment_lanes": "pickup_belleville,pickup_kingston,drop_paid",
"cf_fulfillment_default": "pickup_belleville",
"cf_delivery_fee": 15,
"cf_cart_total_with_fee": 264,
"cf_fulfillment_message": "pickup is free, or we'll drop it at your door for $15.",
"cf_qr_labels": "Pickup belleville · free|Pickup kingston · free|Drop off · $15",
"cf_qr_values": "pickup_belleville|pickup_kingston|drop_paid"
}
}
The handler validates the query, loads the catalog from R2 (cached), runs the search engine, and shapes the result into a Messenger carousel. ~80 lines including the carousel-card builder.
// 613parts Messenger Bot · er_search handler
// POST /api/v1/er-search
import { loadCatalog } from '../lib/catalog';
import { searchCatalog } from '../lib/search-engine';
import { ok, err } from '../lib/manychat';
import type { Env, ManyChatRequest, SearchResult } from '../types';
export async function handleSearch(req: ManyChatRequest, env: Env): Promise<Response> {
// ----- Validate -----
const query = req.cf_query?.trim();
if (!query || query.length < 2) {
return err('what part are you looking for? send a name, SKU, or symptom (e.g. "squeaky brakes").');
}
// ----- Load + search -----
const parts = await loadCatalog(env);
const results = searchCatalog(parts, {
query,
vehicleId: req.cf_vehicle_id,
inStockOnly: true,
limit: 3,
});
// ----- No matches -----
if (results.length === 0) {
return ok({
version: 'v2',
content: {
messages: [{
type: 'text',
text: `couldn't find a match for "${query}" in stock. try a different name or SKU — or tap "Talk to a human" and one of our counter staff will help find it.`,
}],
},
set_field_value: { cf_match_count: 0, cf_query_failed: query },
});
}
// ----- Build the carousel -----
const elements = results.map(r => buildCarouselCard(r));
return ok({
version: 'v2',
content: {
messages: [
{ type: 'text', text: `found ${results.length} in stock for you ↓` },
{
attachment: {
type: 'template',
payload: { template_type: 'generic', elements },
},
},
],
},
set_field_value: {
cf_match_results: results.map(r => r.sku).join(','),
cf_match_count: results.length,
cf_top_match_sku: results[0].sku,
cf_top_match_price: results[0].price,
},
});
}
// Build a Messenger Generic Template element (carousel card)
function buildCarouselCard(r: SearchResult) {
const branchLabel =
r.branch_with_stock === 'both' ? 'in stock · both shops' :
r.branch_with_stock === 'belleville' ? 'in stock · belleville' :
'in stock · kingston';
const fitLabel = r.fitment_confirmed ? ' · fits your car ✓' : '';
return {
title: `${r.brand} ${r.name}`.slice(0, 80),
subtitle: `$${r.price.toFixed(2)} · ${branchLabel}${fitLabel}`.slice(0, 80),
image_url: r.image || `https://613parts.ca/img/sku/${r.sku}.jpg`,
buttons: [
{ type: 'postback' as const, title: 'Buy', payload: `BUY_${r.sku}` },
{ type: 'postback' as const, title: 'Specs', payload: `SPECS_${r.sku}` },
{ type: 'postback' as const, title: 'Compare', payload: `COMPARE_${r.sku}` },
],
};
}
Pure logic — no R2/KV reads needed. All decisions are made from request inputs (postal, cart total, tire-package flag) and constants. Returns the lane list + QR labels the bot renders dynamically.
// 613parts Messenger Bot · er_fulfillment handler
// POST /api/v1/er-fulfillment
//
// Lane logic (v2):
// In zone + (tire_package OR cart >= $300) → Lanes 01 + 02 + 03 (default 03 free drop)
// In zone + cart < $300 → Lanes 01 + 02 (default closer pickup)
// Out of zone → Lane 01 only (pickup either branch)
import { checkZone } from '../lib/zone';
import { ok, err } from '../lib/manychat';
import type { Env, ManyChatRequest, FulfillmentLane, QROption } from '../types';
const PAID_DROP_FEE = 15;
const FREE_DROP_THRESHOLD = 300;
export async function handleFulfillment(req: ManyChatRequest, _env: Env): Promise<Response> {
// ----- Validate + coerce -----
const postal = req.cf_postal?.trim();
const cartTotal = Number(req.cf_cart_total) || 0;
const isTirePackage = coerceBool(req.cf_is_tire_package);
if (!postal) return err('postal code is required to show delivery options.');
if (cartTotal <= 0) return err('cart total is required.');
// ----- Zone + branch routing -----
const zone = checkZone(postal);
// ----- Lane selection -----
const lanes: FulfillmentLane[] = [];
let defaultLane: FulfillmentLane;
let deliveryFee = 0;
let message: string;
if (zone.in_zone) {
lanes.push('pickup_belleville', 'pickup_kingston');
if (isTirePackage || cartTotal >= FREE_DROP_THRESHOLD) {
// Lane 03 — free drop-off
lanes.push('drop_free');
defaultLane = 'drop_free';
deliveryFee = 0;
message = isTirePackage
? 'tire+rim package — free local drop-off included.'
: `your order is over $${FREE_DROP_THRESHOLD} — free local drop-off included.`;
} else {
// Lane 02 — paid drop-off
lanes.push('drop_paid');
defaultLane = `pickup_${zone.branch}` as FulfillmentLane;
deliveryFee = PAID_DROP_FEE;
message = `pickup is free, or we'll drop it at your door for $${PAID_DROP_FEE}.`;
}
} else {
// Out of zone — pickup only
defaultLane = 'pickup_belleville';
lanes.push('pickup_belleville', 'pickup_kingston');
deliveryFee = 0;
message = "we don't deliver to your area yet — but you can pick up at either of our shops.";
}
const qrOptions: QROption[] = lanes.map(lane => buildQROption(lane, deliveryFee));
const cartTotalWithFee = cartTotal + deliveryFee;
return ok({
version: 'v2',
content: {
messages: [
{
type: 'text',
text: deliveryFee > 0
? `👍 cart: $${cartTotal.toFixed(2)}.`
: `👍 cart: $${cartTotal.toFixed(2)}. ${message}`,
},
{ type: 'text', text: 'how do you want it?' },
],
},
set_field_value: {
cf_in_zone: zone.in_zone,
cf_branch_origin: zone.branch || '',
cf_fulfillment_lanes: lanes.join(','),
cf_fulfillment_default: defaultLane,
cf_delivery_fee: deliveryFee,
cf_cart_total_with_fee: cartTotalWithFee,
cf_fulfillment_message: message,
cf_qr_labels: qrOptions.map(q => q.label).join('|'),
cf_qr_values: qrOptions.map(q => q.value).join('|'),
},
});
}
// ----- Helpers -----
function coerceBool(v: boolean | string | undefined): boolean {
if (typeof v === 'boolean') return v;
if (typeof v === 'string') return v.toLowerCase() === 'true' || v === '1';
return false;
}
function buildQROption(lane: FulfillmentLane, fee: number): QROption {
switch (lane) {
case 'pickup_belleville':
return { label: 'Pickup belleville · free', value: 'pickup_belleville' };
case 'pickup_kingston':
return { label: 'Pickup kingston · free', value: 'pickup_kingston' };
case 'drop_paid':
return { label: `Drop off · $${fee}`, value: 'drop_paid' };
case 'drop_free':
return { label: 'Drop off · free', value: 'drop_free' };
}
}
Hybrid: FlexSearch retrieves candidates fast, then the canonical 613parts scoring formula re-ranks them. Identical scoring weights as the existing catalog architecture so behavior matches what shop staff already expect.
// Scoring formula (matches the canonical 613parts catalog architecture):
// exact SKU/MPN +1500
// token=SKU/MPN +1000
// substring +600
// brand match +500
// cat match +300
// sub match +200
// name token +50
// desc match +10
// in stock >5 +15
// fits vehicle +250
import FlexSearch from 'flexsearch';
import type { Part, SearchResult } from '../types';
let docIndex: any = null;
let indexedAt = 0;
function buildIndex(parts: Part[]): any {
const index = new FlexSearch.Document({
document: {
id: 'sku',
index: [
{ field: 'sku', tokenize: 'forward' },
{ field: 'mpn', tokenize: 'forward' },
{ field: 'name', tokenize: 'forward' },
{ field: 'brand', tokenize: 'strict' },
{ field: 'cat', tokenize: 'strict' },
{ field: 'sub', tokenize: 'strict' },
{ field: 'desc', tokenize: 'forward' },
{ field: 'tags', tokenize: 'strict' },
],
} as any,
});
for (const part of parts) index.add(part);
docIndex = index;
indexedAt = Date.now();
return index;
}
export interface SearchOptions {
query: string;
vehicleId?: string;
inStockOnly?: boolean;
limit?: number;
}
export function searchCatalog(parts: Part[], opts: SearchOptions): SearchResult[] {
const { query, vehicleId, inStockOnly = true, limit = 3 } = opts;
if (!docIndex || indexedAt === 0) buildIndex(parts);
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
const tokens = q.split(/\s+/).filter(Boolean);
// Step 1 — FlexSearch retrieves candidates fast (~5,000 SKUs in <1ms)
const candidateSkus = new Set<string>();
const fieldResults = docIndex.search(q, { limit: 50 });
for (const fr of fieldResults) {
for (const sku of fr.result) candidateSkus.add(String(sku));
}
// Step 2 — Substring fallback for SKUs/MPNs FlexSearch tokenizer missed
for (const part of parts) {
if (part.sku.toLowerCase().includes(q) || part.mpn.toLowerCase().includes(q)) {
candidateSkus.add(part.sku);
}
}
// Step 3 — Custom scoring on candidates
const partMap = new Map(parts.map(p => [p.sku, p]));
const scored: Array<{ part: Part; score: number }> = [];
for (const sku of candidateSkus) {
const part = partMap.get(sku);
if (!part) continue;
const totalStock = part.stock.belleville + part.stock.kingston;
if (inStockOnly && totalStock === 0) continue;
let score = 0;
const skuLower = part.sku.toLowerCase();
const mpnLower = part.mpn.toLowerCase();
if (skuLower === q || mpnLower === q) score += 1500;
for (const token of tokens) {
if (skuLower === token || mpnLower === token) score += 1000;
else if (skuLower.includes(token) || mpnLower.includes(token)) score += 600;
if (part.brand.toLowerCase() === token) score += 500;
if (part.cat.toLowerCase().includes(token)) score += 300;
if (part.sub.toLowerCase().includes(token)) score += 200;
if (part.name.toLowerCase().includes(token)) score += 50;
if (part.desc.toLowerCase().includes(token)) score += 10;
}
if (totalStock > 5) score += 15;
if (vehicleId && part.fits.includes(vehicleId)) score += 250;
if (score > 0) scored.push({ part, score });
}
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, limit).map(({ part, score }) => ({
sku: part.sku,
mpn: part.mpn,
name: part.name,
brand: part.brand,
price: part.price,
price_label: part.price_label,
image: part.image,
branch_with_stock:
part.stock.belleville > 0 && part.stock.kingston > 0 ? 'both' :
part.stock.belleville > 0 ? 'belleville' : 'kingston',
fitment_confirmed: vehicleId ? part.fits.includes(vehicleId) : false,
score,
}));
}
Catalog loader, postal zone check, and ManyChat response helpers. Each does one thing.
import type { Env, Part } from '../types';
const CATALOG_KEY = 'catalog-search-latest.json';
const CACHE_TTL_SECONDS = 60 * 60; // 1 hour for KV
const IN_MEMORY_TTL_MS = 30 * 60 * 1000; // 30 min for isolate
let inMemoryCache: { parts: Part[]; cachedAt: number } | null = null;
export async function loadCatalog(env: Env): Promise<Part[]> {
const now = Date.now();
// 1. In-memory cache (per-isolate)
if (inMemoryCache && now - inMemoryCache.cachedAt < IN_MEMORY_TTL_MS) {
return inMemoryCache.parts;
}
// 2. KV cache (shared across isolates)
const cached = await env.SEARCH_CACHE.get<Part[]>('catalog', { type: 'json' });
if (cached && Array.isArray(cached) && cached.length > 0) {
inMemoryCache = { parts: cached, cachedAt: now };
return cached;
}
// 3. Cold load from R2
const obj = await env.CATALOG_BUCKET.get(CATALOG_KEY);
if (!obj) throw new Error(`Catalog not found at ${CATALOG_KEY}.`);
const parts = await obj.json<Part[]>();
inMemoryCache = { parts, cachedAt: now };
await env.SEARCH_CACHE.put('catalog', JSON.stringify(parts), {
expirationTtl: CACHE_TTL_SECONDS,
});
return parts;
}
const ZONE_FSAS = {
belleville: ['K8N', 'K8P', 'K8R', 'K8V', 'K0K'],
kingston: ['K7K', 'K7L', 'K7M', 'K7N', 'K7P', 'K0H', 'K7R'],
} as const;
export interface ZoneCheckResult {
in_zone: boolean;
branch: 'belleville' | 'kingston' | null;
fsa: string;
}
export function checkZone(postal: string): ZoneCheckResult {
const fsa = postal.replace(/\s+/g, '').toUpperCase().slice(0, 3);
if (ZONE_FSAS.belleville.includes(fsa as any)) {
return { in_zone: true, branch: 'belleville', fsa };
}
if (ZONE_FSAS.kingston.includes(fsa as any)) {
return { in_zone: true, branch: 'kingston', fsa };
}
return { in_zone: false, branch: null, fsa };
}
import { handleSearch } from './handlers/search';
import { handleFulfillment } from './handlers/fulfillment';
import { fallback } from './lib/manychat';
import type { Env, ManyChatRequest } from './types';
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/health') return new Response('ok', { status: 200 });
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405, headers: { allow: 'POST' } });
}
let body: ManyChatRequest;
try { body = (await request.json()) as ManyChatRequest; }
catch { return new Response('Invalid JSON', { status: 400 }); }
try {
switch (url.pathname) {
case '/api/v1/er-search': return await handleSearch(body, env);
case '/api/v1/er-fulfillment': return await handleFulfillment(body, env);
default: return new Response('Not Found', { status: 404 });
}
} catch (e) {
console.error(`Worker error · path=${url.pathname}`, e);
return fallback();
}
},
} satisfies ExportedHandler<Env>;
Cloudflare's free tier covers everything for a v1 launch (100k requests/day, 1GB R2 storage, 1k KV ops/day). Production tier kicks in at $5/month.
Wrangler is Cloudflare's CLI. One npm install, one auth.
cd worker
npm install
npx wrangler login
Bucket holds the nightly catalog JSON; KV caches it.
npx wrangler r2 bucket create 613parts-catalog
npx wrangler kv namespace create SEARCH_CACHE
Stored encrypted, only readable inside the Worker.
npx wrangler secret put STRIPE_SECRET
Hot reload, full Worker runtime, real R2 + KV bindings.
npm run dev
# Then in another terminal:
curl -X POST http://localhost:8787/api/v1/er-search \
-H 'content-type: application/json' \
-d '{"cf_query":"oil filter civic"}'
Builds + deploys in ~10 seconds. Live globally on edge in 30 sec.
npm run deploy
# Returns: https://613parts-bot-worker.<account>.workers.dev
In ManyChat: Settings → External Requests → Add. Method POST, URL = your Worker URL + path. Map ManyChat custom fields to JSON body.
# In ManyChat External Request config:
URL: https://bot.613parts.ca/api/v1/er-search
Method: POST
Body: { "cf_query": "{{cf_query}}", "cf_vehicle_id": "{{cf_vehicle_id}}" }
Twelve scenarios covering happy paths, edge cases, and failure modes. Each one is a curl command — run them all green and you're production-ready.
Curl with vehicle_id=honda_civic_2018_15t and confirm response has 3 elements with fitment_confirmed=true.
Send cf_query="zxqwerty", confirm 200 OK with cf_match_count=0.
Send the exact SKU of a known part — confirm it returns first.
K8N 4Z2 + $249 + tire_package=false → 3 lanes, default pickup_belleville, fee $15.
K7L 4V1 + $419 + tire_package=false → 3 lanes (incl drop_free), default drop_free, fee $0.
K8P 1X9 + $1249 + tire_package=true → drop_free is default regardless of total.
M5V 2T6 + $249 (Toronto) → only 2 lanes, both pickup.
"k8n4z2", "K8N 4Z2", "K8N4Z2" all return identical results.
Empty body → 200 OK with helpful prompt, NOT a 400.
First request after deploy → check tail logs, p99 should be <50ms.
Upload new catalog to R2 → DELETE the KV key → next request reloads.
Force a throw (e.g. malformed catalog JSON) → bot still gets a 200 with "talk to human" message.