Resume Match Optimizer — API
Open the app

Use the analyzer from your own code

Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS, so you can script it from any language. This page walks through each task with examples in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure.

StatusMeaning
401Missing or expired token — create a new session.
402Not enough credits — top up at skillsafe.ai/account/credits.
403The token isn't allowed to do this (e.g. guests running a custom analysis).
404Unknown job or record id.
5xxTransient platform error — retry with backoff.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.

Step 0 — A tiny client

Every task below is a single HTTP call, so start with a 15-line helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse this helper.

export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN"      # see step 1

# every call looks like:
#   curl -s "$API/…" -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": …} envelope
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your environment in real code

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"data": …}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1

def api(method, path, body = nil)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

A guest token lets you check balances, estimate costs and run the built-in example (free). To analyze your own resume you need your personal token: open the token page, sign in, and hit "Copy shell export" — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which every example below reads. Treat the token like a password — it can spend your credits. For fully headless scripts, POST /guest (below) mints a guest token with no browser involved.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"resume-match-optimizer"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "resume-match-optimizer"})["token"]
const { token } = await api("POST", "/guest", { slug: "resume-match-optimizer" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "resume-match-optimizer"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"resume-match-optimizer"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "resume-match-optimizer" })["token"]
$token = api("POST", "/guest", ["slug" => "resume-match-optimizer"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "resume-match-optimizer" });
var token = guest.GetProperty("token").GetString();

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance. Check this before an expensive run.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 3 — Estimate the cost

POST /estimate

Send the same input you would send to /run; the response's hold_credits is the worst-case cost. Nothing is charged and no job is created.

Input fieldTypeNotes
resumestring, requiredPlain-text resume.
job_descriptionstring, requiredPlain-text job posting (strip site chrome for better results).
target_rolestring, optionale.g. "Senior Backend Engineer".
job_urlstring, optionalPosting URL — remove tracking params (utm_*, refId…).
source_platformstring, optionalJob board / ATS name (LinkedIn, Indeed, Greenhouse, Lever, Workday…) to tailor ATS tips.
curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"resume":"…","job_description":"…","target_role":"Senior Backend Engineer"}' \
  | jq '.data.hold_credits'
est = api("POST", "/estimate", {
    "resume": resume, "job_description": jd,
    "target_role": "Senior Backend Engineer",
})
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const est = await api("POST", "/estimate", {
  resume, job_description: jd, target_role: "Senior Backend Engineer",
});
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", map[string]string{
	"resume": resume, "job_description": jd,
	"target_role": "Senior Backend Engineer",
}, &est)
String envelope = api("POST", "/estimate", """
    {"resume": %s, "job_description": %s, "target_role": "Senior Backend Engineer"}
    """.formatted(toJsonString(resume), toJsonString(jd)));
// worst-case cost is at data.hold_credits
est = api("POST", "/estimate", { resume: resume, job_description: jd,
                                 target_role: "Senior Backend Engineer" })
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$est = api("POST", "/estimate", [
    "resume" => $resume,
    "job_description" => $jd,
    "target_role" => "Senior Backend Engineer",
]);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", new {
    resume, job_description = jd, target_role = "Senior Backend Engineer" });
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");

Step 4 — Run an analysis and wait for the result

POST /run
GET /jobs/{job_id}

/run takes the same input as /estimate, places a credit hold and returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed (a run typically takes 20–60 s). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run. The report is in output (sometimes nested as output.output, and possibly a JSON string — parse defensively).

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: run-$(date +%s)" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

echo "$JOB" | jq '.data.output'
import time

job_id = api("POST", "/run", {
    "resume": resume, "job_description": jd,
    "target_role": "Senior Backend Engineer",
}, **{"Idempotency-Key": "my-run-001"})["job_id"]

while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if job["status"] == "failed":
    raise RuntimeError(job.get("error", "run failed"))

raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
    raw = raw["output"]
report = json.loads(raw) if isinstance(raw, str) else raw
print(report["match_score"], report["recommendation"])
const { job_id } = await api("POST", "/run", {
  resume, job_description: jd, target_role: "Senior Backend Engineer",
}, { "Idempotency-Key": crypto.randomUUID() });

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");

if (job.status === "failed") throw new Error(job.error ?? "run failed");

