DEV Community

Cover image for One person, one contact: deduping at the point of capture
Nasrul Hazim
Nasrul Hazim

Posted on

One person, one contact: deduping at the point of capture

Today I shipped both sides of a form. A marketing site got a "Get Notified" modal on its course pages, and the CRM behind it grew a fourth intake channel: a headless JSON endpoint that external apps POST straight into.

The endpoint itself is the boring half. Ninety minutes of work. The interesting half is what happens on the second submission — when the same person, who subscribed three months ago from a different site, fills in the same form again.

Get that wrong and your CRM slowly becomes a list of the same forty people, five times each.

The endpoint whose credential is its own URL

Start with the shape. Public intake has no user to authenticate, so the token in the path is the credential:

public function store(Request $request, string $token): JsonResponse
{
    $endpoint = CaptureEndpoint::query()
        ->active()
        ->ofType(CaptureEndpointType::API)
        ->where('token', $token)
        ->first();

    if ($endpoint === null) {
        return response()->json(['status' => 'not_found'], 404);
    }

    // Always answer JSON regardless of the Accept header, so a
    // misconfigured client still gets a machine-readable response.
    $validator = Validator::make($request->all(), [
        'name'  => ['required', 'string', 'max:255'],
        'email' => ['required', 'email', 'max:255'],
        'phone' => ['nullable', 'string', 'max:50'],
        // … attribution fields
    ]);

    if ($validator->fails()) {
        return response()->json([
            'status' => 'invalid',
            'errors' => $validator->errors(),
        ], 422);
    }

    ProcessWebLead::dispatch($endpoint->tenant_id, $data, $endpoint->enter_funnel);

    $endpoint->recordUse('submission_count');

    return response()->json(['status' => 'queued'], 202);
}
Enter fullscreen mode Exit fullscreen mode

Three decisions in there that I'd defend in review:

The token resolves the tenant. Nothing in the request body decides which account the lead lands in. A caller can't tamper their way into someone else's pipeline, because there's no field to tamper with.

202, not 200. The lead is queued, not created. Once you accept a public submission, the one outcome you can never have is dropping it, and a lead that has to survive a slow webhook, a sequence trigger, and a round-robin owner assignment before it's "created" is a lead that dies in a timeout. Queue it, ack fast, process later. The 202 is honest about that — it means "I have this", not "it's done".

Always JSON, whatever the Accept header says. The people integrating with a headless intake endpoint are, by definition, writing their own client. Half of them will forget the header. Handing them an HTML error page is a hostile way to spend somebody's afternoon.

The client side is about as thin as you'd expect:

const res = await fetch(INTAKE_URL, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Accept: 'application/json',
    'X-API-Version': 'v1',
  },
  body: JSON.stringify(payload),
});

if (res.status === 202) return { status: 'queued' };
if (res.status === 422) return { status: 'invalid', errors: (await res.json()).errors ?? {} };
if (res.status === 429) return { status: 'error', message: 'Too many attempts — try again in a minute.' };
Enter fullscreen mode Exit fullscreen mode

That URL is baked into a client bundle at build time, and that's fine — it's a public form action, same as <form action="..."> has always been. What it must never be is a URL that reads. Write-only, unguessable, revocable. If it leaks, you rotate the token and the old one 404s.

Now the part that actually matters

Here's the thing about public intake: you are inviting strangers to create rows in your database, and some of those strangers are people you already know.

The naive version creates a contact per submission. Then your sales lead opens the list and sees "Aisyah" four times — one from a webinar signup in March, one from a lead ad, two from the new modal. Which one has the deal attached? Which one has the notes? Nobody knows, and the merge tooling gets built six months later under duress.

So dedupe belongs at capture time, not in a cleanup job:

public function capture(
    array $data,
    Tenant $tenant,
    ?User $by = null,
    bool $requireSource = true,
    bool $enterFunnel = true,
    SequenceTrigger $captureTrigger = SequenceTrigger::CONTACT_CREATED,
    bool $dedupe = false,
): Contact {
    // A repeat submission from a known person merges into the master
    // record — one contact per person.
    if ($dedupe && ($existing = $this->findExisting($data)) !== null) {
        return $this->mergeRepeatSubmission(
            $existing, $data, $tenant, $by, $enterFunnel, $captureTrigger
        );
    }

    // … normal creation path
}
Enter fullscreen mode Exit fullscreen mode

Note that $dedupe defaults to false. Internal creation — a salesperson typing in a contact they just met, a bulk import — does not dedupe. If a human deliberately creates a second row, they had a reason, and silently merging it into an existing record is the kind of "helpful" behaviour that makes people distrust the software. Only the public channels opt in.

The matching itself is deliberately dumb: email first, then phone.

private function findExisting(array $data): ?Contact
{
    if (filled($data['email'] ?? null)) {
        if ($byEmail = Contact::where('email', $data['email'])->first()) {
            return $byEmail;
        }
    }

    if (filled($data['phone'] ?? null)) {
        return Contact::where('phone', $data['phone'])->first();
    }

    return null;
}
Enter fullscreen mode Exit fullscreen mode

No fuzzy name matching, no Levenshtein on the company field. Fuzzy matching gets you the failure mode you cannot undo — two different humans collapsed into one record, with one person's deal history now attached to the other. An exact match on a field the person themselves typed is boring, and boring is the correct amount of clever for something that runs unattended on public input.

