Skip to content
jobs recruiting ats schema email-to-json tutorial

Parse Job Application Emails to JSON

Turn Greenhouse, Lever, Workday, and Ashby candidate-status emails into typed JSON: status, role, company, recruiter, posting ID, interview date, offer. Schema-first.

MailFrame Team

If you are hunting for a job, or building the tool that helps other people do it, your inbox fills up with candidate-status emails fast: an “application received” acknowledgement from Greenhouse, a “phone screen scheduled” from Lever, an “onsite confirmed” from Ashby, an offer from Workday, a polite rejection from SmartRecruiters. Each applicant-tracking system writes its own copy, lays the fields out its own way, and buries the one thing you actually want to act on — what stage is this application at, and what happens next — somewhere between a greeting and a footer. Parsing that by hand with a pile of per-vendor regexes is the kind of code that works until an ATS ships a new email template.

This post shows how to turn those emails into typed, validated JSON you can route to an application tracker, a recruiting-analytics warehouse, or an offer pager — using a JSON Schema and a single POST to MailFrame’s /v1/parse endpoint. If you want the ready-made schema reference, it lives at Parse Job Application Emails; this post is the why-and-how around it.

Everything below uses the shipped, synchronous path: you send raw MIME, you get schema-validated JSON back in the same HTTP response. No inbox to configure, no callback to wait on. (For the reasoning behind the schema-first design, see Why We Built MailFrame Schema-First.)

What’s actually in an ATS notification email

The signal you want from a candidate-status email is not the friendly “we appreciate your interest” prose — it is a small, structured set of fields every ATS prints somewhere in the message:

  • The stagenotification_type is the single most useful field. application_received, screening_scheduled, interview_scheduled, interview_completed, offer_extended, offer_accepted, offer_declined, rejected, withdrawn. This is what decides the lane: an offer belongs in a very different queue than a rejection.
  • The ATSats_provider (greenhouse, lever, workday, ashby, smartrecruiters, icims, unknown). Knowing the source system lets you reconcile against that ATS’s API later, or apply vendor-specific handling where it matters.
  • The role and companyjob_title (Senior Backend Engineer) and company_name (Acme Co), plus company_domain for the sending or careers domain. company_name is the employer, not the ATS vendor — a status email from greenhouse.io is about Acme Co, not about Greenhouse.
  • The postingjob_posting_id is the vendor’s external job ID (Greenhouse’s numeric job ID, Lever’s UUID, Workday’s jobRequisitionId), and application_id is the ATS’s internal application/candidate ID. The latter is the dedup key across a candidate’s whole lifecycle.
  • The peoplerecruiter_name, recruiter_email, and hiring_manager_name when the email names them.
  • The next thing — for a scheduled interview, event_date (ISO 8601), event_location, and event_location_url (the conferencing link). next_step captures the free-text next-action description, and status_url links to the candidate’s application status page.

The fields worth extracting are roughly:

FieldWhy you want it
notification_typeThe lane-decider: offer vs interview vs rejection vs acknowledgement
ats_providerReconcile against the source ATS; apply vendor-specific handling
job_title / company_nameGroup every email for one role at one employer
company_domainDisambiguate white-labeled careers domains
job_posting_idJoin to the requisition in your own or the ATS’s system
application_idDedup key across the application’s lifecycle
recruiter_name + recruiter_emailRoute replies and follow-ups to the right person
event_date + event_location_urlDrop a scheduled interview straight onto a calendar
next_stepEnough context to decide without opening the ATS
status_urlA deep link to the live application status page

A JSON Schema for the extraction

MailFrame is schema-first: you declare the exact shape you want before anything is parsed, and every extraction is validated against that contract before it is returned.