const raw = job.output?.output ?? job.output;
const report = typeof raw === "string" ? JSON.parse(raw) : raw;
console.log(report.match_score, report.recommendation);
var started struct{ JobID string `json:"job_id"` }
err := call("POST", "/run", map[string]string{
	"resume": resume, "job_description": jd,
	"target_role": "Senior Backend Engineer",
}, &started)
if err != nil {
	log.Fatal(err)
}

var job struct {
	Status string          `json:"status"`
	Error  string          `json:"error"`
	Output json.RawMessage `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}
// job.Output holds the report (may be {"output": …} or a JSON string —
// unwrap/unquote before unmarshalling into your report struct).
String envelope = api("POST", "/run", """
    {"resume": %s, "job_description": %s, "target_role": "Senior Backend Engineer"}
    """.formatted(toJsonString(resume), toJsonString(jd)));
String jobId = /* data.job_id via your JSON library */;

while (true) {
    String job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// the report is at data.output (sometimes data.output.output, possibly a
// JSON string — parse it again if so)
started = api("POST", "/run", { resume: resume, job_description: jd,
                                target_role: "Senior Backend Engineer" })

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"

raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
report = raw.is_a?(String) ? JSON.parse(raw) : raw
puts "#{report["match_score"]}/100 — #{report["recommendation"]}"
$started = api("POST", "/run", [
    "resume" => $resume,
    "job_description" => $jd,
    "target_role" => "Senior Backend Engineer",
]);

do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));

if ($job["status"] === "failed") {
    throw new Exception($job["error"] ?? "run failed");
}

$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$report = is_string($raw) ? json_decode($raw, true) : $raw;
echo "{$report['match_score']}/100 — {$report['recommendation']}\n";
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", new {
    resume, job_description = jd, target_role = "Senior Backend Engineer" });
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}
// the report is at job.GetProperty("output") — sometimes nested under
// "output", possibly a JSON string; parse defensively.

The report object has this shape:

FieldType
match_scorenumber, 0–100
recommendationstrong_match | promising | needs_work | not_a_fit
verdictstring — one-paragraph summary
matched_keywords, missing_keywordsstring[]
strengths, gaps, ats_tipsstring[]
suggestionsarray of {section, priority, issue, action, example}
tailored_summarystring — paste-ready summary rewrite

Step 5 — Query your saved analyses

POST /collections/analyses/query

The app saves each signed-in analysis to the analyses collection. Records come back as {id, created_at, doc} where doc holds target_role, match_score, recommendation, verdict and the full result_json. You can also POST …/records {"doc": …} to save your own.

curl -s -X POST "$API/collections/analyses/query" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"sort":{"field":"created_at","dir":"desc"},"limit":20}' \
  | jq '.data.records[].doc | {match_score, target_role}'
res = api("POST", "/collections/analyses/query",
          {"sort": {"field": "created_at", "dir": "desc"}, "limit": 20})
for rec in res["records"]:
    print(rec["doc"]["match_score"], rec["doc"].get("target_role", ""))
const { records } = await api("POST", "/collections/analyses/query", {
  sort: { field: "created_at", dir: "desc" }, limit: 20,
});
for (const rec of records) console.log(rec.doc.match_score, rec.doc.target_role);
var res struct {
	Records []struct {
		CreatedAt string          `json:"created_at"`
		Doc       json.RawMessage `json:"doc"`
	} `json:"records"`
}
err := call("POST", "/collections/analyses/query", map[string]any{
	"sort":  map[string]string{"field": "created_at", "dir": "desc"},
	"limit": 20,
}, &res)
String envelope = api("POST", "/collections/analyses/query", """
    {"sort":{"field":"created_at","dir":"desc"},"limit":20}""");
// records are at data.records[]; each has created_at and doc
res = api("POST", "/collections/analyses/query",
          { sort: { field: "created_at", dir: "desc" }, limit: 20 })
res["records"].each { |rec| puts "#{rec["doc"]["match_score"]} #{rec["doc"]["target_role"]}" }
$res = api("POST", "/collections/analyses/query", [
    "sort" => ["field" => "created_at", "dir" => "desc"],
    "limit" => 20,
]);
foreach ($res["records"] as $rec) {
    echo "{$rec['doc']['match_score']} {$rec['doc']['target_role']}\n";
}
var res = await SkillSafe.ApiAsync(HttpMethod.Post, "/collections/analyses/query", new {
    sort = new { field = "created_at", dir = "desc" }, limit = 20 });
foreach (var rec in res.GetProperty("records").EnumerateArray())
    Console.WriteLine($"{rec.GetProperty("doc").GetProperty("match_score")}");