LinkedIn's "Easy Apply" button is, from the outside, an API-shaped feature — a candidate clicks once, applies, and the job disappears from their feed. From the inside, on the recruiter's end, it's nothing of the sort. LinkedIn doesn't expose an Easy Apply API to third-party platforms. What it gives you instead is an email.

For a recruitment platform ingesting applications from dozens of job posts a day, that email is the entire interface. This is the story of replacing the automation tool that was bridging that gap — Zapier — with a single-purpose AWS Lambda, and why that turned out to be a low-risk, high-leverage swap rather than a rebuild.

The problem: Easy Apply has no API, only email

Every time a candidate applies through Easy Apply, LinkedIn sends an email to the inbox tied to the job (in this case, an address like linkedin-easyapply-emails), with a subject line that looks like this:

New application: Financial Manager (1928) from Alpheus Themane

The candidate's CV comes attached as a PDF, DOC, or DOCX. Everything the platform needs to turn that into an actual application is sitting right there in the subject line and the attachment — but something has to actually do the work:

  • Watch that inbox for new mail
  • Pull the job ID and applicant name out of the subject line
  • Grab the CV attachment
  • Drop it somewhere the platform's own pipeline can pick it up and turn it into an application

That "somewhere" used to be Zapier — a Zap watching the inbox, parsing the email, and pushing the attachment into storage over a webhook. It worked. It was also a black box sitting between LinkedIn and our own infrastructure, billed monthly, with a task-volume ceiling we'd eventually run into.

What Zapier was doing, and why it had to go

For a single Zap whose entire job was "watch one mailbox, parse a subject line, forward an attachment," the cost was around $100 a month — not because the task itself was expensive to run, but because that's what the plan tier covering this volume of automation runs. The actual compute behind it — an inbox trigger, one parsing step, one webhook — is trivial. The price wasn't for the work; it was for the convenience of not having to write and own it.

That trade-off is fine right up until it isn't. A monthly bill for something this small is easy to justify when you have bigger problems to solve. It stops being easy to justify once you can describe the entire logic in a single sentence, once you hit a task-volume ceiling that forces a plan upgrade, or once a Zap fails silently and you're debugging a third-party UI instead of your own logs. At that point the question isn't "is Zapier worth $100 a month" — it's "why are we paying anything at all to run six lines of parsing logic."

The insight: this isn't automation, it's translation

Zapier is built for a general problem: connect any app to any other app, for people who don't want to write code. That's genuinely valuable when the integration is fuzzy, changes often, or needs a non-engineer to maintain it. This wasn't that. This was one deterministic translation step: an email arrived → here is a normalized file, named the way our pipeline expects, in our own bucket. No branching logic, no third-party app connectors, no visual workflow to maintain. Just a parse and a copy.

A generic automation tool is the wrong shape for a problem that specific. The replacement needed to be exactly as small as the problem — which turned out to be one AWS Lambda function, not a project.

The architecture

The Zap was replaced with a single-purpose Lambda sitting in front of the CV-ingestion pipeline the platform already runs elsewhere — the same queue-worker → jobs-service → AI-parsing flow covered in more depth in one of my case studies. End to end, a candidate's application now travels through the system like this:

Architecture diagram: LinkedIn email flows through SES, S3, and a new Lambda, then into the existing SQS-based CV ingestion pipeline New — built for this migration Existing — untouched LinkedIn Easy Apply email Recruiter inbox: linkedin-easyapply-emails Amazon SES Receives mail, stores raw MIME in S3 S3 — raw email bucket RAW_EMAIL_BUCKET Lambda — linkedin-email-lambda Parses subject line, extracts CV attachment S3 — CV queue bucket cv-queue-uploads S3 event notification → SQS SQS_CV_QUEUE queue-worker → jobs service POST /cv/uploadfromqueue OpenAI parsing → candidate created JobUser application row, CV moved to permanent storage
Everything above the Lambda is new. Everything below the CV queue bucket is the pre-existing CV ingestion pipeline, completely unmodified by this migration.
The Lambda's only job is to be the thing Zapier used to be: a translator between "an email arrived" and "here's a normalized file in our own bucket, named the way our pipeline expects." Everything downstream — the queue worker, the jobs service, the OpenAI parsing, the candidate matching — already existed and didn't change at all. That's what made this a clean, low-risk swap rather than a rebuild.

Inside the Lambda

The whole thing is linkedin-email-lambda/src/index.js — 71 lines, no framework, two dependencies (@aws-sdk/client-s3 and mailparser):