Here is a compact schema for ATS candidate-status emails. The enum constraints on notification_type and ats_provider are not hints — they are hard constraints, so a value outside the list fails validation instead of silently landing in your tracker:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "job_application",
  "type": "object",
  "required": [
    "notification_type",
    "job_title",
    "company_name",
    "ats_provider"
  ],
  "properties": {
    "notification_type": {
      "type": "string",
      "enum": [
        "application_received",
        "screening_scheduled",
        "interview_scheduled",
        "interview_completed",
        "offer_extended",
        "offer_accepted",
        "offer_declined",
        "rejected",
        "withdrawn"
      ]
    },
    "application_id":      { "type": "string" },
    "job_title":          { "type": "string", "minLength": 1 },
    "company_name":       { "type": "string", "minLength": 1 },
    "company_domain":     { "type": "string", "format": "hostname" },
    "ats_provider": {
      "type": "string",
      "enum": ["greenhouse", "lever", "workday", "ashby", "smartrecruiters", "icims", "unknown"]
    },
    "job_posting_id":     { "type": "string" },
    "job_location":       { "type": "string" },
    "recruiter_name":     { "type": "string" },
    "recruiter_email":    { "type": "string", "format": "email" },
    "hiring_manager_name":{ "type": "string" },
    "event_date":         { "type": "string", "format": "date-time" },
    "event_location":     { "type": "string" },
    "event_location_url": { "type": "string", "format": "uri" },
    "next_step":          { "type": "string" },
    "status_url":         { "type": "string", "format": "uri" },
    "date":               { "type": "string", "format": "date" }
  }
}

This is the same schema MailFrame ships pre-built under the id job_application, so you do not have to register it yourself — but copying it here is a good starting point if you want to tighten or extend it for your own routing logic (for example, require event_date on the interview stages, or add company-specific stage names).

Send the raw email to /v1/parse

MailFrame’s shipped path is direct and synchronous: you POST the raw RFC 822 MIME text of the email — exactly the bytes you received — along with a schema, and you get typed JSON back in the same HTTP response.

You can reference the pre-built schema by id (schema_id) or pass an inline JSON Schema. Here it is with curl, using the built-in job_application schema against a Greenhouse “application received” email:

curl -X POST https://api.mailframe.ai/v1/parse \
  -H "Authorization: Bearer ${MAILFRAME_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "schema_id": "job_application",
    "raw_mime": "From: Acme Co Recruiting <no-reply@greenhouse.io>\r\nTo: jordan.rivera@example.com\r\nSubject: We received your application for Senior Backend Engineer\r\nDate: Thu, 03 Jul 2026 09:12:00 -0400\r\n\r\nHi Jordan,\r\n\r\nThanks for applying to the Senior Backend Engineer role at Acme Co. Our recruiting team has received your application and will review it shortly.\r\n\r\nPosition:     Senior Backend Engineer\r\nLocation:     Remote (US)\r\nRequisition:  4025001\r\nRecruiter:    Priya Shah <priya.shah@acme.example>\r\n\r\nView application:\r\nhttps://boards.greenhouse.io/acme/jobs/4025001/status"
  }'

The response wraps the extracted, schema-valid data in a data object alongside the parse status and any validation errors:

{
  "id": "parse_5b1e77",
  "status": "completed",
  "validation_errors": [],
  "data": {
    "notification_type": "application_received",
    "application_id": "88472013",
    "job_title": "Senior Backend Engineer",
    "company_name": "Acme Co",
    "company_domain": "acme.example",
    "ats_provider": "greenhouse",
    "job_posting_id": "4025001",
    "job_location": "Remote (US)",
    "recruiter_name": "Priya Shah",
    "recruiter_email": "priya.shah@acme.example",
    "next_step": "Our recruiting team will review your application and be in touch about next steps.",
    "status_url": "https://boards.greenhouse.io/acme/jobs/4025001/status",
    "date": "2026-07-03"
  }
}

Validate, then route

Because the result is validated against your schema before it reaches you, your application code can stay small. Check validation_errors first, then branch on notification_typeoffer_extended and rejected are the two that almost always need to go to a different lane than the routine acknowledgements:

interface ParseResult {
  id: string;
  status: "completed" | "failed";
  validation_errors: unknown[];
  data: JobApplication;
}

