Job seekers send out dozens of applications and hear nothing back. Many of them are sure “the ATS rejected me,” so they’ll pay someone to make their CV “ATS-friendly.” Search Shopee, Tokopedia or Instagram for “jasa CV ATS” and you’ll find hundreds of sellers. Search Fiverr for “ATS resume” and you’ll find thousands.

Most of those sellers do the same thing: move the CV into a plain one-column template and fix the headings. Few of them do the part that actually helps: rewriting the CV for one specific job posting, so the wording matches what the recruiter is searching for, without making anything up.

That’s the service in this guide. A Python script does the heavy lifting with Gemini, at well under a cent per CV. It also checks the output for invented numbers, employers and certifications before you send anything. You do the quality check and talk to the client.

In this guide:

  • what “ATS-friendly” means in practice, and the myths you shouldn’t sell
  • market prices in Indonesia and on Fiverr
  • the full script: CV + job post in, tailored DOCX/PDF and a client question list out
  • costs, realistic earnings, and the risks: platform bans, refunds, privacy law, and the line between “optimizing” and forging documents

What “ATS-friendly” actually means

An Applicant Tracking System (Workday, Greenhouse, Lever, Taleo, SmartRecruiters, and the systems behind portals like JobStreet or Kalibrr) does two things to a CV:

  1. Parses it into fields: name, contact details, jobs, dates, education, skills. Two-column layouts, text boxes, tables, icons and text placed in headers or footers often come out garbled or missing.
  2. Lets recruiters search and filter the parsed text. A recruiter types “SQL” or “Google Ads” or “IFRS”. If your CV says “database queries” instead of “SQL,” you don’t come up in the search.

Most ATS don’t auto-reject CVs by giving them a “score.” Some do have knockout questions (work permit, minimum years, location). In practice, the CV loses because a human never finds it, or skims it in seconds and sees nothing that matches the posting.

So what you’re really selling is:

  • a clean, parseable file: single column, standard headings, real text (not an image), DOCX or text-based PDF
  • wording that mirrors the job posting, wherever the candidate’s real experience supports it
  • a list of gaps: keywords the posting asks for that the CV can’t truthfully claim, so the client can tell you whether they actually have that skill

That third point is what sets you apart from sellers who just swap templates. It’s also what keeps you on the right side of the law (more on that below).

What it pays (October 2026)

Prices change, so do your own 10-minute survey before you set yours (Step 1). These are the ranges we saw when checking listings:

ChannelTypical priceWhat buyers get
Shopee / Tokopedia “jasa CV ATS”~Rp20.000–Rp75.000Reformat into an ATS template, 1–2 day turnaround
Instagram / TikTok sellers, bundled~Rp100.000–Rp250.000CV + LinkedIn profile rewrite + cover letter
Fiverr “ATS resume” (new and mid-level sellers)~$15–$60Rewrite + DOCX/PDF, 1–3 revisions
Fiverr, established resume writers$100+Executive CVs, interview-style intake call

Fiverr keeps 20% of every order (including tips), and new sellers wait about 14 days before earnings clear. Shopee and Tokopedia charge admin and service fees that depend on your seller type and category. Check Seller Centre for current rates.

How to position yourself: the cheap end is crowded with template swaps. Price per job posting. For example: “1 CV tailored to 1 job: Rp75.000. Extra job posting: Rp35.000.” A job seeker applying to 10 companies is then a Rp390.000 order, and each extra version costs you about two minutes of review.

What you need

ItemCostNotes
Python 3.10+$0Any laptop
google-genai, pydantic, python-docx, pypdf$0pip install
Gemini API key$0 to startaistudio.google.com. The free tier works for testing. Use the paid tier for client data (see Risks)
LibreOffice$0For DOCX → PDF on the command line
A sales channel$0 / 20%WhatsApp + QRIS, a Shopee/Tokopedia listing, or Fiverr
A Google Form for intake$0CV upload, job posting link or text, phone number, consent checkbox

Gemini pricing (check ai.google.dev/pricing before relying on these numbers): this guide uses Gemini 3.5 Flash-Lite at $0.30 per 1M input tokens and $2.50 per 1M output tokens on the paid tier. A two-page CV plus a job posting is about 2,000–4,000 input tokens. The structured output is about 1,000–2,000 tokens. That’s roughly $0.003–$0.007 per tailored CV, a bit more if the model spends tokens on thinking (the script counts those too).

Step 1: Survey your market (10 minutes)

Before you write any code:

  1. Search “jasa CV ATS” on Shopee and Tokopedia and sort by best-selling. Note the price, the sales count and what’s included for the top 10.
  2. Do the same on Fiverr for “ATS resume.” Note the Level 1/Level 2 sellers, not just the top-rated ones.
  3. Read the 1–3 star reviews. The complaints repeat: “it’s just a template,” “they added experience I don’t have,” “slow revision.” Those complaints are your sales copy.

