Changelog

This page tracks every shipping change to the JobTactics Public API. Entries are dated and grouped by category. Breaking changes are called out explicitly.

v2.7.0 — 2026-09-15

Added — operations accept a fileId directly

Six operations now take cvFileId and jobFileId: pass the id returned by files_upload instead of copying its extractedText into a text field.

OperationcvFileIdjobFileId
ats_audityesyes
cv_generateyesyes
coverLetter_generateyesyes
cvOptimization_runyesyes
jobOffer_analyzeyesyes
email_generate—yes

Passing the id rather than the text is not just shorter. The server verifies the file’s SHA-256 integrity before use, reconstructs a document that was uploaded as several pages, and refuses a job offer passed as a CV — all things you would otherwise have to reimplement on top of extractedText.

Failure is soft, except on ats_audit. A file that cannot be found, read, or that turns out to be the wrong kind is ignored, and the operation continues from your profileId or your text. You have already paid for the generation at that point, so a stale id never costs you the document. ats_audit is the exception: there the CV is the subject of the operation, so an unusable file is refused — before any credit is spent, with a stable code (not_a_cv, file_no_text, file_not_found…).

For cv_generate, a CV file takes precedence over the profile and is announced to the model as the source of truth, with the profile still available as fallback.

resignationLetter_generate is deliberately left out: it uses neither a CV nor an offer, only identity, employer and notice period. A fileId there would be a setting with no effect.

Added — POST /public/profiles/from-cv

Turns a CV file into structured profile data — identity, experiences, education, skills, languages, certifications and the additional sections. 1 credit. Accepts PDF, DOCX, plain text or a photo (PNG, JPEG, WebP), 10 MB max.

files_upload gives you raw text; this gives you fields. It is the missing link between a file and a usable profile.

It does not create a profile. The data is returned for you to review — creating the profile stays a deliberate act, so nothing is written to the account from a single automated call. The returned fileId refers to the stored CV, which stays reusable by the operations above.

A file that turns out not to be a CV is still processed, flagged with warning: "not_a_cv" — the extraction may well be useful, and refusing it outright would cost you the call.

Limits: 10 extractions per hour per API key.

This brings the surface to 33 operations, with the new one joining the existing profiles resource rather than adding another. The fourteen resources are: cv, cvOptimization, coverLetter, resignationLetter, ats, jobOffer, email, content, documents, profiles, credits, templates, settings, files.

v2.6.0 — 2026-09-15

Added — chain an optimization report into CV generation

cv_generate now accepts optimizationReportId: pass the id returned by cvOptimization_run and that report’s recommendations are applied to the CV.

POST /public/cv/optimize  → { id: "8f2c1b7e-…", currentScore: 62, potentialScore: 84 }
POST /public/cv/generate  → { profileId, optimizationReportId: "8f2c1b7e-…" }

Until now the two operations could not talk to each other. You could buy a report full of rewrite suggestions and a prioritised action plan, and nothing could apply it — cv_generate started from scratch every time. The recommendations were yours to retype by hand, or to lose.

The report is read server-side. You pass an id, never the recommendations themselves: they are loaded from your own report and injected into the generation. You cannot mistype them, and you cannot make the model apply something the report never said. The model is explicitly told to apply them without inventing facts, projects or figures to satisfy them.

A bad id never costs you the document. An unknown id, a mistyped one, a report belonging to another account, or one with nothing actionable in it is simply ignored and the CV is generated without it. You have already paid for the generation at that point — failing the whole call over a stale identifier would be the wrong trade.

The field is optional and additive: requests that do not use it behave exactly as before.

v2.5.0 — 2026-09-15

Added — files_upload accepts images, and the size limit doubles

POST /public/files now accepts PNG, JPEG and WebP alongside PDF, DOCX and plain text, and the ceiling moves from 5 MB to 10 MB. A photo of a CV taken on a phone now works.

The image restriction was never justified. It was borrowed from the anonymous audit on the marketing site, which is free and unauthenticated — while this endpoint requires a valid API key, costs a credit and is capped at 30 uploads an hour. The closer precedent was JobTactics Lite, which has accepted photos from day one, for a reason that applies just as well here: people photograph their CV. So does an integrator’s mobile app.

