Services
Web Development→Mobile Development→Cloud & DevOps→AI & Automation→UI/UX Design→SEO & GEO→
Company
Case StudiesAboutBlogContact
hello@codevibe.in+91 70677 09224
Client Case Study · Travel CRM

Every lead, from every channel, through one door.

Website forms, Facebook and Instagram lead ads, WhatsApp and campaign landing pages all send leads differently and prove themselves differently. Codevibe built one engine that takes them all through a single webhook, verifies and normalises every lead, hands it to the CRM with retries, and logs every request. Live inside a holiday travel brand’s CRM in September 2026; running inside a second travel platform three weeks after its first commit.

engagement.ts
// lead parsing system · holiday crm, then one more
const lps = await codevibe.build({
  sources: ["forms", "meta-ads", "whatsapp", "pages"],
  entry: "one signed webhook per source",
  pipeline: "verify → parse → normalise → store",
  retries: "30 s × 2ⁿ, cap 1 h, 10 tries",
  log: "every request, every outcome"
});
// → one inbox, each lead exactly once
First built for
Holiday travel brand’s CRM
Reused in
B2B group-booking platform (client anonymised)
Lead sources
Website forms · Meta lead ads · WhatsApp · Landing pages
Stack
NestJS · PostgreSQL · React
// EXECUTIVE SUMMARY

Sales teams do not lose leads because nobody is trying. They lose them in the gaps between tools: a Meta form that has to be exported by hand, a WhatsApp enquiry buried in support chat, a landing page that posts to an inbox nobody checks, a provider that swears it sent the lead and nothing to prove otherwise.

The Lead Parsing System closes those gaps with one idea: every source is a connector that only knows how to verify and parse its provider; everything after that is shared. Codevibe built it first inside a holiday travel brand’s CRM, then ported the same module into a B2B group-booking platform for a second client, with different business rules on identical plumbing.

4
Lead sources, one webhook
100
Form field names understood out of the box
21
Days from first commit to a second platform
246
Unit tests across both copies
// SECTION 01 · THE PROBLEM

No two sources agree on anything.

What a lead looks like before it is a lead. Each provider has its own payload, its own way of proving who sent the request, and its own retry habits. Any system that takes them all has to absorb those differences somewhere.

Meta lead ads
A notification carrying only a leadgen id. The answers are not in the webhook.
HMAC-SHA256 over the raw body in X-Hub-Signature-256, plus a GET handshake with a verify token.
Retries any delivery that did not get a 200. The standard “city” question is the traveller’s home city, not the destination.
WhatsApp
Inbound message events. Meta Cloud API wraps them in entry → changes → messages; DoubleTick sends its own flat shape.
Cloud API signs like Meta. DoubleTick signs nothing: the secret in the webhook URL is the whole check.
Every message from a number is a candidate lead, including “ok” and support chatter.
Website forms
Whatever field names the form builder chose: name or full_name or your-name, mobile or phone or contact.
Optional HMAC-SHA256 in x-lps-signature when a signing secret is set.
May arrive as one object, an array, or wrapped in a data envelope.
Landing pages
The same as a form, from Unbounce, Instapage or WordPress builders.
Same optional signature as forms.
Often arrives without UTMs, so the campaign has to be inferred from the page.

“Only the verify and parse steps are connector-specific. Everything else, from normalising a phone number to retrying the CRM, is written once.”

// SECTION 02 · INSIDE THE SYSTEM

One lead, start to finish.

Follow one enquiry from the moment a customer sends it to the moment an agent sees it. Five stops, one glowing lead, and a log that writes down every request, accepted or refused.

The story plays on its own. Pause, step through, or click a card.

