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 stage —
notification_typeis 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 ATS —
ats_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 company —
job_title(Senior Backend Engineer) andcompany_name(Acme Co), pluscompany_domainfor the sending or careers domain.company_nameis the employer, not the ATS vendor — a status email fromgreenhouse.iois about Acme Co, not about Greenhouse. - The posting —
job_posting_idis the vendor’s external job ID (Greenhouse’s numeric job ID, Lever’s UUID, Workday’sjobRequisitionId), andapplication_idis the ATS’s internal application/candidate ID. The latter is the dedup key across a candidate’s whole lifecycle. - The people —
recruiter_name,recruiter_email, andhiring_manager_namewhen the email names them. - The next thing — for a scheduled interview,
event_date(ISO 8601),event_location, andevent_location_url(the conferencing link).next_stepcaptures the free-text next-action description, andstatus_urllinks to the candidate’s application status page.
The fields worth extracting are roughly:
| Field | Why you want it |
|---|---|
notification_type | The lane-decider: offer vs interview vs rejection vs acknowledgement |
ats_provider | Reconcile against the source ATS; apply vendor-specific handling |
job_title / company_name | Group every email for one role at one employer |
company_domain | Disambiguate white-labeled careers domains |
job_posting_id | Join to the requisition in your own or the ATS’s system |
application_id | Dedup key across the application’s lifecycle |
recruiter_name + recruiter_email | Route replies and follow-ups to the right person |
event_date + event_location_url | Drop a scheduled interview straight onto a calendar |
next_step | Enough context to decide without opening the ATS |
status_url | A 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_type — offer_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_typeandats_providerare enum-constrained in the schema, aswitchover them is exhaustive and safe — an unexpected value would have shown up invalidation_errorsrather 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 tocompany_name+job_posting_id+job_titlewhen 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
greenhousefromleverand 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_idandjob_posting_idto 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.