The 5 MB limit went with it: a phone photo routinely weighs 3 to 6 MB, so keeping it would have accepted images while rejecting the very case that motivates them.

Still 1 credit, whatever the format. Images are read by vision rather than parsed, which costs us more, but a single tariff keeps the contract predictable — you do not have to know how a file will be processed to know what it costs.

Legibility is what matters. A blurred, cropped or glare-heavy photo yields no text: it is rejected with file_no_text and refunded, like any unreadable upload. When you have the choice, a PDF or DOCX remains the more reliable input.

HEIC — the iPhone default — is not accepted yet: it needs verifying that it survives the whole pipeline before we bill for it.

v2.4.0 — 2026-09-15

Fixed — insufficient credits now returns 402, as documented

Every endpoint that consumes credits returned 400 Bad Request when your balance was too low, while this documentation had always announced 402 Payment Required. The code now matches the documentation.

A depleted balance is not a malformed request: there is nothing in your payload to fix, so 400 sent integrators looking for a validation error that did not exist.

The response body is unchanged — only the status code moves:

{
  "statusCode": 402,
  "message": "insufficient_credits",
  "timestamp": "2026-09-15T10:00:00.000Z"
}

If you branch on message === "insufficient_credits" (what the SDK and our own clients do), nothing changes for you. If you branch on status === 400 to detect a depleted balance, switch to 402 — jt.credits.balance() remains free to call.

The 402 example in the reference was also fictional: it promised an error field and a details object carrying creditsRequired and creditsAvailable, which no version of the API has ever returned. It now shows the real body.

Affected: all credit-consuming operations, including files_upload and the six content endpoints, whose 402 was documented but never actually returned.

v2.3.0 — 2026-09-15

Added — three operations that only existed inside the product

The Studio could optimize a CV, analyse a job offer and write an application email. The API could not. Three endpoints close that gap.

EndpointOperationCost
POST /public/cv/optimizecvOptimization_run4 credits
POST /public/jobs/analyzejobOffer_analyze2 credits
POST /public/email/generateemail_generate2 credits

This brings the surface to 32 operations. The fourteen resources are: cv, cvOptimization, coverLetter, resignationLetter, ats, jobOffer, email, content, documents, profiles, credits, templates, settings, files.

Same prices and same prompts as the in-product versions: given the same input, the reports have the same structure whichever surface produced them.

cv/optimize is not ats/audit. The audit tells you what a résumé parser will choke on — parsing risks, missing keywords. The optimization tells you what to rewrite and how: action verbs, quantification of results, relevance, length, industry fit, and a prioritised action plan with before/after previews. They answer different questions and are billed separately.

email/generate is not coverLetter/generate. It writes the short message that carries the application — the one with the attachments — not the letter itself. attachmentsMentioned is taken at face value, so only list what you will really attach.

Each operation accepts either a saved profileId or raw text, exactly like ats/audit. Combined with POST /public/files, a PDF now goes all the way through:

POST /public/files          → { id, extractedText }
POST /public/cv/optimize    → { resumeText: extractedText, jobDescription }

Reports are persisted like every other generation: the returned id works with GET /public/documents/{id}, and cv/optimize and jobs/analyze results can be exported with documents_exportReportPdf. An application email has no PDF export — it is meant to be copied into a mail client.

Note — still no fileId on business endpoints

These three operations take text, not file ids, like all the others. Pass the extractedText returned by the upload. Accepting file ids directly remains planned.

v2.2.0 — 2026-09-15

Added — file upload (POST /public/files)

The API accepted no files at all: auditing a CV meant extracting its text yourself first. Four new endpoints close that gap.

EndpointCost
POST /public/files1 credit
GET /public/filesfree
GET /public/files/{id}free
DELETE /public/files/{id}free

This brings the surface to 29 operations. The eleven resources are: cv, coverLetter, resignationLetter, ats, content, documents, profiles, credits, templates, settings, files.