import { S3Client, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { simpleParser } from "mailparser";

const s3 = new S3Client({ region: process.env.AWS_REGION || "us-east-1" });

const RAW_EMAIL_BUCKET = process.env.RAW_EMAIL_BUCKET;
const RAW_EMAIL_PREFIX = process.env.RAW_EMAIL_PREFIX || "";
const CV_QUEUE_BUCKET = process.env.CV_QUEUE_BUCKET;

const CV_EXTENSIONS = ["pdf", "doc", "docx"];
// "New application: Financial Manager (1928) from Alpheus Themane"
const SUBJECT_PATTERN = /\((\d+)\)\s+from\s+(.+)$/i;

export const handler = async (event) => {
  const sesRecord = event.Records?.[0]?.ses;
  if (!sesRecord) {
    console.error("Not an SES event, skipping:", JSON.stringify(event));
    return;
  }

  const messageId = sesRecord.mail.messageId;
  const rawKey = `${RAW_EMAIL_PREFIX}${messageId}`;

  const rawObject = await s3.send(
    new GetObjectCommand({ Bucket: RAW_EMAIL_BUCKET, Key: rawKey })
  );
  const rawBuffer = await streamToBuffer(rawObject.Body);
  const parsed = await simpleParser(rawBuffer);

  const subject = parsed.subject || "";
  const match = subject.match(SUBJECT_PATTERN);
  if (!match) {
    console.error(`Could not extract job_id from subject "${subject}" (messageId: ${messageId})`);
    return;
  }
  const jobId = match[1];
  const applicantName = match[2].trim();

  const cvAttachment =
    parsed.attachments.find((a) =>
      CV_EXTENSIONS.includes(a.filename?.split(".").pop()?.toLowerCase())
    ) || parsed.attachments[0];

  if (!cvAttachment) {
    console.error(`No CV attachment found for subject "${subject}" (messageId: ${messageId})`);
    return;
  }

  const extension = cvAttachment.filename.split(".").pop();
  const destinationKey = `${jobId} - ${applicantName}.${extension}`;

  await s3.send(
    new PutObjectCommand({
      Bucket: CV_QUEUE_BUCKET,
      Key: destinationKey,
      Body: cvAttachment.content,
      ContentType: cvAttachment.contentType,
    })
  );

  console.log(`Uploaded CV to ${CV_QUEUE_BUCKET}/${destinationKey} (messageId: ${messageId})`);
};

Reading the raw email from S3

The handler doesn't get the email body directly. SES event triggers only hand Lambda a reference — a messageId — not the MIME content itself. That's the standard SES → S3 → Lambda receipt pattern: SES stores the raw email in S3 first, and the Lambda does a GetObject against RAW_EMAIL_BUCKET before it can parse anything. It's also why RAW_EMAIL_PREFIX exists as a config knob — SES rule sets can write incoming mail into a prefixed path within that bucket.

Extracting the job ID and applicant name

The "API" here is a regular expression run against the subject line:

/\((\d+)\)\s+from\s+(.+)$/i

It pulls the job ID out of the parentheses and the applicant name out of the trailing "from X". This is brittle by nature — if LinkedIn ever changes the wording of its notification subject, this breaks silently. That's a fair criticism, but it's exactly the same fragility Zapier's own email parser had. The difference isn't that the new version is more robust; it's that the parsing logic is now version-controlled, testable, and one git blame away from a fix, instead of buried in a third-party UI you don't control and can't diff.

Selecting the right attachment

Attachment selection has a fallback built in. It prefers a pdf, doc, or docx attachment, but falls back to the first attachment if none match those extensions — so the pipeline never fails silently just because a candidate's CV came through as a .rtf or something unexpected.

The filename is the contract

The most deliberate line in the whole function is this one:

const destinationKey = `${jobId} - ${applicantName}.${extension}`;

Encoding the job ID and applicant name directly into the filename means the file itself carries the metadata the downstream queue worker needs — no separate message payload, no extra database write, no new API contract to design. On the consuming side, the jobs service simply splits the filename on " - " to recover the job ID before doing anything else with it.

That's the one coupling point between the new Lambda and the existing system — and it's precisely what kept the blast radius of this migration to a single new service. Nothing in the queue worker or the jobs service needed to change; they simply keep reading filenames the way they always have.

What happens after the handoff

Once the CV lands in the CV queue bucket, the pre-existing pipeline takes over completely unmodified:

  • An S3 event notification pushes the object-created event onto an SQS queue.
  • queue-worker's CV upload handler picks it up and forwards { bucket, fileName } to the jobs service via POST /cv/uploadfromqueue.
  • The jobs service extracts text from the CV, sends it to OpenAI for structured extraction (name, email, experience, and so on), matches or creates the candidate record, creates the application row, moves the CV into permanent storage, increments the job's application counter, and fires off a confirmation email and a log event.

This is the part worth sitting with: the migration only ever touched the "email arrives → file lands in our bucket" step. Everything after that boundary was already ours, already battle-tested in production, and untouched — which is exactly why the risk was low enough to ship this as a single self-contained Lambda rather than plan it as a project.

Results: cost and operational control

A recurring $100-a-month vendor bill for one Zap became a pipeline that costs low single-digit dollars a month to run — and that spend is driven almost entirely by S3 storage of raw emails, not compute.

The AWS side of the replacement, roughly:

  • SES inbound receiving — $0.10 per 1,000 emails.
  • S3 storage for raw emails and queued CVs — fractions of a cent per GB.
  • Lambda invocations at this volume sit comfortably inside the always-free tier (1M requests/month free).
  • SQS is likewise negligible, well below its free-tier threshold.

Beyond the raw cost difference, the operational win is arguably bigger:

  • The whole thing lives in the same repo as every other service, deployed the same way.
  • Failures show up as real logs in CloudWatch, not a Zap run-history UI you have to go check separately.
  • There's no third party that can change rate limits, reprice a task tier, or deprecate a connector this pipeline quietly depends on.

Lessons: owning brittle logic beats hiding it

The regex parsing a LinkedIn subject line will always be a little fragile — that's a property of scraping structure out of a notification email, not a property of the tool doing the scraping. Zapier didn't solve that fragility; it just moved it somewhere you couldn't see it. The value in this migration wasn't "more robust parsing." It was moving a deterministic, easily-described piece of logic out of a general-purpose automation tool and into a small, version-controlled, testable service that costs a fraction of the price and fails in ways you can actually observe and fix in minutes — not a support ticket.

Not every Zapier workflow deserves this treatment. The ones that do share a pattern: a single deterministic translation step, a stable enough interface to code against, and a cost or reliability ceiling that's starting to bite. When those three line up, the replacement is usually smaller than it looks — in this case, 71 lines and two dependencies.