interface JobApplication {
  notification_type:
    | "application_received"
    | "screening_scheduled"
    | "interview_scheduled"
    | "interview_completed"
    | "offer_extended"
    | "offer_accepted"
    | "offer_declined"
    | "rejected"
    | "withdrawn";
  application_id?: string;
  job_title: string;
  company_name: string;
  company_domain?: string;
  ats_provider:
    | "greenhouse"
    | "lever"
    | "workday"
    | "ashby"
    | "smartrecruiters"
    | "icims"
    | "unknown";
  job_posting_id?: string;
  job_location?: string;
  recruiter_name?: string;
  recruiter_email?: string;
  hiring_manager_name?: string;
  event_date?: string;
  event_location?: string;
  event_location_url?: string;
  next_step?: string;
  status_url?: string;
  date?: string;
}

async function processJobApplicationEmail(rawMime: string): Promise<void> {
  const res = await fetch("https://api.mailframe.ai/v1/parse", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MAILFRAME_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      schema_id: "job_application",
      raw_mime: rawMime,
    }),
  });

  const result: ParseResult = await res.json();

  // Validation ran server-side against your schema — surface failures, don't guess.
  if (result.status !== "completed" || result.validation_errors.length > 0) {
    await flagForReview(result);
    return;
  }

  const r = result.data;

  // Route by stage, not by ATS — every provider produces the same shape.
  switch (r.notification_type) {
    case "offer_extended":
      // High-signal: an offer is on the table. Page the candidate / approval flow.
      await notifyOffer(r);
      return;
    case "interview_scheduled":
    case "screening_scheduled":
      // Carries event_date + event_location_url — drop it onto the calendar.
      await scheduleInterview(r);
      return;
    case "rejected":
    case "offer_declined":
    case "withdrawn":
      // Terminal states. Close out the application row.
      await closeApplication(r);
      return;
    case "application_received":
    case "interview_completed":
    case "offer_accepted":
    default:
      // Routine stage change — advance the tracker, no alert.
      await advanceStage(r);
  }
}

A few practical notes:

  • Trust the types, not the strings. Because notification_type and ats_provider are enum-constrained in the schema, a switch over them is exhaustive and safe — an unexpected value would have shown up in validation_errors rather than reaching this code.
  • Dedup on application_id + event_date. An ATS sends a separate email per stage transition; grouping on the internal application ID (falling back to company_name + job_posting_id + job_title when it is absent) keeps one application from spawning a duplicate row per email.
  • Keep the raw MIME intact. Don’t pre-strip headers before sending — the sender domain, the subject line, and the requisition footer are the signal the extractor leans on to tell greenhouse from lever and an interview from an offer. Send the message as you received it.
  • Reconcile, don’t replace. The email is the fastest signal that a stage changed, but it is not the system of record — use application_id and job_posting_id to reconcile against the ATS’s own API when you need the authoritative state.

Where this fits in your pipeline

The example above POSTs from wherever you already hold the message — an IMAP poller on a candidate’s inbox, an inbound-email webhook on a shared recruiting alias, a Lambda reading from S3. That keeps ingestion in your own code and under your own retries, which is the model we generally recommend; the trade-offs are laid out in How to Choose an Email Parsing API and the broader email-to-JSON API overview.

Candidate-status emails are usually one lane of a broader developer-mail pipeline, so most teams wire this schema up alongside the other pre-built parsers — Parse Linear Notifications and Parse GitHub Notification Emails route product and code activity to a triage queue, while job-application emails feed the recruiting/ATS path — an application tracker, a “what’s in flight” digest, or a recruiting-analytics warehouse. Browse the rest of the schema library for the full list of pre-built parsers.

For teams that would rather receive results asynchronously, signed webhook delivery (HMAC-SHA256, with retries and delivery history) is available in early access — see Designing Webhooks That Don’t Break for how to verify and handle those payloads. Forwarding a recruiting inbox to a unique MailFrame inbox address, instead of POSTing it yourself, is on the roadmap; until it ships, the direct POST shown here is the supported path.

The full endpoint reference — request and response shapes, error handling, and language samples — lives in the API docs. If you want to try the job-application schema against your own candidate-status emails, request early access and we’ll help you wire it up.

Turn your inbox into an API

MailFrame parses raw email into typed JSON against your own schema — PDF and image input are planned. Request early developer access.

Request early access