Push every lead a Replyee chatbot captures straight into the client's own admin portal (e.g. Big Mike's Bail Bonds → portal.bigmikesbailbonds.com/admin/leads), the moment it happens — alongside the existing DB record and owner email alert.
One extra hop. When a visitor drops their email in the Replyee chat widget, Replyee does what it already did — and POSTs the lead to a URL you configure per bot. Your portal receives it and inserts a row. No polling, no shared tables, no cross-app coupling.
POST /api/leadsInserts into replyee_leads, bumps the counter, emails the owner (all unchanged).lead_webhook_url, fire-and-forget POST with an X-Replyee-Signature header. Never blocks the visitor; never fails lead capture if the portal is down.POST /api/leads/ingest NEWVerifies the secret, inserts a lead row with source = 'chat'./admin/leadsShows up with a CHAT badge next to the existing CONTACT FORM leads. Done.replyee_leads and the email still sends.
Adds two nullable columns to replyee_chatbots. Run once on the shared Supabase (db.boommedia.us). Safe to re-run.
Replyee/supabase/schema-lead-webhook.sql
alter table replyee_chatbots
add column if not exists lead_webhook_url text,
add column if not exists lead_webhook_secret text;
bot-config route uses for later-migration columns.)
Already written into the repo. This is what was added to the leads route, right after the DB insert + counter bump.
Replyee/src/app/api/leads/route.ts
await supabase.rpc('replyee_increment_lead_count', { bot_id: botId })
// Push the lead to the client's own portal, if configured. Read the webhook
// columns in a SEPARATE, error-swallowing query so an un-migrated DB can't
// break lead capture above.
try {
const { data: hook } = await supabase
.from('replyee_chatbots')
.select('lead_webhook_url, lead_webhook_secret')
.eq('id', botId)
.maybeSingle()
const h = hook as { lead_webhook_url?: string | null; lead_webhook_secret?: string | null } | null
if (h?.lead_webhook_url) {
// Fire-and-forget: never make the visitor wait on the client's portal.
fetch(h.lead_webhook_url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(h.lead_webhook_secret ? { 'X-Replyee-Signature': h.lead_webhook_secret } : {}),
},
body: JSON.stringify({
source: 'chat',
bot_id: botId,
bot_name: bot.name,
email,
question: question ?? null,
session_id: sessionId ?? null,
captured_at: new Date().toISOString(),
}),
}).catch((e) => console.error('[/api/leads] webhook push failed', e))
}
} catch (e) {
console.error('[/api/leads] webhook lookup failed', e)
}
POST {lead_webhook_url}
Content-Type: application/json
X-Replyee-Signature: {lead_webhook_secret}
{
"source": "chat",
"bot_id": "b1a2...",
"bot_name": "Big Mike's Assistant",
"email": "visitor@example.com",
"question": "Need to bail out my brother from PBC jail tonight — is anyone available?",
"session_id": "sess_...",
"captured_at": "2026-07-24T18:54:00.000Z"
}
This lives in the bmbb-portal repo (the live portal.bigmikesbailbonds.com), which is separate from the SaaS workspace. Drop in this route. It's written for a Next.js App-Router portal on Supabase — adjust table/column names to match the portal's actual leads table (see §4).
bmbb-portal/src/app/api/leads/ingest/route.ts (new file)
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
export async function POST(req: NextRequest) {
// 1. Verify the shared secret from Replyee.
const sig = req.headers.get('x-replyee-signature') || ''
if (!process.env.REPLYEE_LEAD_SECRET || sig !== process.env.REPLYEE_LEAD_SECRET) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
// 2. Read the lead.
const body = await req.json().catch(() => ({}))
const { email, question, session_id, bot_name, captured_at } = body || {}
if (!email) {
return NextResponse.json({ error: 'email required' }, { status: 400 })
}
// 3. Insert into the portal's leads table. Adapt column names to your schema.
const { error } = await admin.from('leads').insert({
name: 'Chat visitor', // the widget only collects email; name is unknown
email,
phone: null,
message: question ?? null,
source: 'chat', // drives the CHAT badge on /admin/leads
status: 'new',
external_ref: session_id ?? null, // ties back to the Replyee conversation
created_at: captured_at ?? new Date().toISOString(),
})
if (error) {
console.error('[leads/ingest]', error)
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ ok: true })
}
insert({...}) above is a template. Open the portal's existing leads query (the code behind /admin/leads that renders the "CONTACT FORM" rows) and copy its exact table + column names into this insert. If the contact-form path already has a createLead() helper, call that instead of a raw insert so both sources stay consistent.
The Leads page already renders a source badge ("CONTACT FORM") and its subtitle already reads "Inbound inquiries from the website contact form and chat." So the UI is ready — you just need chat rows to carry source = 'chat'.
source column-- If it doesn't already exist in bmbb-portal's DB:
alter table leads add column if not exists source text default 'contact_form';
alter table leads add column if not exists external_ref text;
Wherever the page renders the grey badge, add the chat case:
const SOURCE_LABEL = {
contact_form: 'CONTACT FORM',
chat: 'CHAT',
}
// ...
<span className="badge">{SOURCE_LABEL[lead.source] ?? 'LEAD'}</span>
Point Big Mike's Replyee bot at the portal, with a shared secret. Two ways:
Generate a secret, then set both columns on the bot. Find the bot id first if you don't have it.
-- find the bot
select id, name from replyee_chatbots where name ilike '%big mike%';
-- set the webhook (use a long random secret — e.g. `openssl rand -hex 24`)
update replyee_chatbots
set lead_webhook_url = 'https://portal.bigmikesbailbonds.com/api/leads/ingest',
lead_webhook_secret = 'PASTE_A_LONG_RANDOM_SECRET_HERE'
where id = 'THE_BOT_ID';
Then set the same secret on the portal as an env var and redeploy:
REPLYEE_LEAD_SECRET=PASTE_THE_SAME_SECRET_HERE
Add "Lead webhook URL" + "Secret" inputs to the Replyee bot settings page so any client can self-serve. The API-key settings page is a good place to add them. Not required for BMBB to work today.
/api/leads/ingest route and (2) a unique secret set on its bot + as its env var. The Replyee side never changes again.
You can test each half independently.
curl -sS -X POST https://portal.bigmikesbailbonds.com/api/leads/ingest \
-H "Content-Type: application/json" \
-H "X-Replyee-Signature: THE_SECRET" \
-d '{"source":"chat","email":"lead-test@example.com","question":"Test from curl","captured_at":"2026-07-24T19:00:00Z"}'
# → {"ok":true} then refresh /admin/leads — a CHAT row appears
Wrong/blank secret should return 401 unauthorized.
curl -sS -X POST https://replyee.online/api/leads \
-H "Content-Type: application/json" \
-d '{"botId":"THE_BOT_ID","email":"chain-test@example.com","question":"End-to-end test"}'
# → {"success":true} the owner gets the email AND the portal gets the CHAT row
| # | Step | Where |
|---|---|---|
| 1 | Run schema-lead-webhook.sql | db.boommedia.us (shared Supabase) |
| 2 | Commit + push the Replyee /api/leads change, redeploy | Replyee (Vercel) |
| 3 | Add /api/leads/ingest route, matched to the real leads schema | bmbb-portal |
| 4 | Add source/external_ref columns + the CHAT badge case | bmbb-portal + its DB |
| 5 | Set REPLYEE_LEAD_SECRET env var, redeploy | bmbb-portal (Coolify/Vercel) |
| 6 | update replyee_chatbots with the URL + same secret | db.boommedia.us |
| 7 | Run both curl tests | — |
node_modules stripped, so the code change was written but not built — it compiles by construction (same pattern as bot-config), but do a real build on deploy (step 2).
Replyee/supabase/schema-lead-webhook.sql · Replyee/src/app/api/leads/route.ts