Step 2: Install

mkdir cvtailor && cd cvtailor
python3 -m venv .venv && source .venv/bin/activate
pip install google-genai pydantic python-docx pypdf

export GEMINI_API_KEY="paste-your-key-here"
sudo apt install -y libreoffice-writer poppler-utils   # macOS: brew install --cask libreoffice && brew install poppler
# macOS: soffice isn't on PATH by default; use /Applications/LibreOffice.app/Contents/MacOS/soffice

Step 3: The script

Save this as cv_tailor.py:

import argparse, os, re, sys
from pathlib import Path

from docx import Document
from docx.shared import Pt
from google import genai
from google.genai import types
from pydantic import BaseModel
from pypdf import PdfReader

MODEL = os.environ.get("CV_MODEL", "gemini-3.5-flash-lite")
PRICE_IN, PRICE_OUT = 0.30, 2.50   # USD per 1M tokens, paid tier

class Job(BaseModel):
    title: str
    company: str
    location: str
    dates: str
    bullets: list[str]

class Education(BaseModel):
    school: str
    degree: str
    dates: str

class CV(BaseModel):
    name: str
    contact: str
    summary: str
    skills: list[str]
    experience: list[Job]
    education: list[Education]
    certifications: list[str]
    job_keywords: list[str]
    missing_keywords: list[str]

PROMPT = """You rewrite CVs so they match one job posting.

HARD RULES:
- Use ONLY facts found in the ORIGINAL CV. Never add employers, job titles, dates,
  degrees, schools, certifications, tools or numbers that are not in it.
- You may reorder, shorten, merge, drop and rephrase. Put the most relevant
  experience and skills first.
- Where the CV truthfully supports it, use the exact wording of the job posting
  (e.g. write "SQL" if the posting says SQL and the CV mentions writing queries).
- Bullets: start with a verb, max 25 words, keep any numbers exactly as written.
- job_keywords: the 15-25 skills, tools and qualifications the posting asks for,
  in the posting's own wording.
- missing_keywords: items from job_keywords that the CV does NOT support.
- Write the CV in {lang}.

JOB POSTING:
{job}

ORIGINAL CV:
{cv}
"""

def read_text(path):
    p = Path(path)
    if p.suffix.lower() == ".pdf":
        return "\n".join(page.extract_text() or "" for page in PdfReader(p).pages)
    if p.suffix.lower() == ".docx":
        d = Document(p)
        cells = [c.text for t in d.tables for r in t.rows for c in r.cells]
        return "\n".join([para.text for para in d.paragraphs] + cells)
    return p.read_text(encoding="utf-8")

def flatten(cv):
    parts = [cv.name, cv.contact, cv.summary, " ".join(cv.skills), " ".join(cv.certifications)]
    for j in cv.experience:
        parts += [j.title, j.company, j.location, j.dates, " ".join(j.bullets)]
    for e in cv.education:
        parts += [e.school, e.degree, e.dates]
    return "\n".join(parts)

def numbers(text):
    return {n.strip(".,") for n in re.findall(r"\d[\d.,]*%?", text)}

def check(source, cv):
    flags, low = [], source.lower()
    for n in sorted(numbers(flatten(cv)) - numbers(source)):
        flags.append(f"number not in original CV: {n}")
    names = [j.company for j in cv.experience] + [e.school for e in cv.education] + cv.certifications
    for name in names:
        if name and name.lower() not in low:
            flags.append(f"name not found verbatim in original CV: {name}")
    return flags

def coverage(text, keywords):
    if not keywords:
        return 0.0
    low = text.lower()
    return sum(k.lower() in low for k in keywords) / len(keywords)