Upload a PDF, DOCX or plain-text file (5 MB max). The response carries the extracted text, which plugs straight into the JSON endpoints:

{
  "id": "630748c9-…",
  "contentType": "cv",
  "extractedText": "Jean DUPONT
Developpeur Python Senior…",
  "charCount": 648,
  "creditsUsed": 1,
  "expiresAt": "2026-12-14T12:42:46.559Z"
}
POST /public/files            → { id, extractedText }
POST /public/ats/audit        → { resumeText: extractedText, jobDescription }

Billed once. Re-uploading the same bytes is deduplicated by checksum: the second call returns the existing record with creditsUsed: 0. A file with no readable text (a scanned PDF with no usable OCR layer) is rejected with file_no_text and refunded — an unusable upload is never charged.

Files expire automatically; check expiresAt. Rate limit: 30 uploads per hour per API key — the first quota actually enforced on the public API.

Note — the API still has no file-native operations

ats/audit, cv/generate and the others do not yet accept a fileId: pass the extractedText you got from the upload. Accepting file ids directly on those endpoints is planned.

v2.1.0 — 2026-09-15

Fixed — generated documents are now persisted (the id is real)

POST /public/cv/generate, /ats/audit, /cover-letter/generate and /resignation-letter/generate used to return an id that was never written to the database. It could not be passed to GET /public/documents/{id} or to any export endpoint — both returned 404.

v1.1.0 removed POST /public/documents on the stated premise that generation already persisted. That premise was wrong, so the documented generate → export workflow had been broken end to end ever since: exports only worked on documents created inside the app.

Generation now persists the document and returns its real id, which works with GET /public/documents/{id} and with the four export endpoints. The document also appears in your library at app.jobtactics.io.

Added — persisted on every generation response

A boolean telling you whether the returned id is usable:

{ "id": "e71500c9-…", "persisted": true, "content": … }

persisted: false means generation succeeded and you were charged normally, but the document could not be stored — so the id is throwaway and exports will 404. The practical cause is an account with no profile: documents are attached to a profile, and an API key whose owner never created one has nothing to attach to. Create a profile at app.jobtactics.io and subsequent calls will persist.

Storage failures never fail the request: the content is yours once it is generated and billed.

Note — ats/audit accepts raw text

Not a change, but it was undocumented and easy to miss: ats/audit takes either a saved profileId or resumeText (up to 50 000 characters), with an optional jobDescription to audit against a specific offer. The API does not accept file uploads — extracting text from a PDF or DOCX is currently the caller’s job.

v2.0.0 — 2026-09-12

Breaking — every operationId renamed

Each of the 25 operations now declares an explicit, stable operationId. The previous identifiers were derived automatically from internal class names:

OperationBeforeAfter
POST /public/cv/generatePublicApiController_generateCvcv_generate
GET /public/credits/balancePublicCreditsController_balancecredits_balance
POST /public/documents/{id}/export-pdfPublicPdfController_exportCvPdfdocuments_exportCvPdf

Are you affected? Only if you generate code from this spec.

Why we did this. Identifiers derived from class names leak our internal structure: the word “Controller” means nothing to you, and PublicCreditsController_balance is not a name anyone would choose for a method. Worse, they were unstable by construction — renaming a class on our side would silently rename a method on yours.

We are doing this now, before publishing any official SDK. Once client libraries ship against these names, changing them would break real integrations. Renaming while the only consumers are direct HTTP callers costs nobody anything.

The new naming convention

Identifiers follow resource_method, so generated clients group operations by resource instead of exposing 25 flat functions:

cv_generate               →  jt.cv.generate()
documents_list            →  jt.documents.list()
documents_exportCvPdf     →  jt.documents.exportCvPdf()
content_generateSummary   →  jt.content.generateSummary()

The ten resources are: cv, coverLetter, resignationLetter, ats, content, documents, profiles, credits, templates, settings.

