Resume parser for Salesforce: parse CVs into records with Apex
Published
A resume parser for Salesforce is a small piece of Apex that takes a CV attached to a record, sends the file to a parsing API, and writes the fields it gets back onto that record. Salesforce does not parse resumes on its own. If you run a recruiting app from AppExchange, check first whether it already parses on upload, because many do. If your candidates live in standard or custom objects you built yourself, the parse step is yours to add, and it is about a hundred lines of code.
This guide is for the Salesforce developer or admin asked to stop recruiters retyping CVs into a Contact or a custom Candidate__c object. It covers where the file comes from, how to call the parser, which governor limits decide the design, and how to keep a bad file from turning into a blank record.
How the pieces fit
Every working setup has the same four parts:
- The file. A CV uploaded to a record lands as a
ContentVersion, and its bytes are in theVersionDatafield. - The trigger. Something notices the upload: a trigger on
ContentDocumentLink, a record-triggered Flow, or a button a recruiter presses. - The callout. Apex sends the bytes to a resume parsing API over HTTPS and reads JSON back.
- The write. Apex maps the parsed fields onto the candidate record and saves it.
The callout is the part that needs care, because Salesforce limits what a callout may do and when.
The Salesforce limits that decide the design
These figures are from Salesforce's own Apex documentation as of October 2026.
| Limit | Synchronous Apex | Asynchronous Apex |
|---|---|---|
| Callout request or response size | 6 MB | 12 MB |
| Total heap size | 10 MB | 25 MB |
| Callouts per transaction | 100 | 100 |
| Cumulative callout timeout per transaction | 120 seconds | 120 seconds |
| Default timeout of one callout | 10 seconds | 10 seconds |
Three rules follow from that table and from the callout documentation.
- You cannot call out from inside a trigger's own transaction once it has saved anything. Salesforce's documentation says it plainly:
You can't make a callout when there are pending operations in the same transaction.
DML statements count as pending operations. So the trigger hands the work to a Queueable job, and the job makes the callout before it writes.
- Run the parse asynchronously. A Queueable gets the 12 MB callout ceiling and the 25 MB heap instead of 6 MB and 10 MB. The file sits in the heap as a Blob and again inside the request, so the larger heap matters for a long PDF.
- Raise the timeout from its default. Ten seconds is enough for most parses, but a scanned CV read as images takes longer than a text PDF. Set it explicitly, for example to 60 seconds, and stay inside the 120 second total.
A CV larger than the callout ceiling cannot be sent from Apex at all, even if the parser itself accepts it. Check ContentSize first and route an oversized file to a person.
Step 1: let Salesforce reach the parser
Salesforce blocks callouts to hosts it has not been told about. Create a Named Credential for the parser's host, so the URL and the API key live in setup instead of in code. With ResumeJSON the host is resumejson-resume-cv-parser-api.p.rapidapi.com and the key comes from the RapidAPI listing, sent in the x-rapidapi-key header. A Named Credential with a custom header, or an External Credential holding the key, keeps it out of the Apex class and out of source control.
If you only want to see what comes back before you build anything, the free parser takes one file with no signup and shows the same JSON.
Step 2: send the file as the raw body
Apex has no convenient multipart builder, and you do not need one. ResumeJSON accepts the file bytes as the raw request body and detects PDF, DOCX and image types from the bytes themselves, so setBodyAsBlob is the whole upload:
public class ResumeParseJob implements Queueable, Database.AllowsCallouts {
private final Id versionId;
private final Id candidateId;
public ResumeParseJob(Id versionId, Id candidateId) {
this.versionId = versionId;
this.candidateId = candidateId;
}
public void execute(QueueableContext ctx) {
ContentVersion cv = [
SELECT VersionData, FileExtension, ContentSize
FROM ContentVersion WHERE Id = :versionId
];
Candidate__c cand = new Candidate__c(Id = candidateId);
if (cv.ContentSize > 11 * 1024 * 1024) {
cand.Parse_Status__c = 'Too large for Apex';
update cand;
return;
}
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:ResumeJSON/v1/parse');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/octet-stream');
req.setBodyAsBlob(cv.VersionData);
req.setTimeout(60000);
HttpResponse res = new Http().send(req);
// the callout is done, so writing is allowed from here on
ResumeMapper.apply(cand, res);
update cand;
}
}The ceiling in the size check sits a little under 12 MB because the request carries headers as well as the file. A trigger on ContentDocumentLink finds the latest ContentVersion for each new link whose LinkedEntityId is a candidate, and enqueues one job per file with System.enqueueJob.
Step 3: map the JSON onto the record
The response is JSON with a resume object in a fixed, documented shape, so the mapping is written once:
| Parsed field | Example Salesforce field | Note |
|---|---|---|
resume.basics.full_name | Name | null when the CV states no name |
resume.basics.email | Email__c | Use it for duplicate checks |
resume.basics.phone | Phone__c | As written on the CV |
resume.work[0].title | Current_Title__c | Check is_current before calling it current |
resume.skills | Skills__c (long text or multi-select) | An array of strings |
resume.total_years_experience | Years_Experience__c | A number, or null |
Parse it with JSON.deserializeUntyped, or generate an Apex class from a sample response and use JSON.deserialize for typed access. Either way, treat null as "the CV does not say" and leave the Salesforce field empty instead of writing a placeholder. A recruiter filtering on years of experience should never see a zero that means "unknown".
Store the whole JSON in a long text field as well. The fields you map today are the ones you search on; the rest of the CV, such as education, every past role and languages, stays available when someone asks for it next quarter.
Step 4: handle the failures where a recruiter will see them
Some uploads are not CVs: a cover letter on its own, a portfolio, a blank page. ResumeJSON's refusals are specific, so each one becomes a status the recruiter can act on:
| Status | Error code | What it means | Set Parse_Status__c to |
|---|---|---|---|
| 413 | too_large | The file is over the size limit | Too large |
| 415 | unsupported_type | Not a PDF, DOCX, text or image | Unsupported file |
| 422 | unreadable_file | The file could not be read | Unreadable, check by hand |
| 422 | not_a_resume | The document is not a CV | Not a CV |
| 200 | none | Parsed | Parsed |
Never retry a 4xx. It spells the same answer again, more slowly. Retry only what is transient, such as a callout timeout, and do it by enqueueing a fresh job with a counter so a stuck file stops after two or three tries. Put Parse_Status__c on the record page and in a list view, so a failed parse is something a recruiter sees beside the candidate, rather than a record that quietly looks like an empty CV.
Step 5: test it without calling out
Apex tests cannot make real callouts. Implement HttpCalloutMock, return a saved sample response for the happy path and one response per error code, and assert what lands on the record in each case. The refusal branches are where a quiet bug hides: a test that only covers the 200 will pass against code that writes a blank candidate for every cover letter.
Choosing how to parse inside Salesforce
| A recruiting app that parses | Your own Apex and a parsing API | Typing it in | |
|---|---|---|---|
| Setup | Install and configure | About a hundred lines plus tests | None |
| Works with your own objects | Only the app's | Yes | Yes |
| File types | Whatever the app supports | Whatever the API supports | Anything a person can read |
| Running cost | The app's licence | Each parse, on the API's plan | Recruiter time |
| Best when | You want a whole recruiting product | Your data model is already built | A handful of CVs a week |
If you are still choosing a parsing API, the CV parser API comparison covers what each option returns and costs. If you would rather build this outside Salesforce and push the results in, the n8n resume parser guide does the same job as a workflow.
When the manual way is enough
Skip the code when:
- a few CVs arrive a week, and one recruiter reads every one anyway;
- nobody filters or reports on the fields, so a typed record saves nobody time later;
- your recruiting app already parses on upload, and a second parser would only add a step.
In those cases a required file upload and a short checklist on the record page are enough. The point to automate is when typing records takes longer than finding candidates in them saves. Once it does, the candidate database guide covers which fields are worth keeping, and the parse step above fills them.
Try one CV first
Before writing the Queueable, run one real CV from your org through the free parser and look at the JSON. It shows which fields your CVs actually carry, and that is the mapping table for Step 3. The API docs list every field and the plans.