def render(cv, out):
    doc = Document()
    doc.styles["Normal"].font.name = "Calibri"
    doc.styles["Normal"].font.size = Pt(11)
    doc.add_heading(cv.name, level=0)
    doc.add_paragraph(cv.contact)
    doc.add_heading("Summary", level=1)
    doc.add_paragraph(cv.summary)
    doc.add_heading("Skills", level=1)
    doc.add_paragraph(", ".join(cv.skills))
    doc.add_heading("Experience", level=1)
    for j in cv.experience:
        doc.add_paragraph().add_run(f"{j.title}, {j.company}").bold = True
        doc.add_paragraph(f"{j.location} | {j.dates}")
        for b in j.bullets:
            doc.add_paragraph(b, style="List Bullet")
    doc.add_heading("Education", level=1)
    for e in cv.education:
        doc.add_paragraph(f"{e.degree}, {e.school} ({e.dates})")
    if cv.certifications:
        doc.add_heading("Certifications", level=1)
        for c in cv.certifications:
            doc.add_paragraph(c, style="List Bullet")
    doc.save(out)

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("cv")
    ap.add_argument("job")
    ap.add_argument("--out", default="tailored.docx")
    ap.add_argument("--lang", default="the same language as the job posting")
    a = ap.parse_args()

    source, job = read_text(a.cv), read_text(a.job)
    if len(source.strip()) < 200:
        sys.exit("CV text is almost empty. Scanned PDF? Ask the client for a DOCX or run OCR first.")

    client = genai.Client()
    resp = client.models.generate_content(
        model=MODEL,
        contents=PROMPT.format(lang=a.lang, job=job, cv=source),
        config=types.GenerateContentConfig(
            response_mime_type="application/json", response_schema=CV, temperature=0.3),
    )
    cv = resp.parsed
    render(cv, a.out)

    u = resp.usage_metadata
    out_tokens = (u.candidates_token_count or 0) + (u.thoughts_token_count or 0)
    cost = ((u.prompt_token_count or 0) * PRICE_IN + out_tokens * PRICE_OUT) / 1e6
    flags = check(source, cv)

    report = [
        f"Keyword coverage: {coverage(source, cv.job_keywords):.0%} before, "
        f"{coverage(flatten(cv), cv.job_keywords):.0%} after",
        "", "ASK THE CLIENT (do you really have these?):",
        *[f"  - {k}" for k in cv.missing_keywords],
        "", "CHECK BEFORE SENDING:",
        *([f"  ! {f}" for f in flags] or ["  (no flags)"]),
        "", f"API cost: ${cost:.4f}",
    ]
    Path(a.out).with_suffix(".report.txt").write_text("\n".join(report), encoding="utf-8")
    print("\n".join(report))

if __name__ == "__main__":
    main()

What it does:

  • Reads PDF, DOCX or plain text. If a “PDF” is really a photo of a CV, it stops and tells you, instead of sending empty text to the model.
  • Asks Gemini for structured JSON that matches the CV schema, so you never have to parse free-form text.
  • Checks for fabrication. Any number in the output that isn’t in the original CV gets flagged. So does any company, school or certification name that doesn’t appear word for word in the original. Models sometimes “improve” a bullet by inventing “increased sales by 30%”. This check catches that.
  • Measures keyword coverage before and after, which is a simple, honest number to show the client.
  • Writes a .report.txt with the gap questions for the client.

Step 4: Run an order

Save the job posting text as job.txt (copy it from JobStreet, LinkedIn, Glints or the company site), then:

python cv_tailor.py client_cv.pdf job.txt --out andi_tokopedia_data_analyst.docx
soffice --headless --convert-to pdf andi_tokopedia_data_analyst.docx

Example report:

Keyword coverage: 35% before, 70% after

ASK THE CLIENT (do you really have these?):
  - Looker Studio
  - A/B testing
  - stakeholder management

CHECK BEFORE SENDING:
  ! name not found verbatim in original CV: Universitas Gadjah Mada

API cost: $0.0041

That flag isn’t an error. The original said “UGM,” and the model wrote out the full name. Every flag needs a human look, though, and most take ten seconds.

Then message the client: “Your CV is ready. Three things in the posting aren’t in your CV: Looker Studio, A/B testing, stakeholder management. If you’ve done any of them, even in a class project or an organization, tell me what you did and I’ll add it.” Clients love this. It feels like a consultation, and it’s your natural upsell.

For multi-job orders, put one posting per file in a jobs/ folder:

mkdir -p out
for j in jobs/*.txt; do
  python cv_tailor.py client_cv.pdf "$j" --out "out/$(basename "$j" .txt).docx"
done
soffice --headless --convert-to pdf --outdir out out/*.docx

Step 5: Your human pass (5–15 minutes per CV)

Don’t skip this. It’s what the client is paying for.

  1. Read every flag in the report and fix or confirm it.
  2. Open the PDF. Is it two pages or less? Are the dates consistent? Is the most recent job first?
  3. Parse test: paste the PDF text into a plain text editor (or run pdftotext out/file.pdf -, from the poppler-utils package). If the text comes out in the wrong order, the ATS will read it that way too.
  4. Make sure the summary doesn’t claim a seniority level the candidate doesn’t have.

Costs and realistic earnings

Monthly costs at 60 tailored CVs (for example, 20 clients × 3 postings each):

ItemCost
Gemini API, 60 runs incl. retries< $0.50
LibreOffice, Python, Google Forms$0
Fiverr fee (if you sell there)20% of revenue
Your time20 × ~25 min intake + review, 40 extra versions × ~5 min ≈ 11–12 hours

Realistic revenue at the prices above:

ScenarioOrders/monthAvg orderRevenueNotes
WhatsApp, friends + campus groups, month 15–10Rp100.000Rp500k–1jtMostly word of mouth
Shopee/Tokopedia listing, months 2–320–40Rp75.000Rp1,5–3jtFees take a few %
Fiverr, after first 10 reviews10–20$30$300–600 gross, $240–480 netFirst month is often 0–3 orders

Expect demand to spike around graduation season, at the start of the year (after annual bonuses, when people switch jobs) and after big layoff news. It drops during Ramadan and the end-of-year holidays.

Risks: the honest list

1. Fabrication: this is where it becomes illegal. Rewording real experience is fine. Adding a job the client never had, inflating a title, or putting a degree or certificate they don’t hold on the CV is not “beating the ATS.” Making or using a forged diploma or certificate can be prosecuted under Indonesia’s document-forgery article: Pasal 391 of the new KUHP (UU No. 1 Tahun 2023, in force since 2 January 2026; it replaced the old Pasal 263), with up to 6 years in prison, and in any country a fabricated CV is grounds for being fired once it’s discovered, even years later. Never create fake certificates, reference letters or “work experience” from a friend’s company. If a client asks, decline. The fabrication check in the script is there to protect you.

2. Personal data. A CV holds a name, phone number, address, sometimes a date of birth, ID number or photo. Indonesia’s Personal Data Protection Law (UU No. 27 Tahun 2022, fully in force since October 2024) applies to you as a data processor. In practice: put a consent checkbox on your intake form, delete client files after 30 days, don’t post “before/after” examples without permission and anonymization, and use the paid Gemini tier for client data. Google’s terms say free-tier prompts may be used to improve its products. The paid tier is not used that way.

3. Platform bans. Fiverr bans sellers who ask for payment or contact outside the platform. Don’t put your WhatsApp number in a Fiverr delivery. Repeated cancellations and late deliveries also push your account down fast. On Shopee/Tokopedia, a digital service listing has to follow their rules for “jasa” products. Read the category rules before you list.

4. Refunds and “no callbacks” complaints. You can’t promise interviews. Nobody can, and promising them invites refund disputes. Sell what you can control: “tailored to the posting, ATS-parseable format, gap questions, 1 revision.” Put “no guarantee of interviews” in the listing.

5. The grey-area upsells to avoid. You’ll get asked for these:

  • White-text keyword stuffing (invisible keywords in the margins). Modern ATS show recruiters the parsed text, so the hidden words show up as a block of nonsense. This makes candidates look dishonest, not qualified.
  • Auto-apply bots that mass-submit on LinkedIn or JobStreet for the client. LinkedIn’s User Agreement prohibits bots and automated access, and the client’s account, not yours, gets restricted. If you offer it anyway, the client has to understand that risk in writing. We don’t recommend it.
  • Writing the client’s online assessment or test answers. That’s cheating on a hiring process. Don’t do it.

Reality check

What doesn’t work:

  • Selling “ATS score checks” on their own. Free tools already exist (Jobscan has a free tier, and many job boards show a match score). Use a score to support the rewrite, not as the product.
  • Running the script and sending the output unchecked. The first client who finds an invented percentage on their CV will leave a 1-star review, and they’d be right.
  • Competing on price with Rp20.000 template swaps. You’ll lose, and you’ll burn out. Compete on “tailored to the job, and I ask you about the gaps.”
  • Scanned CVs. A photo of a printed CV makes pypdf return nothing. Ask for the DOCX, or run ocrmypdf scan.pdf scan_ocr.pdf first and check the text.

Common mistakes:

  • Asking for the CV but not the job posting. Without it, you’re just reformatting. Make the posting a required field on your intake form.
  • Translating into English for jobs posted in Bahasa Indonesia, or the other way around. The script uses the posting’s language by default. Override with --lang English only when the client asks.
  • Dropping the client’s older jobs entirely. Shorten them to one line. Gaps in a timeline raise more questions than an old irrelevant job.
  • Forgetting LinkedIn. If the CV says “Data Analyst” and the LinkedIn headline says “Fresh Graduate,” the recruiter notices. Offer a headline + About rewrite as a Rp50.000 add-on. It’s the same script with a different prompt.

Start today

  1. Spend 10 minutes on the price survey (Step 1).
  2. Install the script and run it on your own CV against three real postings. Read the report and fix what’s wrong in the prompt.
  3. Do three free or Rp25.000 orders for friends or a campus group in exchange for a testimonial and permission to show an anonymized before/after.
  4. List the service at a per-posting price on one channel (WhatsApp status + one marketplace is enough to start).

Your cost per CV is under a cent. What clients pay for is a person who reads the posting, asks the right questions, and refuses to make things up.