CallbackCloser is a Next.js SaaS MVP for the workflow: Missed Call -> Booked Job.
When a customer calls a business's Twilio number and the forwarded call is missed, the app:
- records the call and lead in Postgres
- starts an SMS qualification flow (subscription-gated)
- stores all inbound/outbound messages in Prisma
- notifies the owner by SMS after ZIP is collected
- lets the owner manage leads in a protected dashboard
- Next.js 14 App Router + TypeScript
- Tailwind CSS + shadcn-style UI components
- Prisma + Postgres
- Clerk auth
- Stripe subscriptions
- Twilio voice + messaging webhooks
- Vercel-ready deployment
- Clerk sign-in/sign-up and protected
/apparea - Business onboarding (creates
Businessassociated toownerClerkId) - Business Settings with call/SMS config + Twilio number purchase button
- Twilio voice webhook (
/api/twilio/voice) and dial status callback (/api/twilio/status) - Missed-call lead creation + idempotent callback handling
- Persisted SMS state machine per lead (
smsStatein DB) - Twilio SMS webhook (
/api/twilio/sms) with lead qualification steps - Lead dashboard + filters + lead detail transcript + status updates
- Stripe billing page + checkout + billing portal
- Public purchase entry route (
/buy) for external marketing-site links - Stripe webhook sync for subscription status gating
- SMS compliance commands (
STOP/START/HELP) with DB-backed opt-out state - Call recording enabled on forwarded calls + recording metadata captured on callbacks
- Twilio webhook protection: production-enforced
X-Twilio-Signaturevalidation, with shared-token fallback only in non-production - Webhook observability baseline: correlation IDs (
X-Correlation-Id), centralizedapp.errorreporting, optional alert webhook dispatch /api/healthreadiness endpoint for deploy smoke checks and uptime monitors- Production guardrail:
PORTFOLIO_DEMO_MODEis blocked in production unlessALLOW_PRODUCTION_DEMO_MODE=trueis explicitly set
npm installCreate a Postgres database named callbackcloser (or any name you prefer), then set DATABASE_URL in .env.local.
Example:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/callbackcloser?schema=publicCopy .env.example to .env.local if needed, then fill all required values.
Required categories:
- Clerk keys
- Stripe keys + price IDs + webhook secret
- Twilio credentials + webhook auth token
- Database URL
- Optional rate-limit tuning vars (defaults are built in)
This repo includes a Prisma migration at prisma/migrations/20260222000000_init/migration.sql.
npm run db:generate
npx prisma migrate deployFor local development schema iteration, you can also use:
npm run db:migratenpm run devOpen http://localhost:3000, sign up, then go to /app/onboarding if not redirected automatically.
npm run env:check
npm test
npm run lint
npm run typecheck
npm run buildOptional helper commands:
npm run webhooks:print
npm run db:smoke- Create a Clerk application.
- Copy these values into
.env.local:NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYCLERK_SECRET_KEY
- In Clerk dashboard, add redirect URLs (local + production):
http://localhost:3000/sign-inhttp://localhost:3000/sign-uphttps://YOUR_DOMAIN/sign-inhttps://YOUR_DOMAIN/sign-up
- Ensure your app origin(s) are allowed in Clerk.
Create two recurring subscription prices in Stripe (Starter and Pro). Copy the Price IDs into:
STRIPE_PRICE_STARTERSTRIPE_PRICE_PRO
Set:
STRIPE_SECRET_KEY
Create a webhook endpoint pointed to:
https://YOUR_DOMAIN/api/stripe/webhook- Local (via Stripe CLI tunnel):
http://localhost:3000/api/stripe/webhook
Recommended events:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failedinvoice.payment_succeeded
Set the resulting endpoint signing secret as:
STRIPE_WEBHOOK_SECRET
stripe listen --forward-to localhost:3000/api/stripe/webhookCopy the printed webhook signing secret into .env.local.
Set:
TWILIO_ACCOUNT_SIDTWILIO_AUTH_TOKENTWILIO_WEBHOOK_AUTH_TOKEN(your shared secret used by this app)TWILIO_VALIDATE_SIGNATURE(required in production, set totrue)
- Complete Business Settings in the app.
- Open
/app/settings. - Click Buy Twilio number.
- The app purchases a US local number and sets the Twilio Voice + Messaging webhook URLs automatically.
If you configure a Twilio number manually in the Twilio Console, use:
You can print the exact URLs from your current env with:
npm run webhooks:print- Voice webhook (A CALL COMES IN)
- Method:
POST - URL:
https://YOUR_DOMAIN/api/twilio/voice?webhook_token=YOUR_TWILIO_WEBHOOK_AUTH_TOKEN
- Method:
- Messaging webhook (A MESSAGE COMES IN)
- Method:
POST - URL:
https://YOUR_DOMAIN/api/twilio/sms?webhook_token=YOUR_TWILIO_WEBHOOK_AUTH_TOKEN
- Method:
The /api/twilio/status callback URL is set automatically by the TwiML returned from /api/twilio/voice (the <Dial action="..."> URL includes the same webhook_token).
Notes:
- The app supports shared-secret checks (header/query) for non-production/local workflows.
- Production requires
TWILIO_VALIDATE_SIGNATURE=trueand valid TwilioX-Twilio-Signature. - In production, token-only webhook auth is rejected and signature validation fails closed.
- Some Twilio Console surfaces do not expose custom header configuration, so query param fallback is supported for direct console setup.
/api/twilio/statusis called automatically by the TwiML generated from/api/twilio/voice.
- Looks up the
Businessby called Twilio number (To) - Returns TwiML
<Dial>tobusiness.forwardingNumber - Uses
timeout = business.missedCallSeconds - Enables call recording on
<Dial>(record-from-answer-dual) - Sets both dial action callback and recording status callback to
/api/twilio/status - Returns
401for invalid/missing webhook auth token and logs a structured webhook event
- Records/upserts
Call - Marks answered vs missed using
DialCallStatus - Captures recording metadata when Twilio sends recording status callbacks (
RecordingSid,RecordingUrl,RecordingStatus,RecordingDuration) - Creates missed-call
Leadif needed (idempotent) - Starts SMS flow only when billing is active
- If billing inactive: lead is still recorded and
billingRequired=true - Duplicate/retried callbacks are safe:
Callis upserted bytwilioCallSid,Leadis reused bycallId, and an already-started SMS thread (smsStartedAt) is not started again
State machine steps (persisted on Lead.smsState):
- Service (1/2/3 or free text)
- Urgency (1 Emergency / 2 Today / 3 This week / 4 Quote)
- ZIP
- Best time (morning/afternoon/evening)
- Optional name
After ZIP is collected, the owner receives a summary SMS + lead link (if notifyPhone is set).
Compliance handling:
- Inbound
STOP/STOPALL/UNSUBSCRIBE/CANCEL/END/QUITmarks the sender opted-out in DB and returns a confirmation - Inbound
START/YES/UNSTOPclears opt-out and confirms - Inbound
HELPreturns a help message with app name + instructions - Future outbound SMS to an opted-out recipient is suppressed until they opt back in (
START)
Security / idempotency notes:
- Invalid webhook token ->
401 - Unauthorized webhook bursts are rate-limited with
429(Retry-After+X-RateLimit-*headers) - Duplicate inbound SMS retries with the same
MessageSidare deduped viaMessage.twilioSidand ignored after persistence check - Webhook handlers log structured events (
callSid/messageSid, event type, decision)
Current behavior:
- Forwarded calls are recorded via TwiML
<Dial record="record-from-answer-dual"> - The app stores recording metadata on
Call(recordingSid,recordingUrl,recordingStatus,recordingDurationSeconds) when Twilio posts recording callbacks to/api/twilio/status - Recording audio remains hosted in Twilio; CallbackCloser streams it through a server-side proxy for authenticated in-app access
Recording URL safety + proxy behavior:
- Stored recording URLs are validated before use:
- must use
https:// - host must be allowlisted Twilio recording/API host:
api.twilio.com,api.us1.twilio.com,api.ie1.twilio.com, orapi.au1.twilio.com - path must be a Twilio recording resource path (
.../Recordings/...)
- must use
- Invalid/malformed recording URLs return
404from/api/leads/[leadId]/recording - Authorized requests are fetched from Twilio with server credentials and streamed back to the signed-in owner (no raw Twilio URL redirect)
Where to access recordings:
- Lead detail page (
/app/leads/[leadId]) shows recording status/duration and an authenticatedOpen recordingaction - Recording links are mediated through
/api/leads/[leadId]/recording, which checks the signed-in owner + business ownership and streams media via the server proxy - Twilio Console -> Monitor -> Calls (or Call Logs / Recordings, depending on account UI)
- Database (
Call.recording*fields) for metadata lookup / correlation
- Missed calls and leads are always recorded.
- If Stripe subscription status is not active, the app does not send SMS to leads.
- These leads are marked
billingRequired=trueand flagged in the dashboard. - New missed calls begin SMS follow-up automatically once subscription status becomes active again.
Prisma models included:
BusinessLeadMessageCall
- Push repo to Git.
- Import project in Vercel.
- Add all environment variables from
.env.local(or from your secret manager).- Quick check:
npm run env:check
- Quick check:
- Set
NEXT_PUBLIC_APP_URLto your production origin, e.g.https://app.example.com. - Run Prisma migrations against your production database:
- Either via CI/CD step:
npx prisma migrate deploy - Or manually once after deploy
- Either via CI/CD step:
- Configure Stripe webhook to the Vercel domain.
- Configure Twilio phone number webhooks (or buy the number through the app after deploy).
- Helper:
npm run webhooks:print(redacts the shared token by default)
- Helper:
- Confirm
NEXT_PUBLIC_APP_URLis set in bothProductionand (if used)Preview, and includeshttps://. - Optionally set
DEBUG_ENV_ENDPOINT_TOKEN, then verify app URL resolution:https://YOUR_DOMAIN/api/debug/env?token=YOUR_DEBUG_ENV_ENDPOINT_TOKEN
Use this URL for the Buy CTA on getrelayworks.com:
https://YOUR_DOMAIN/buy
Optional plan-specific links:
https://YOUR_DOMAIN/buy?plan=starterhttps://YOUR_DOMAIN/buy?plan=pro
/buy handles auth/onboarding redirects and lands the user on /app/billing.
Use this checklist before sending paid traffic from getrelayworks.com or allowing the release to auto-deploy to production.
- Confirm the production branch release content is complete.
- Merge and verify the launch branches that are not yet on
main:chore/p0-security-roadmapchore/product-ux-legalhardening/g14-recordings-ux
- Merge and verify the launch branches that are not yet on
- Run the full verification suite from a clean checkout:
npm run env:checknpm testnpm run lintnpm run typechecknpm run build
- Confirm Vercel production env vars match
docs/PRODUCTION_ENV.md. - Apply production Prisma migrations:
npx prisma migrate deploy- optional smoke:
npm run db:smoke
- Confirm Stripe production setup:
- live products/prices exist
- live webhook targets
/api/stripe/webhook - billing portal is enabled
- Confirm Twilio production setup:
- production number is assigned
- webhooks point to the production app URL
TWILIO_VALIDATE_SIGNATURE=true- answered, missed, STOP, START, and HELP flows are tested
- Confirm Clerk production setup:
- production domain/origins are allowed
- sign-in and sign-up redirects work
- Confirm monitoring and operations readiness:
/api/healthreturns200- alerting or error sink is live
- backup/restore drill evidence is current
- Confirm customer-facing launch surface:
/terms,/privacy, and/refundare public- support inbox/contact path is monitored
getrelayworks.comBuy CTA points to the approved production flow
/- landing page/buy- external purchase entry (redirects through auth/onboarding to billing)/terms- terms of service/privacy- privacy policy/refund- refund policy/contact- public support/contact page/sign-in- Clerk sign-in/sign-up- Clerk sign-up/app/onboarding- create business record/app/leads- dashboard/app/settings- business settings + Twilio number provisioning/app/billing- Stripe subscription page/api/health- readiness probe for deploy and uptime checks/api/twilio/voice- Twilio voice webhook/api/twilio/status- Twilio dial action callback/api/twilio/sms- Twilio SMS webhook/api/leads/[leadId]/recording- authenticated recording media proxy for lead owners/api/stripe/webhook- Stripe webhook
- Twilio webhook verification is env-gated: production enforces signature validation; shared-token checks are for non-production fallback/testing.
- Outbound lead/owner messages are sent via Twilio REST API so their
twilioSidcan be persisted. - For simplicity, this MVP assumes one owner-managed business per Clerk user.
- Folders matching
upwork_pack*,portfolio_*, andupwork_gallery_images/are generated export/demo artifacts and are not part of the app source; they are ignored by Git/TypeScript/ESLint.
- Set
NEXT_PUBLIC_APP_URLin Vercel -> Project Settings -> Environment Variables (Production and Preview as needed) - Use a full URL including
https://(for examplehttps://callbackcloser.com) - After updating env vars, redeploy
- Optional: use
/api/debug/env(token-protected in production) to confirm which app URL source was resolved
- In production: confirm
TWILIO_VALIDATE_SIGNATURE=true,TWILIO_AUTH_TOKENmatches the Twilio account token, and Twilio is calling the exact production URL - In non-production token-mode tests: confirm
TWILIO_WEBHOOK_AUTH_TOKENis set on the app and the same token is in webhook requests (?webhook_token=...) or a supported header - Reprint expected URLs with
npm run webhooks:print - Re-sync webhooks from
/app/settingsafter changingNEXT_PUBLIC_APP_URLor the webhook token
- Keep app envs in
.env.local - Create a root
.env(gitignored) withDATABASE_URLandDIRECT_DATABASE_URLfor Prisma CLI - See
docs/DB_NEON_PRISMA.md