priya sharma · Bali
fake request
200 ✓ · Meta ad
  1. 01 · Provider → API
    One webhook per connection

    Every source gets its own URL with its own secret. The provider posts to it; nothing else in the system knows or cares what that provider’s payload looks like. Before anything is checked, a delivery-log row is opened for the request.

    REJECTED_SECRET
  2. 02 · API
    Paused connections acknowledge and drop

    An admin can pause a source from the panel. A paused connection still answers 200, so the provider stops retrying, but nothing is stored. This is deliberate: a 4xx here would make Meta hammer the endpoint for days.

    CONNECTION_DISABLEDCONNECTOR_MISSING
  3. 03 · Connector
    The connector proves who sent it

    Meta and the WhatsApp Cloud API sign every request; the connector checks the signature over the exact bytes received. Forms can be signed too. A GET from Meta is a subscription handshake and is answered with its challenge.

    REJECTED_SIGNATUREHANDSHAKE
  4. 04 · Connector
    The connector turns the payload into leads

    One delivery can hold zero, one or many leads. For Meta, the webhook is only a notification: the connector fetches the actual answers from the Graph API with the page token, then maps the form’s questions onto our fields. Anything unmapped is kept in the message as “question: answer” lines, so nothing is lost.

    INVALID_JSONERROR
  5. 05 · Pipeline
    Normalise to one shape

    Phone numbers become digits with the country code, so a WhatsApp number and a form entry for the same person look identical. Emails are lowercased, dates become ISO, names are tidied. Campaign attribution is read from wherever the provider put it.

  6. 06 · Pipeline
    Guard against noise and replays

    A lead with no phone and no email cannot be contacted, so it is dropped and the reason is logged. A provider resending the same lead is recognised by its id and stored once. In the holiday CRM a repeat phone or email within 30 days is held as a duplicate rather than sent on; the group-booking platform treats every enquiry as a new trip.

    NO_LEADDUPLICATE_ONLY
  7. 07 · PostgreSQL
    Store with provenance

    The lead is written with everything known about it: contact, trip intent, 31 attribution fields, the connection it came from, the provider’s timestamp and ours, and the untouched raw payload for debugging or replay.

    ACCEPTED
  8. 08 · Forwarder
    Hand off, and keep trying

    The moment a lead is stored, the forwarder hands it to the CRM in the same process. If the CRM is busy or down, the lead stays put with a retry time and a job sweeps every minute. Only a lead the CRM rejects as invalid is parked for a person to look at.

  9. 09 · CRM
    A lead in the inbox, exactly once

    The CRM creates its own lead, keyed on the LPS lead id, so even a retry that races the first attempt cannot create twins. From here the business rules of that CRM take over: a round-robin to a destination expert in the holiday CRM, an unassigned inbox lead in the group-booking panel.

// SECTION 03 · NOTHING LOST, NOTHING TWICE

Retries that cannot create twins.

Handing a lead to the CRM is where lead systems usually leak: the CRM is down and the lead is lost, or a retry sends it twice and two agents call the same customer. Pick a scenario and watch what this system does instead.

Pick what goes wrong, then watch

The CRM is restarting or its database is busy. The lead must not be lost.

Without the LPS provider posts straight to the CRM

The provider retries for a while, then gives up. The lead sits inside Meta or the form tool until someone notices and exports it by hand.

With the LPS what you just watched

Stored first, retried on a doubling schedule that never passes an hour. After ten tries it is parked for a person, never dropped.

01At the door

A provider sending the same lead again is recognised by its id and skipped, even if two copies arrive at the same moment.

unique (connectionId, externalId)
02In the forwarder

Only one send per lead at a time. A lead that is being sent cannot also be picked up by the minute sweep.

in-flight set · sweep batch 50
03In the CRM

The inbox lead is keyed on the LPS lead id. A second attempt gets the same lead back, never a new one.

idempotency row, same transaction, 180 days
// SECTION 04 · CREDENTIALS AND ACCESS

Secrets that stay secret.

A connection holds real provider credentials: a Meta app secret, a page access token, a verify token. They are treated as the most sensitive thing in the system.

Encrypted at rest, in a format that explains itself

Each connector names its secret fields. A decorator around the store seals those fields on save with AES-256-GCM under a key that belongs to the LPS alone. The ciphertext carries its own version and IV, so encryption can be switched on for a running system: older plain values are still read, and sealed the next time the connection is saved.

core/secrets.ts
// enc:v1:<iv>:<tag>:<data>
const iv = randomBytes(12);
createCipheriv('aes-256-gcm', key, iv);
return `enc:v1:${iv}:${tag}:${data}`;

Masked everywhere it is shown

Every API response masks a secret to its last four characters. When the settings form is saved with the mask still in place, the stored value is kept; only a new value replaces it. The webhook secret itself is 24 random bytes, shown once in the URL because the URL is what gets pasted into the provider, and replaced in one click when a URL leaks.

  • Delivery log records signature headers as present or absent, never their value
  • Query strings logged with secret, verify token and access token redacted
  • Raw payloads visible only with the connections permission

Permissions per action, not per screen

Seeing the queue, acting on a lead and managing connections are three different grants. Admins hold all three by default; a sales manager can retry a stuck lead without ever seeing a webhook URL.