(Tenant scoping is handled by the global scope on the model, so these queries are already narrowed to the endpoint's tenant. Worth stating out loud, because a dedupe lookup that isn't tenant-scoped is a cross-tenant data leak wearing a helpful hat.)

The merge rule: the master record wins

This is where I spent the actual thinking time. When someone submits again, what changes?

private function mergeRepeatSubmission(
    Contact $contact, array $data, Tenant $tenant, ?User $by,
    bool $enterFunnel, SequenceTrigger $captureTrigger
): Contact {
    DB::transaction(function () use ($contact, $data, $enterFunnel): void {
        // Fill blanks only — the master record wins over the submission.
        foreach (['phone', 'company', 'job_title'] as $field) {
            if (blank($contact->{$field}) && filled($data[$field] ?? null)) {
                $contact->{$field} = $data[$field];
            }
        }

        // Appending tags diffs on save and fires ContactTagAdded,
        // so automations still see the new interest.
        $tags = array_values(array_unique([
            ...(array) $contact->tags,
            ...(array) ($data['tags'] ?? []),
        ]));

        // … never touch stage, owner, or first-touch attribution
    });

    return $contact;
}
Enter fullscreen mode Exit fullscreen mode

The rules, in order of how much they matter:

  1. Never reset stage or owner. A repeat form submission is not a reason to yank a deal back to "New" or reassign it away from the person who's been working it. This is the one that would actually cost money.
  2. Fill blanks only. If the record has a phone number and the submission has a different one, the record keeps its own. A form field is a lower-trust source than something a salesperson confirmed on a call.
  3. Keep first-touch attribution. The UTM params from the submission that originally found this person stay put. Overwriting them means your channel report quietly credits the last touch to the first touch's budget.
  4. Append tags, don't replace. And append them in a way that diffs on save, so the tag-added event still fires. That's the whole point — the merge has to be invisible to the data model but fully visible to the automations. Somebody who subscribed for Laravel content and now submitted on a Kubernetes page should trigger the Kubernetes sequence.
  5. The message becomes a timeline note. An inquiry message has no column to live in and was previously dropped on the floor. It's now an inbound note activity on the contact — so "what did they actually say" survives the merge.

The one thing that does change state: a contact who has never been in the pipeline — a subscriber who only ever opted into a newsletter — gets promoted when they submit through a funnel-mode endpoint. Never-in-pipeline → in-pipeline is a one-way upgrade and safe. Already-in-pipeline → anything is not.

That distinction is the whole subscriber/funnel split, actually. An endpoint can be configured to capture without entering the funnel: contact recorded, Subscriber status, no owner round-robin, no stage, no first task. Which is exactly what you want from a "notify me when this course opens" button. Generating a sales task for every newsletter signup is how you teach your team to ignore their task list.

Testing the second submission

The test that matters isn't "does the endpoint work". It's "does the second call leave the first call's work alone":

it('merges a repeat submission without resetting the pipeline', function () {
    $endpoint = CaptureEndpoint::factory()->api()->create();

    $existing = Contact::factory()->create([
        'email'    => 'aisyah@example.test',
        'stage_id' => $negotiation->id,
        'owner_id' => $salesRep->id,
        'tags'     => ['laravel'],
    ]);

    postJson("/api/intake/{$endpoint->token}", [
        'name'  => 'Aisyah',
        'email' => 'aisyah@example.test',
        'phone' => '0123456789',
    ])->assertStatus(202);

    Queue::assertPushed(ProcessWebLead::class);
    // … run the queued job

    expect(Contact::where('email', 'aisyah@example.test')->count())->toBe(1);

    $existing->refresh();
    expect($existing->stage_id)->toBe($negotiation->id)   // not reset
        ->and($existing->owner_id)->toBe($salesRep->id)   // not reassigned
        ->and($existing->phone)->toBe('0123456789')       // blank was filled
        ->and($existing->tags)->toContain('laravel');     // old tag survived
});
Enter fullscreen mode Exit fullscreen mode

Four assertions, and three of them are about things that didn't happen. That ratio is normal for merge logic. The bugs here are never "it didn't merge" — you notice that immediately. They're "it merged and also quietly threw away six weeks of sales work", which you notice in a pipeline review, in a month, with no idea when it started.

What I'd watch

The race. Two submissions in the same second both find nothing and both create. The queue serialises most of it in practice, but "in practice" is not a constraint — a unique index on (tenant_id, email) is. On the list.

Email as identity. Shared inboxes (info@, admin@) will collapse several real humans into one contact. For B2B intake that's a real failure mode, and the honest answer is that exact-match dedupe is the right default with an explicit escape hatch, not a universal truth.

Merge is not audit. Right now the merge appends tags and a note, but there's no single "this contact was merged from a repeat submission on this date" event. When somebody eventually asks "why does this contact have a tag from a campaign it was never in", I want a better answer than reading the activity timeline sideways.

The broader takeaway, though, is the one I keep relearning: the moment you open a write endpoint to the public, your data model stops being a description of your business and starts being a description of what strangers submitted. Deduping at capture is how you keep those two things the same shape.

Top comments (0)