Two groupings differ from the previous documentation tags. Cover letters, résumés and resignation letters each get their own resource, while the smaller writing helpers (bullet points, summaries, rewriting, skills, LinkedIn) sit together under content — the old split between “Generation” and “AI Generation” drew a line where there wasn’t one, since all ten are AI generations. Document exports moved under documents: an export acts on a document, so exportCvPdf now sits next to get rather than in a separate family.

v1.2.0 — 2026-08-21

Added — sortBy and sortOrder on every listing endpoint

GET /public/documents, GET /public/profiles and GET /public/templates now accept an explicit sort, matching what GET /public/credits/history already supported:

EndpointsortBy valuesDefault
/public/documentscreatedAt, title, typecreatedAt desc
/public/profilescreatedAt, label, updatedAtcreatedAt desc
/public/templatesname, slug, downloadsname desc

sortOrder accepts asc or desc and defaults to desc. An unknown sortBy falls back to the default rather than erroring, so a typo degrades gracefully instead of breaking a running scenario.

Why this matters. Asking for “10 documents” without saying which ten is an incomplete contract: the caller cannot tell whether they got the newest, the oldest, or an arbitrary slice. /public/documents did order by creation date, but that was implicit and undocumented — it could have changed in a refactor and silently broken every integration built on it. /public/profiles and /public/templates declared no ordering at all, leaving it to PostgreSQL: a paginated list without a stable order can repeat or skip rows between two pages.

Defaults reproduce the previous behaviour exactly, so no existing caller changes.

Added — limit and offset on profile and template listings

GET /public/profiles and GET /public/templates now accept pagination, aligning them with GET /public/documents which already supported it:

GET /api/v1/public/templates?limit=10&offset=0
GET /api/v1/public/profiles?limit=10&offset=0
ParameterTypeDefaultBounds
limitinteger50clamped to 1–100
offsetinteger0negatives clamped to 0

Both endpoints previously returned the entire collection and silently ignored any query parameter. This is not a breaking change: omitting both parameters returns up to 50 items, and neither collection is expected to exceed that for a single account. Callers who relied on receiving everything in one response should pass an explicit limit.

Why it matters for automation platforms. In Make, a search module emits one bundle per result, and every downstream module re-runs once per bundle. An unbounded listing therefore multiplies the cost of a whole scenario — a 31-template catalogue re-ran the rest of the flow 31 times. Bounding the listing is what makes those modules usable in a chained scenario.

Fixed — Letters no longer carry a markdown code fence

POST /public/cover-letter/generate and POST /public/resignation-letter/generate returned a content string wrapped in a markdown fence — literally starting with ```html or ```json — which every caller had to strip by hand.

The fence is now removed server-side. When format is html (the default) and the model returns a structured letter, the response is serialised to simple HTML (<p>, <br>) with all values escaped, instead of being passed through raw.

Fixed — ATS audit no longer fails on long reports

POST /public/ats/audit returned a 500 when the generated report was long enough to be truncated mid-JSON. The token budget for that operation has been raised, and a truncated response now surfaces as a 503 with an actionable message instead of an opaque 500.

Changed — Validation errors name the offending field

A rejected payload previously answered Validation failed with no indication of which field was wrong, because the generation schemas are unions and the underlying error was nested. The response now carries the path of each faulty field:

{
  "statusCode": 400,
  "message": "Validation failed",
  "errors": [
    { "path": "targetJob.title", "message": "String must contain at least 1 character(s)" }
  ],
  "timestamp": "2026-08-21T10:00:00.000Z"
}

Only the field path and the reason are returned — never the value received.

Fixed — null is now treated as an omitted field

Automation platforms send null (not an absent key) for any field the user leaves blank. Optional fields declared with .optional() rejected null, so a single empty Company box failed the whole request. null values are now stripped before validation and treated as omitted.

Docs — GET /public/profiles/{id} documents all 20 sections

The endpoint description listed 8 sections while the response has carried 20 since the profile extension. Unfilled sections come back as empty arrays.

v1.1.1 — 2026-08-13

Fixed — Array-wrapped request bodies are now accepted

Automation platforms (Make, Zapier, n8n) send the request body as a bundle — a single-element array [{ … }] rather than a plain object { … }.

Four endpoints rejected that shape with a 500:

The remaining endpoints already accepted it, so the API behaved inconsistently with itself depending on which route you called. A single-element array is now unwrapped before validation on every endpoint. Sending a plain object keeps working exactly as before — no change is required in existing integrations.

Fixed — Invalid payloads return 400, not 500 (behaviour change)

A payload that failed schema validation used to surface as 500 Internal server error, which reads as an outage and gives no clue about the offending field. Validation failures now return a proper 400 with the faulty paths:

{
  "statusCode": 400,
  "message": "Validation failed",
  "errors": [
    { "path": "targetJob.title", "message": "String must contain at least 1 character(s)" }
  ],
  "timestamp": "2026-08-13T10:00:00.000Z"
}

Only the field path and the validation message are echoed — never the value you sent, since request bodies can carry résumé content or credentials.

If your client treats 5xx as retryable, note that these cases now return 4xx and should not be retried without fixing the payload.

v1.1.0 — 2026-06-30

Removed — Profile, folder and document CRUD (breaking)

The Public API is now a workflow integration surface, not a remote substitute for the JobTactics dashboard. Resources whose creation/editing only makes sense inside the dashboard’s purpose-built UI have been removed from the public surface. They remain fully available inside app.jobtactics.io.

Profiles (write surface removed; two reads remain):

Folders (entire surface removed):

Documents (write surface removed; reads remain):

Removed — Application score (breaking)

Application scoring is split between an integration signal and two SaaS-internal features. Only the former stays public:

The use case left behind: an external provider can still score a single profile, but auditing a specific application or batch-rating a board are SaaS-internal features.

Changed — Document exports : locale query parameter removed (breaking)

The locale query parameter has been removed from every export endpoint:

Why. Exports must reflect the document as it was generated, not be re-translated at download time. The previous design let callers pass a locale that would override the section labels (e.g. “Experience” → “Expérience”), producing PDFs where the body content was in one language and the section labels in another. That was a footgun.

New behavior. The export endpoints now pick the language from the document itself, in this order:

  1. content.language if the document was generated with an explicit language.
  2. The user’s preferred language (users.language).
  3. English as the ultimate fallback.

If you want a CV in French, generate it in French (POST /public/cv/generate with language: "fr"). The export then matches.

Added — CV section labels follow the document language, for any locale

CV section labels (“Experience”, “Education”, “Skills”…) now follow the language the CV was generated in — including languages outside the six UI locales (fr, en, es, it, de, pt). Generate a CV in Japanese, Arabic or Chinese and the exported PDF’s section headers come out in that language instead of falling back to English.

How it works. When a CV is generated, the target language is stored on the document (content.language), and the AI produces the translated section labels alongside the content (content.sectionLabels). At export time, labels are resolved in this order, per label: the document’s own sectionLabels, then the built-in registry for one of the six UI locales, then English. Explicit per-document labels always win, so non-EU languages render correctly without any new endpoint or parameter.

Changed — every export follows the document’s own language, end to end

The “language is intrinsic to the document” principle now covers all generation paths and all exports:

Migration notes

If you previously created profiles, folders or documents via the API in scripts or integrations:

Total surface: 31 → 17 public endpoints. The Make and Zapier integrations have been updated in lockstep.

v1.0.1 — 2026-05-18

Removed — Matching (breaking)

Fixed

v1.0.0 — 2026-05-04

The first public release of the API expands the surface from four endpoints to a complete integration toolkit (31 endpoints), all gated by personal API keys generated in Settings → API keys.

Added — AI generation

Added — Application scoring

Added — Profile, document and folder CRUD

Added — Document exports

All export endpoints follow the same async pattern: the file is generated, uploaded to private storage and returned as a presigned URL valid for one hour, in this exact JSON shape:

{
  "downloadUrl": "https://...",
  "expiresAt": "2026-05-04T18:30:00.000Z",
  "filename": "cv-jane-doe-software-engineer-acme-04-05-2026.pdf",
  "contentType": "application/pdf",
  "sizeBytes": 245678
}

Added — Read-only resources

Added — Settings

Notes