leadparsing:readSee the queue and the breakdowns
leadparsing:manageRetry a failed hand-off, send a duplicate on purpose
leadparsing:connectionsConnections, the delivery log, raw payloads

Keys from the group-booking platform. The holiday-CRM copy uses ops.lead_parsing.read and .write, with connections and deliveries limited to the admin role.

// SECTION 05 · ONE ENGINE, TWO BUSINESSES

Same plumbing. Different rules.

The strongest proof that the design holds is that it was ported. Connectors, pipeline, forwarder, secrets and delivery log are the same code in both products; what each business wants to happen to a lead lives in its intake adapter, not in the connectors.

Repeat contact
Holiday CRMSame phone or email within 30 days is held as a duplicate and not sent to the CRM. An admin can send it on purpose.
Group bookingEvery enquiry is its own lead (since 15 Oct 2026). A provider resending the same lead is still dropped by its id.
Who gets the lead
Holiday CRMRound-robin among that destination’s experts, only agents seen in the last 20 minutes, between 10:00 and 16:00 IST, Monday to Saturday. Otherwise the lead waits with a retry time and is picked up when the window opens or someone logs in. No expert for the destination: it waits for a hand-pick.
Group bookingLands in the inbox as New and unassigned. Admins and sales managers assign it, like any enquiry. No round-robin, no working-hours window.
Exactly once
Holiday CRMAn idempotency row is claimed inside the same transaction as the CRM lead and kept 180 days. The round-robin pointer is locked with SELECT … FOR UPDATE, so two leads for one destination take turns.
Group bookingThe inbox lead carries a unique LPS id. The intake returns the existing lead on a retry and handles the race on the unique index.
First contact
Holiday CRMA WhatsApp welcome template goes out once per lead on arrival, before assignment, so a lead that waits for the morning has something to reply to.
Group bookingStaff get the usual enquiry-received notification with a link to the lead; a system activity records which connection it came from.

Round-robin, working hours and the online rule are the holiday CRM’s. The group-booking platform’s documentation says it plainly: “There is no round-robin and no working-hours window.”

// SECTION 06 · HOW IT WAS BUILT

Five weeks, two platforms.

The dates below are commit and merge dates from the two repositories. The work ran on a feature branch and reached the holiday CRM’s main branch through 37 pull requests between 21 September and 11 October 2026; the port into the second platform was a single reviewed pull request. Tap a milestone to expand it.

A standalone app: four connectors, the verify → parse → normalise → dedupe → store pipeline, an event bus and a JSON store. 30 files, 2,012 lines.

PostgreSQL store with its migration, the CRM forwarder with retry and parking, secrets sealed with AES-256-GCM, the first unit tests, the admin screens, and round-robin assignment to destination experts.

Last-touch marketing attribution captured per lead, and every webhook request recorded with its outcome, redacted query and raw body.

The separate app, its URL and its tokens went away. The LPS became a module inside the CRM’s NestJS API; the forwarder calls the intake in process.

Departure-city matching with nearest-airport lookup, parsing of free-text form answers, destination and channel summaries, and redistribution of a departed agent’s leads through the same round-robin.

A CRM-form connector mirrors leads created on the CRM form, keyed on the CRM id, so one summary counts every lead whatever the entry point.

The module dropped into the group-booking platform in one pull request: 68 files, 9,389 lines, seven test files, three new tables and an intake adapter for that platform’s inbox.

The enquiry wizard, call-back and newsletter forms register with the API and flow through the website-form connection, with a direct fallback so a form is never lost.

A DoubleTick Bot Studio intake endpoint enriches a recent open lead or relays the conversation into the pipeline, where it gets the same round-robin and welcome.

The group-booking copy drops the 30-day duplicate window: for that business a repeat contact is a new trip. The holiday-CRM copy keeps it. Same engine, each platform’s own rule.

// SECTION 07 · THE STACK, IN CONTEXT

What each technology is for.

No logo wall. Each entry says what the technology does in this system and why it earned its place.

NestJS 11
The LPS is a NestJS module: a public webhook controller, a permission-guarded admin controller, a service that owns the store and the forwarder, and two cron jobs on @nestjs/schedule.
PostgreSQL + Prisma
Three tables: connections, leads and deliveries. Unique indexes do the replay and twin protection; a SELECT … FOR UPDATE on the round-robin cursor serialises assignment.
Zod
Each connector declares its config as a Zod schema. The API publishes it as JSON Schema and the panel renders the form from it, so a new connector needs no UI code. The intake payload contract is Zod too.
node:crypto
HMAC-SHA256 with a timing-safe compare for provider signatures; AES-256-GCM with a random IV for credentials at rest.
React panels
Inbound leads, deliveries and connections screens on TanStack Query, with a schema-driven connection dialog and a lead drawer that links each lead to the request that produced it.
Vitest and Jest
96 unit tests in the group-booking copy (Vitest) and 150 in the holiday-CRM copy (Jest, Vitest for the admin), over normalisation, every connector, the pipeline, the forwarder, secrets, the webhook and the intake.
// SECTION 08 · OUTCOMES

What is demonstrated, and what it is for.

Two lists, kept apart on purpose: what the code does today, and what that is designed to make possible. The second has not been measured yet, so it is not claimed as a result.

Demonstrated in the code
  1. 01

    Four sources through one door

    Website forms, Meta lead ads, WhatsApp (Cloud API or DoubleTick) and landing pages post to one webhook route and come out as one lead shape.

  2. 02

    Every request is on record

    Ten distinct outcomes, each with the redacted query, safe headers and the raw body. The first place to look when a provider says it sent a lead.

  3. 03

    Retries without twins

    In-process hand-off, exponential backoff to an hour, ten attempts, parking on a 400, and idempotency on the lead id at the CRM side.

  4. 04

    Credentials sealed

    App secrets, page tokens and verify tokens encrypted at rest in a self-describing format, masked in every response, and recognised when a form echoes the mask back.

  5. 05

    Ported once, proven

    The same module runs inside two unrelated products with different assignment, duplicate and notification rules.

Designed to enable
  • Leads from paid campaigns reach a salesperson without anyone exporting a CSV from Meta.
  • A WhatsApp enquiry sent at night is greeted immediately and assigned when the team is in.
  • Marketing can see paid versus organic by destination and platform from the same data the sales team works.
  • Adding a new lead source is one connector file, not a project.
On the evidence

Every count, constant and date on this page was read from the two codebases and their commit history in October 2026. Where the copies differ, the page says which is which. Both clients are described by sector rather than named. No business metrics are claimed, because none have been measured yet.

// FAQ

Questions about this system.

A lead-ingestion module Codevibe built for travel CRMs. Each lead source, such as a website form, a Meta lead-ad form, a WhatsApp number or a campaign landing page, gets its own webhook URL. The module verifies each request, turns the provider’s payload into one lead shape, stores it with its attribution and raw payload, and hands it to the CRM with retries. Every request a provider sends is logged with its outcome.

Four provider connectors: website forms, landing pages, Meta lead ads (Facebook and Instagram) and WhatsApp through either the Meta Cloud API or DoubleTick. The holiday-CRM copy adds a connector that mirrors leads typed into the CRM by hand and an intake for DoubleTick Bot Studio conversations. A new source is one connector file plus one line in the registry; the panel form for its settings is generated from the connector’s schema.

Three layers. A provider resending the same lead is recognised by its id and stored once, backed by a unique index. The hand-off to the CRM runs in the same process with exponential backoff, ten attempts and a sweep every minute, and only a lead the CRM rejects as invalid is parked for a person. The CRM creates its lead idempotently on the LPS lead id, so a retry that races the first attempt cannot create twins.

Yes. That is how the second installation happened: the module was ported into the group-booking platform in one pull request, with a small intake adapter that creates that platform’s inbox lead. The connectors, pipeline, forwarder, secrets handling and delivery log came across unchanged; the business rules, such as what counts as a duplicate and who gets the lead, are set per platform.

Each connector declares which of its settings are secrets. Those are encrypted at rest with AES-256-GCM under a key that belongs to the LPS alone, in a self-describing format so encryption can be switched on without downtime. Every response masks them, and a settings form that echoes the mask keeps the stored value. Webhook URLs carry a per-connection secret that can be rotated in one click, and the delivery log records whether a signature header was present, never its value.

The repositories record the first commit on 11 September 2026, the module running inside the holiday CRM’s API by 22 September, and the port into a second platform merged on 2 October 2026. The dates on this page are commit and merge dates. No separate delivery deadline for this module is recorded in either repository, so none is claimed.

Yes. The engine is not travel-specific: a connector understands one provider, and an intake adapter creates whatever your CRM calls a lead. Start with the form below and we will come back within one business day.

// LET'S TALK

Leads arriving from more places than you can watch?

One webhook per source, one shape for every lead, retries that cannot create twins, and a log of every request. We have built it twice; the third time starts with a conversation.

hello@codevibe.in · +91 70677 09224 · Gurgaon, India