Applicant & Status Management

This guide walks through what happens to a job application after it reaches Apploi:

  1. finding the job applications you need to act on,
  2. reading one job application in full,
  3. looking up the statuses a team uses,
  4. moving job applications to a new status,
  5. messaging the applicant,
  6. scheduling or cancelling an interview.

Each step shows the one request that matters, then links to the endpoint's reference page for the details. Putting it together combines the steps into one flow.

Before you start

Which id goes where

Every endpoint in this guide works on job applications. A job application is one person's application to one job. An entry in the /applicants response has three ids:

Field in /applicantsWhat it identifiesWhere you send it
idThe job applicationThe {applicant_id} in every path below, and application_ids in a status update
applicant_idThe person who applied. A person who applied to two jobs has two job applications with the same applicant_id.Nowhere in this guide
job.team_idThe team that owns the jobteam_id when you look up status names

Despite its name, the {applicant_id} path parameter takes the job application's id, not its applicant_id. Mixing them up usually returns a 403 or 404, but not always: the wrong number can also match a different job application, and a status update or interview would then change that one instead. Always take the id from the id field.

What your API key needs

  • The examples read your API key from an environment variable named APPLOI_API_KEY. Set APPLOI_API_KEY in your own environment to the Partner API key you received from Apploi. The examples don't set it for you, and the key should never be written into your code.
  • Your key must be allowed to update job applications and manage interviews. If a request returns 403 with a valid key and an id from /applicants, contact your Apploi CSM.
  • Messaging must be turned on for each team you message on behalf of. Ask your Apploi contact to enable it.

Setup

Every example after this one builds on the code below. In Python, TypeScript and Ruby it defines:

  • apploi_request (apploiRequest in TypeScript), which sends one request with your API key and returns the response;
  • read_json (readJson), which returns a successful response's JSON body and raises an ApploiError for anything else, including a failed status code and a body that isn't JSON. The error carries the status code and the body.

Each step then adds one function to the same file. The cURL examples stand alone.

# APPLOI_API_KEY must hold the Partner API key you received from Apploi.
# These examples do not set it for you: set it yourself, in your own
# environment, before running them. For example, in your shell:
#   export APPLOI_API_KEY="<the API key you received from Apploi>"
#
# Some examples below use jq (https://jqlang.org) to read and build JSON.
import os

import requests

# APPLOI_API_KEY must hold the Partner API key you received from Apploi.
# These examples do not set it for you: set it yourself, in your own
# environment, before running them. For example, in your shell:
#   export APPLOI_API_KEY="<the API key you received from Apploi>"
API_KEY = os.environ["APPLOI_API_KEY"]
BASE_URL = "https://partners.apploi.com"


class ApploiError(Exception):
    def __init__(self, status, body):
        super().__init__(f"Request failed with status {status}: {body[:200]}")
        self.status = status
        self.body = body


def apploi_request(method, path, params=None, json=None):
    return requests.request(
        method,
        f"{BASE_URL}{path}",
        headers={"X-Api-Key": API_KEY},
        params=params,
        json=json,
        timeout=60,
    )


def read_json(response):
    if not response.ok:
        raise ApploiError(response.status_code, response.text)
    try:
        return response.json()
    except ValueError:
        # A server error can arrive with status 200 and a body that isn't JSON.
        raise ApploiError(response.status_code, response.text)
// APPLOI_API_KEY must hold the Partner API key you received from Apploi.
// These examples do not set it for you: set it yourself, in your own
// environment, before running them. For example, in your shell:
//   export APPLOI_API_KEY="<the API key you received from Apploi>"
if (!process.env.APPLOI_API_KEY) {
  throw new Error("Set APPLOI_API_KEY to the Partner API key you received from Apploi");
}
const API_KEY: string = process.env.APPLOI_API_KEY;
const BASE_URL = "https://partners.apploi.com";

// The fields of a /applicants entry that this guide uses
interface JobApplication {
  id: number;
  applicant_state: string;
  job: { id: number; team_id: number };
  person: { first_name: string };
}

class ApploiError extends Error {
  constructor(
    readonly status: number,
    readonly body: string,
  ) {
    super(`Request failed with status ${status}: ${body.slice(0, 200)}`);
  }
}

async function apploiRequest(
  method: string,
  path: string,
  options: { params?: Record<string, string | number>; body?: unknown } = {},
): Promise<Response> {
  const params = new URLSearchParams();
  for (const [name, value] of Object.entries(options.params ?? {})) {
    params.set(name, String(value));
  }
  const query = options.params ? `?${params}` : "";
  const headers: Record<string, string> = { "X-Api-Key": API_KEY };
  if (options.body !== undefined) {
    headers["Content-Type"] = "application/json";
  }
  return fetch(`${BASE_URL}${path}${query}`, {
    method,
    headers,
    body: options.body === undefined ? undefined : JSON.stringify(options.body),
    signal: AbortSignal.timeout(60_000),
  });
}

async function readJson(response: Response): Promise<any> {
  const text = await response.text();
  if (!response.ok) {
    throw new ApploiError(response.status, text);
  }
  try {
    return JSON.parse(text);
  } catch {
    // A server error can arrive with status 200 and a body that isn't JSON.
    throw new ApploiError(response.status, text);
  }
}
require "json"
require "net/http"
require "set"
require "uri"

# APPLOI_API_KEY must hold the Partner API key you received from Apploi.
# These examples do not set it for you: set it yourself, in your own
# environment, before running them. For example, in your shell:
#   export APPLOI_API_KEY="<the API key you received from Apploi>"
API_KEY = ENV.fetch("APPLOI_API_KEY")
BASE_URL = "https://partners.apploi.com"

class ApploiError < StandardError
  attr_reader :status, :body

  def initialize(status, body)
    @status = status
    @body = body
    super("Request failed with status #{status}: #{body.to_s[0, 200]}")
  end
end

def apploi_request(method, path, params: nil, body: nil)
  uri = URI("#{BASE_URL}#{path}")
  uri.query = URI.encode_www_form(params) if params
  request = Net::HTTP.const_get(method.capitalize).new(uri) # "GET" -> Net::HTTP::Get
  request["X-Api-Key"] = API_KEY
  if body
    request["Content-Type"] = "application/json"
    request.body = JSON.generate(body)
  end
  Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 60) do |http|
    http.request(request)
  end
end

def read_json(response)
  raise ApploiError.new(response.code.to_i, response.body) unless response.is_a?(Net::HTTPSuccess)

  JSON.parse(response.body)
rescue JSON::ParserError
  # A server error can arrive with status 200 and a body that isn't JSON.
  raise ApploiError.new(response.code.to_i, response.body)
end

Step 1: Find job applications

GET /applicants lists a team's job applications, most recent first, up to 80 per page. Two filters narrow it to the job applications you need to act on:

  • state returns only job applications whose current status has this name. The match is exact apart from case, so new matches New, but Interview doesn't match Interview Scheduled. Use a name from Step 3.
  • updated_after returns only job applications that changed since a time you give, in UTC. Use it for a regular sync rather than reading everything each time.

The examples define list_job_applications (listJobApplications in TypeScript), which reads every page and keeps one copy of each job application. The /applicants reference explains why the loop stops on a short page and how to choose updated_after safely.

# First page of the team's job applications in the "New" status.
# For the next page, add the response's "limit" to "offset".
curl -G https://partners.apploi.com/applicants \
  -H "X-Api-Key: $APPLOI_API_KEY" \
  --data-urlencode "team_id=123456" \
  --data-urlencode "state=New" \
  --data-urlencode "limit=50" \
  --data-urlencode "offset=0"
def list_job_applications(team_id, **filters):
    job_applications = {}
    offset = 0
    while True:
        response = apploi_request(
            "GET",
            "/applicants",
            params={"team_id": team_id, "limit": 50, "offset": offset, **filters},
        )
        page = read_json(response)

        for job_application in page["data"]:
            job_applications[job_application["id"]] = job_application

        if len(page["data"]) < page["limit"]:
            return list(job_applications.values())
        offset += page["limit"]


for job_application in list_job_applications(123456, state="New"):
    print(job_application["id"], job_application["person"]["first_name"])
async function listJobApplications(
  teamId: number,
  filters: Record<string, string> = {},
): Promise<JobApplication[]> {
  const jobApplications = new Map<number, JobApplication>();
  let offset = 0;
  while (true) {
    const response = await apploiRequest("GET", "/applicants", {
      params: { team_id: teamId, limit: 50, offset, ...filters },
    });
    const page = await readJson(response);

    for (const jobApplication of page.data as JobApplication[]) {
      jobApplications.set(jobApplication.id, jobApplication);
    }

    if (page.data.length < page.limit) {
      return [...jobApplications.values()];
    }
    offset += page.limit;
  }
}

for (const jobApplication of await listJobApplications(123456, { state: "New" })) {
  console.log(jobApplication.id, jobApplication.person.first_name);
}
def list_job_applications(team_id, filters = {})
  job_applications = {}
  offset = 0
  loop do
    response = apploi_request(
      "GET",
      "/applicants",
      params: { team_id: team_id, limit: 50, offset: offset }.merge(filters)
    )
    page = read_json(response)

    page["data"].each do |job_application|
      job_applications[job_application["id"]] = job_application
    end

    return job_applications.values if page["data"].length < page["limit"]

    offset += page["limit"]
  end
end

list_job_applications(123456, state: "New").each do |job_application|
  puts [job_application["id"], job_application["person"]["first_name"]].join(" ")
end

Each entry already holds what the rest of this guide needs. Trimmed to those fields, an entry looks like this:

{
  "id": 98765432,
  "applicant_id": 1234567,
  "applicant_state": "New",
  "job": {
    "id": 7654321,
    "name": "Certified Nursing Assistant",
    "team_id": 345678
  },
  "person": {
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "[email protected]",
    "mobile_phone_number": "+15555550123"
  }
}

Wherever a later step needs the job's team, use job.team_id from the entry rather than the team_id you listed with.

Step 2: Read one job application

GET /applicants/{applicant_id} returns one job application in full, including the applicant's profile and answers. Call it only when you need more than the list gives you. To see what changed, use updated_after in Step 1 rather than calling this for every job application.

The examples define get_job_application (getJobApplication in TypeScript), which returns nothing when no job application has the id. A 403 means the job application is outside your key's scope or was deleted.

curl https://partners.apploi.com/applicants/98765432 \
  -H "X-Api-Key: $APPLOI_API_KEY"
def get_job_application(job_application_id):
    response = apploi_request("GET", f"/applicants/{job_application_id}")
    if response.status_code == 404:
        return None  # no job application with this id
    return read_json(response)["data"]


job_application = get_job_application(98765432)
if job_application is not None:
    print(job_application["applicant_state"], job_application["applicant_profile"]["email"])
async function getJobApplication(jobApplicationId: number): Promise<Record<string, any> | null> {
  const response = await apploiRequest("GET", `/applicants/${jobApplicationId}`);
  if (response.status === 404) {
    return null; // no job application with this id
  }
  return (await readJson(response)).data;
}

const detail = await getJobApplication(98765432);
if (detail !== null) {
  console.log(detail.applicant_state, detail.applicant_profile.email);
}
def get_job_application(job_application_id)
  response = apploi_request("GET", "/applicants/#{job_application_id}")
  return nil if response.code == "404" # no job application with this id

  read_json(response)["data"]
end

detail = get_job_application(98765432)
puts [detail["applicant_state"], detail["applicant_profile"]["email"]].join(" ") if detail

Step 3: Look up the team's status names

Each team has its own list of statuses, including any custom ones added in the Application Stage Manager. A team that hasn't set up its own statuses uses its parent team's list. GET /applicants/applicant-statuses returns the list.

Look it up with the team that owns the job: the job application's job.team_id. Job applications to jobs of the same team share one list, so look it up once per team and reuse it.

The examples define get_status_names (getStatusNames in TypeScript).

curl -G https://partners.apploi.com/applicants/applicant-statuses \
  -H "X-Api-Key: $APPLOI_API_KEY" \
  --data-urlencode "team_id=345678"
def get_status_names(team_id):
    response = apploi_request("GET", "/applicants/applicant-statuses", params={"team_id": team_id})
    return [status["name"] for status in read_json(response)["data"]]


print(get_status_names(345678))
async function getStatusNames(teamId: number): Promise<string[]> {
  const response = await apploiRequest("GET", "/applicants/applicant-statuses", {
    params: { team_id: teamId },
  });
  const body = await readJson(response);
  return body.data.map((status: { name: string }) => status.name);
}

console.log(await getStatusNames(345678));
def get_status_names(team_id)
  response = apploi_request("GET", "/applicants/applicant-statuses", params: { team_id: team_id })
  read_json(response)["data"].map { |status| status["name"] }
end

puts get_status_names(345678)

Each entry has an id and a name. Status updates take the name, so copy it exactly as returned.

Step 4: Move job applications to a new status

PUT /applicants/status moves one or more job applications to the same status. Send the job applications' ids in application_ids, as integers in an array, and the status name in status.

A 200 doesn't mean every job application was updated. Ids that don't match a job application are left out of the response without an error. The examples define update_status (updateStatus in TypeScript), which compares the ids sent with the response and returns the ids that were updated and the ids that weren't, with a reason for each.

curl -sS -X PUT https://partners.apploi.com/applicants/status \
  -H "X-Api-Key: $APPLOI_API_KEY" \
  -H "Content-Type: application/json" \
  -w "\nHTTP status: %{http_code}\n" \
  -d '{
    "application_ids": [98765432],
    "status": "Not Qualified"
  }'
def update_status(application_ids, status):
    response = apploi_request(
        "PUT", "/applicants/status", json={"application_ids": application_ids, "status": status}
    )
    # A 400 means the request must change before it's sent again.
    # For a 5xx, see "Retries and errors at a glance" before retrying.
    data = read_json(response)["data"]

    returned_ids = {application["id"] for application in data["list_application_form"]}

    updated, not_updated = [], {}
    for application_id in application_ids:
        if application_id not in returned_ids:
            not_updated[application_id] = "No job application with this id"
        else:
            updated.append(application_id)
    return updated, not_updated


updated, not_updated = update_status([98765432], "Not Qualified")
print("Updated:", updated, "Not updated:", not_updated)
async function updateStatus(
  applicationIds: number[],
  status: string,
): Promise<{ updated: number[]; notUpdated: Map<number, string> }> {
  const response = await apploiRequest("PUT", "/applicants/status", {
    body: { application_ids: applicationIds, status },
  });
  // A 400 means the request must change before it's sent again.
  // For a 5xx, see "Retries and errors at a glance" before retrying.
  const { data } = await readJson(response);

  const returnedIds = new Set<number>(
    data.list_application_form.map((application: { id: number }) => application.id),
  );
  const updated: number[] = [];
  const notUpdated = new Map<number, string>();
  for (const applicationId of applicationIds) {
    if (!returnedIds.has(applicationId)) {
      notUpdated.set(applicationId, "No job application with this id");
    } else {
      updated.push(applicationId);
    }
  }
  return { updated, notUpdated };
}

const result = await updateStatus([98765432], "Not Qualified");
console.log("Updated:", result.updated, "Not updated:", result.notUpdated);
def update_status(application_ids, status)
  response = apploi_request(
    "PUT", "/applicants/status", body: { application_ids: application_ids, status: status }
  )
  # A 400 means the request must change before it's sent again.
  # For a 5xx, see "Retries and errors at a glance" before retrying.
  data = read_json(response)["data"]

  returned_ids = data["list_application_form"].map { |application| application["id"] }

  updated = []
  not_updated = {}
  application_ids.each do |application_id|
    if !returned_ids.include?(application_id)
      not_updated[application_id] = "No job application with this id"
    else
      updated << application_id
    end
  end
  [updated, not_updated]
end

updated, not_updated = update_status([98765432], "Not Qualified")
puts "Updated: #{updated} Not updated: #{not_updated}"

Rules that apply to every status update:

  • One status per request. To move job applications to different statuses, send one request per status.
  • Send one request per team. The status must exist for the team of every job in the request. If one job's team doesn't have it, the whole request returns 400 and no status changes. Automations for the job applications processed before the failing one may already have started, though, and they run again when you resend. Grouping job applications by job.team_id avoids both problems.
  • Keep batches to about 50 ids. Job applications are processed in turn, and a request that takes more than 29 seconds is cut off with a 504.
  • Only send job applications whose status needs to change. Sending the status a job application already has counts as a new status change, so any automation the team has set up for that status runs again. Check applicant_state from Step 1 first.

The PUT /applicants/status reference describes every response.

Step 5: Message the applicant

POST /applicants/{applicant_id}/message adds a text or email to the job application's message history, where recruiters see it in Apploi. GET /applicants/{applicant_id}/message reads the history back, including messages recruiters sent from Apploi.

Before you send a message:

  • Every message needs a sender_email. It must be the email address of an active Apploi user on the team that owns the job. Use the recruiter the conversation belongs to.
  • Decide who delivers it. With "is_sent_by_vendor": true (the default), Apploi only records a message that you deliver yourself, or that the applicant sent you. With false, Apploi also delivers it to the applicant. The POST reference explains both.
  • Some errors arrive with a 200 status. If messaging isn't turned on for the team, or an email has no subject, the response is a 200 whose body has a developer_message and no data. A temporary server error can also arrive as a 200, with a body that may not be JSON. Check the body as well as the status code.

Record a message

This example records a text that you delivered yourself. The examples define post_message (postMessage in TypeScript), which treats a request as successful only when the body's data.status is success.

curl -sS -X POST https://partners.apploi.com/applicants/98765432/message \
  -H "X-Api-Key: $APPLOI_API_KEY" \
  -H "Content-Type: application/json" \
  -w "\nHTTP status: %{http_code}\n" \
  -d '{
    "external_id": "msg7f3a90",
    "message_type": "text",
    "message_direction": "to_candidate",
    "message_body": "Hi Jane, thanks for applying. Are you free for a phone screen this week?",
    "status": "DELIVERED",
    "vendor_name": "Acme Messaging",
    "is_sent_by_vendor": true,
    "sender_email": "[email protected]"
  }'
def post_message(job_application_id, message):
    response = apploi_request("POST", f"/applicants/{job_application_id}/message", json=message)
    result = read_json(response).get("data")
    if not result or result.get("status") != "success":
        # Some errors arrive with status 200.
        raise ApploiError(response.status_code, response.text)
    return result


post_message(98765432, {
    "external_id": "msg7f3a90",  # your own id, up to 36 characters
    "message_type": "text",
    "message_direction": "to_candidate",
    "message_body": "Hi Jane, thanks for applying. Are you free for a phone screen this week?",
    "status": "DELIVERED",
    "vendor_name": "Acme Messaging",
    "is_sent_by_vendor": True,
    "sender_email": "[email protected]",
})
async function postMessage(jobApplicationId: number, message: object): Promise<any> {
  const response = await apploiRequest("POST", `/applicants/${jobApplicationId}/message`, {
    body: message,
  });
  const body = await readJson(response);
  if (body.data?.status !== "success") {
    // Some errors arrive with status 200.
    throw new ApploiError(response.status, JSON.stringify(body));
  }
  return body.data;
}

await postMessage(98765432, {
  external_id: "msg7f3a90", // your own id, up to 36 characters
  message_type: "text",
  message_direction: "to_candidate",
  message_body: "Hi Jane, thanks for applying. Are you free for a phone screen this week?",
  status: "DELIVERED",
  vendor_name: "Acme Messaging",
  is_sent_by_vendor: true,
  sender_email: "[email protected]",
});
def post_message(job_application_id, message)
  response = apploi_request("POST", "/applicants/#{job_application_id}/message", body: message)
  result = read_json(response)["data"]
  # Some errors arrive with status 200.
  raise ApploiError.new(response.code.to_i, response.body) unless result.is_a?(Hash) && result["status"] == "success"

  result
end

post_message(
  98765432,
  external_id: "msg7f3a90", # your own id, up to 36 characters
  message_type: "text",
  message_direction: "to_candidate",
  message_body: "Hi Jane, thanks for applying. Are you free for a phone screen this week?",
  status: "DELIVERED",
  vendor_name: "Acme Messaging",
  is_sent_by_vendor: true,
  sender_email: "[email protected]"
)

To record a reply the applicant sent you, send the same fields with "message_direction": "from_candidate". The message is recorded once per request, with no duplicate check, so don't resend after an error without reading the history first.

Read new replies

The history endpoint has no filter, so each call returns every message, newest first. To act on new replies, keep the message_ids you have already handled and skip them next time. The examples define new_replies (newReplies in TypeScript), which returns the replies you haven't handled yet, oldest first.

# Only the applicant's replies
curl -sS https://partners.apploi.com/applicants/98765432/message \
  -H "X-Api-Key: $APPLOI_API_KEY" \
  | jq '.data[] | select(.message_direction == "from_candidate")'
def new_replies(job_application_id, handled_message_ids):
    response = apploi_request("GET", f"/applicants/{job_application_id}/message")
    body = read_json(response)
    if "data" not in body:
        # Some errors arrive with status 200.
        raise ApploiError(response.status_code, response.text)
    replies = [
        message
        for message in body["data"]
        if message["message_direction"] == "from_candidate"
        and message["message_id"] not in handled_message_ids
    ]
    return list(reversed(replies))  # oldest first


handled_message_ids = set()  # load these from your own storage
for reply in new_replies(98765432, handled_message_ids):
    print(reply["sent_at"], reply["message_body"])
    handled_message_ids.add(reply["message_id"])  # and save it
async function newReplies(jobApplicationId: number, handledMessageIds: Set<string>): Promise<any[]> {
  const response = await apploiRequest("GET", `/applicants/${jobApplicationId}/message`);
  const body = await readJson(response);
  if (!Array.isArray(body.data)) {
    // Some errors arrive with status 200.
    throw new ApploiError(response.status, JSON.stringify(body));
  }
  return body.data
    .filter(
      (message: any) =>
        message.message_direction === "from_candidate" && !handledMessageIds.has(message.message_id),
    )
    .reverse(); // oldest first
}

const handledMessageIds = new Set<string>(); // load these from your own storage
for (const reply of await newReplies(98765432, handledMessageIds)) {
  console.log(reply.sent_at, reply.message_body);
  handledMessageIds.add(reply.message_id); // and save it
}
def new_replies(job_application_id, handled_message_ids)
  response = apploi_request("GET", "/applicants/#{job_application_id}/message")
  body = read_json(response)
  # Some errors arrive with status 200.
  raise ApploiError.new(response.code.to_i, response.body) unless body["data"].is_a?(Array)

  body["data"]
    .select { |message| message["message_direction"] == "from_candidate" }
    .reject { |message| handled_message_ids.include?(message["message_id"]) }
    .reverse # oldest first
end

handled_message_ids = Set.new # load these from your own storage
new_replies(98765432, handled_message_ids).each do |reply|
  puts [reply["sent_at"], reply["message_body"]].join(" ")
  handled_message_ids.add(reply["message_id"]) # and save it
end

Step 6: Schedule or cancel an interview

PUT /applicants/{applicant_id}/interviews schedules, reschedules or cancels the job application's interview. Most rejected requests come from the body's format:

  • Send every value as a JSON string, even numbers. Only cancel is a boolean.
  • date, duration and timezone are always required, and so is an interviewer: interviewerTeamId and interviewerEmail, or interviewerId. This applies to cancel requests too.
  • date is the local time in timezone. "2026-11-03T14:00:00Z" with "America/New_York" means 2:00 PM in New York. Don't convert it to UTC, but do keep the Z.

You don't need a separate status update. When an interview is scheduled, rescheduled or cancelled, Apploi moves the job application to the team's matching status, such as Interview Scheduled, if the team has one. Sending the same status again with Step 4 would run the team's automations for it a second time.

The examples define save_interview (saveInterview in TypeScript), which returns saved, or not_found when the job application doesn't exist or, when cancelling, has no upcoming interview. It doesn't read the response body, because after a cancel the body may not be valid JSON.

# Schedule. Sending it again before the interview takes place reschedules it.
curl -sS -X PUT https://partners.apploi.com/applicants/98765432/interviews \
  -H "X-Api-Key: $APPLOI_API_KEY" \
  -H "Content-Type: application/json" \
  -w "\nHTTP status: %{http_code}\n" \
  -d '{
    "date": "2026-11-03T14:00:00Z",
    "duration": "45",
    "timezone": "America/New_York",
    "location": "Virtual",
    "interviewerTeamId": "345678",
    "interviewerEmail": "[email protected]"
  }'

# Cancel. The other fields are ignored but still required.
curl -sS -X PUT https://partners.apploi.com/applicants/98765432/interviews \
  -H "X-Api-Key: $APPLOI_API_KEY" \
  -H "Content-Type: application/json" \
  -w "\nHTTP status: %{http_code}\n" \
  -d '{
    "cancel": true,
    "date": "2026-11-03T14:00:00Z",
    "duration": "45",
    "timezone": "America/New_York",
    "interviewerTeamId": "345678",
    "interviewerEmail": "[email protected]"
  }'
def save_interview(job_application_id, body):
    response = apploi_request("PUT", f"/applicants/{job_application_id}/interviews", json=body)
    if response.status_code == 200:
        return "saved"
    if response.status_code == 404:
        # The job application doesn't exist or, when cancelling, has no upcoming interview.
        return "not_found"
    # 400: fix the request before sending it again. 403: the job application isn't on
    # your teams. 5xx: see "Retries and errors at a glance".
    raise ApploiError(response.status_code, response.text)


interview = {
    "date": "2026-11-03T14:00:00Z",  # 2:00 PM in "timezone", not UTC
    "duration": "45",  # minutes, as a string
    "timezone": "America/New_York",
    "location": "Virtual",
    "interviewerTeamId": "345678",  # a string, not a number
    "interviewerEmail": "[email protected]",
}
print(save_interview(98765432, interview))

# Cancel: the same fields plus "cancel"
print(save_interview(98765432, {**interview, "cancel": True}))
async function saveInterview(
  jobApplicationId: number,
  body: object,
): Promise<"saved" | "not_found"> {
  const response = await apploiRequest("PUT", `/applicants/${jobApplicationId}/interviews`, {
    body,
  });
  if (response.status === 200) {
    return "saved";
  }
  if (response.status === 404) {
    // The job application doesn't exist or, when cancelling, has no upcoming interview.
    return "not_found";
  }
  // 400: fix the request before sending it again. 403: the job application isn't on
  // your teams. 5xx: see "Retries and errors at a glance".
  throw new ApploiError(response.status, await response.text());
}

const interview = {
  date: "2026-11-03T14:00:00Z", // 2:00 PM in "timezone", not UTC
  duration: "45", // minutes, as a string
  timezone: "America/New_York",
  location: "Virtual",
  interviewerTeamId: "345678", // a string, not a number
  interviewerEmail: "[email protected]",
};
console.log(await saveInterview(98765432, interview));

// Cancel: the same fields plus "cancel"
console.log(await saveInterview(98765432, { ...interview, cancel: true }));
def save_interview(job_application_id, body)
  response = apploi_request("PUT", "/applicants/#{job_application_id}/interviews", body: body)
  return :saved if response.code == "200"
  # The job application doesn't exist or, when cancelling, has no upcoming interview.
  return :not_found if response.code == "404"

  # 400: fix the request before sending it again. 403: the job application isn't on
  # your teams. 5xx: see "Retries and errors at a glance".
  raise ApploiError.new(response.code.to_i, response.body)
end

interview = {
  date: "2026-11-03T14:00:00Z", # 2:00 PM in "timezone", not UTC
  duration: "45", # minutes, as a string
  timezone: "America/New_York",
  location: "Virtual",
  interviewerTeamId: "345678", # a string, not a number
  interviewerEmail: "[email protected]"
}
puts save_interview(98765432, interview)

# Cancel: the same fields plus "cancel"
puts save_interview(98765432, interview.merge(cancel: true))

interviewerEmail must belong to a member of the interviewerTeamId team. Once the interview's time has passed, scheduling again adds a new interview, such as a second round, rather than changing the old one. The interviews reference describes every field and response.

Putting it together

A typical flow for a partner who contacts applicants and books their interviews:

  1. On a schedule, find the team's job applications in the New status (Step 1).
  2. Contact each new applicant through your own service, and record the message in Apploi (Step 5) so recruiters can see it.
  3. Check the applicants you've contacted for replies (Step 5).
  4. When an applicant picks a time, schedule the interview (Step 6). Apploi moves the job application to the team's interview status, if it has one, so it drops out of the New list.
  5. When an applicant isn't moving forward, move the job application to one of the team's closing statuses (Steps 3 and 4), but only if the team has that status.

Messages and interviews are checked against the team that owns each job, so the examples look up a recruiter for each job.team_id rather than using one address for everything. invited_ids and done_ids stand in for your own storage:

  • invited_ids holds the job applications you've contacted. A job application stays in the New list until its status changes, so without it every run would contact the applicant again.
  • done_ids holds the job applications you've already booked or closed. Usually that also takes the job application out of the New list, but not if the team has no matching status. Without done_ids, a later reply would book the interview again, which counts as a reschedule and reruns the team's automations.

The examples act on the applicant's first reply, and record the job application in done_ids only once the action succeeds. If it fails, or raises an error such as a 5xx, nothing is recorded, so the next run tries the same reply again.

# Steps 1 and 2: record an invitation for each New job application on the first page.
# Deliver each text with your own service before recording it. The sender_email
# must be a user on the team that owns each job.
curl -sS -G https://partners.apploi.com/applicants \
  -H "X-Api-Key: $APPLOI_API_KEY" \
  --data-urlencode "team_id=123456" \
  --data-urlencode "state=New" \
  --data-urlencode "limit=50" \
  | jq -r '.data[].id' \
  | while read -r job_application_id; do
      jq -n --arg id "$job_application_id" '{
          external_id: ("invite" + $id),
          message_type: "text",
          message_direction: "to_candidate",
          message_body: "Thanks for applying! Reply with a time that suits you for an interview.",
          status: "DELIVERED",
          vendor_name: "Acme Messaging",
          sender_email: "[email protected]"
        }' \
        | curl -sS -X POST "https://partners.apploi.com/applicants/$job_application_id/message" \
            -H "X-Api-Key: $APPLOI_API_KEY" \
            -H "Content-Type: application/json" \
            -d @-
      echo
    done

# Steps 3 to 5: the requests in Step 5 (read replies), Step 6 (schedule)
# and Steps 3 and 4 (status update).
# The recruiter who handles each team's applicants, by job.team_id. Each must be an
# active Apploi user on that team.
RECRUITER_BY_TEAM = {345678: "[email protected]"}
status_names_by_team = {}


def recruiter_for(job_application):
    team_id = job_application["job"]["team_id"]
    if team_id not in RECRUITER_BY_TEAM:
        raise ValueError(f"No recruiter set up for team {team_id}")
    return RECRUITER_BY_TEAM[team_id]


def invite(job_application):
    # Deliver the text with your own service first, then record it:
    post_message(job_application["id"], {
        "external_id": f"invite{job_application['id']}",
        "message_type": "text",
        "message_direction": "to_candidate",
        "message_body": f"Hi {job_application['person']['first_name']}, thanks for applying! "
        "Reply with a time that suits you for an interview.",
        "status": "DELIVERED",
        "vendor_name": "Acme Messaging",
        "sender_email": recruiter_for(job_application),
    })


def book_interview(job_application, date, timezone):
    return save_interview(job_application["id"], {
        "date": date,
        "duration": "30",
        "timezone": timezone,
        "location": "Virtual",
        "interviewerTeamId": str(job_application["job"]["team_id"]),
        "interviewerEmail": recruiter_for(job_application),
    })


def close_job_application(job_application, status="Not Qualified"):
    if job_application["applicant_state"] == status:
        return True  # already there; sending it again would rerun automations
    team_id = job_application["job"]["team_id"]
    if team_id not in status_names_by_team:
        status_names_by_team[team_id] = get_status_names(team_id)
    if status not in status_names_by_team[team_id]:
        print(f"Team {team_id} has no '{status}' status")
        return False
    updated, not_updated = update_status([job_application["id"]], status)
    for application_id, reason in not_updated.items():
        print(f"{application_id} was not updated: {reason}")
    return bool(updated)


def process_new_job_applications(team_id, invited_ids, done_ids):
    for job_application in list_job_applications(team_id, state="New"):
        if job_application["id"] in done_ids:
            continue  # already booked or closed
        if job_application["id"] not in invited_ids:
            invite(job_application)
            invited_ids.add(job_application["id"])  # and save it
            continue
        replies = new_replies(job_application["id"], set())
        if not replies:
            continue
        reply = replies[0]  # the applicant's first reply
        # Decide what the reply means with your own logic. For example:
        if "not interested" in reply["message_body"].lower():
            done = close_job_application(job_application)
        else:
            # The time the applicant picked
            done = book_interview(job_application, "2026-11-03T14:00:00Z", "America/New_York") == "saved"
        if done:
            done_ids.add(job_application["id"])  # and save it


# Load these from your own storage
invited_ids = set()
done_ids = set()
process_new_job_applications(123456, invited_ids, done_ids)
// The recruiter who handles each team's applicants, by job.team_id. Each must be an
// active Apploi user on that team.
const RECRUITER_BY_TEAM = new Map<number, string>([[345678, "[email protected]"]]);
const statusNamesByTeam = new Map<number, string[]>();

function recruiterFor(jobApplication: JobApplication): string {
  const recruiter = RECRUITER_BY_TEAM.get(jobApplication.job.team_id);
  if (recruiter === undefined) {
    throw new Error(`No recruiter set up for team ${jobApplication.job.team_id}`);
  }
  return recruiter;
}

async function invite(jobApplication: JobApplication): Promise<void> {
  // Deliver the text with your own service first, then record it:
  await postMessage(jobApplication.id, {
    external_id: `invite${jobApplication.id}`,
    message_type: "text",
    message_direction: "to_candidate",
    message_body:
      `Hi ${jobApplication.person.first_name}, thanks for applying! ` +
      "Reply with a time that suits you for an interview.",
    status: "DELIVERED",
    vendor_name: "Acme Messaging",
    sender_email: recruiterFor(jobApplication),
  });
}

async function bookInterview(jobApplication: JobApplication, date: string, timezone: string) {
  return saveInterview(jobApplication.id, {
    date,
    duration: "30",
    timezone,
    location: "Virtual",
    interviewerTeamId: String(jobApplication.job.team_id),
    interviewerEmail: recruiterFor(jobApplication),
  });
}

async function closeJobApplication(
  jobApplication: JobApplication,
  status = "Not Qualified",
): Promise<boolean> {
  if (jobApplication.applicant_state === status) {
    return true; // already there; sending it again would rerun automations
  }
  const teamId = jobApplication.job.team_id;
  if (!statusNamesByTeam.has(teamId)) {
    statusNamesByTeam.set(teamId, await getStatusNames(teamId));
  }
  if (!statusNamesByTeam.get(teamId)!.includes(status)) {
    console.log(`Team ${teamId} has no '${status}' status`);
    return false;
  }
  const { updated, notUpdated } = await updateStatus([jobApplication.id], status);
  for (const [applicationId, reason] of notUpdated) {
    console.log(`${applicationId} was not updated: ${reason}`);
  }
  return updated.length > 0;
}

async function processNewJobApplications(
  teamId: number,
  invitedIds: Set<number>,
  doneIds: Set<number>,
): Promise<void> {
  for (const jobApplication of await listJobApplications(teamId, { state: "New" })) {
    if (doneIds.has(jobApplication.id)) {
      continue; // already booked or closed
    }
    if (!invitedIds.has(jobApplication.id)) {
      await invite(jobApplication);
      invitedIds.add(jobApplication.id); // and save it
      continue;
    }
    const [reply] = await newReplies(jobApplication.id, new Set()); // the applicant's first reply
    if (reply === undefined) {
      continue;
    }
    // Decide what the reply means with your own logic. For example:
    let done: boolean;
    if (reply.message_body.toLowerCase().includes("not interested")) {
      done = await closeJobApplication(jobApplication);
    } else {
      // The time the applicant picked
      done =
        (await bookInterview(jobApplication, "2026-11-03T14:00:00Z", "America/New_York")) ===
        "saved";
    }
    if (done) {
      doneIds.add(jobApplication.id); // and save it
    }
  }
}

// Load these from your own storage
const invitedIds = new Set<number>();
const doneIds = new Set<number>();
await processNewJobApplications(123456, invitedIds, doneIds);
# The recruiter who handles each team's applicants, by job.team_id. Each must be an
# active Apploi user on that team.
RECRUITER_BY_TEAM = { 345678 => "[email protected]" }.freeze
STATUS_NAMES_BY_TEAM = {}

def recruiter_for(job_application)
  team_id = job_application["job"]["team_id"]
  RECRUITER_BY_TEAM.fetch(team_id) { raise ArgumentError, "No recruiter set up for team #{team_id}" }
end

def invite(job_application)
  # Deliver the text with your own service first, then record it:
  post_message(
    job_application["id"],
    external_id: "invite#{job_application['id']}",
    message_type: "text",
    message_direction: "to_candidate",
    message_body: "Hi #{job_application['person']['first_name']}, thanks for applying! " \
                  "Reply with a time that suits you for an interview.",
    status: "DELIVERED",
    vendor_name: "Acme Messaging",
    sender_email: recruiter_for(job_application)
  )
end

def book_interview(job_application, date, timezone)
  save_interview(
    job_application["id"],
    date: date,
    duration: "30",
    timezone: timezone,
    location: "Virtual",
    interviewerTeamId: job_application["job"]["team_id"].to_s,
    interviewerEmail: recruiter_for(job_application)
  )
end

def close_job_application(job_application, status = "Not Qualified")
  # Already there; sending it again would rerun automations
  return true if job_application["applicant_state"] == status

  team_id = job_application["job"]["team_id"]
  STATUS_NAMES_BY_TEAM[team_id] ||= get_status_names(team_id)
  unless STATUS_NAMES_BY_TEAM[team_id].include?(status)
    puts "Team #{team_id} has no '#{status}' status"
    return false
  end
  updated, not_updated = update_status([job_application["id"]], status)
  not_updated.each { |application_id, reason| puts "#{application_id} was not updated: #{reason}" }
  !updated.empty?
end

def process_new_job_applications(team_id, invited_ids, done_ids)
  list_job_applications(team_id, state: "New").each do |job_application|
    next if done_ids.include?(job_application["id"]) # already booked or closed

    unless invited_ids.include?(job_application["id"])
      invite(job_application)
      invited_ids.add(job_application["id"]) # and save it
      next
    end
    reply = new_replies(job_application["id"], Set.new).first # the applicant's first reply
    next unless reply

    # Decide what the reply means with your own logic. For example:
    done =
      if reply["message_body"].downcase.include?("not interested")
        close_job_application(job_application)
      else
        # The time the applicant picked
        book_interview(job_application, "2026-11-03T14:00:00Z", "America/New_York") == :saved
      end
    done_ids.add(job_application["id"]) if done # and save it
  end
end

# Load these from your own storage
invited_ids = Set.new
done_ids = Set.new
process_new_job_applications(123456, invited_ids, done_ids)

Not Qualified is only an example. Teams name their statuses differently, which is why close_job_application checks the team's list first. If a team has no such status, close_job_application returns false, so the job application isn't recorded as done and the next run tries it again. Watch for that message in your logs and pick a status the team does have.

Retries and errors at a glance

Branch on the status code. The Errors guide covers the codes shared across the API. These are the points specific to this workflow:

RequestA 200 that isn't a full successBefore retrying a 5xx or 504
GET /applicants, GET /applicants/{applicant_id}, GET /applicants/applicant-statusesNoneRetry after a few seconds. These only read. For repeated 504s on /applicants, use a smaller limit and updated_after.
PUT /applicants/statusIds missing from the response weren't updatedCheck each job application's applicant_state with GET /applicants, and retry only those that don't have the new status yet. The update can finish after a 504.
GET /applicants/{applicant_id}/messageA body without data, or one that isn't JSONRetry after a few seconds. It only reads.
POST /applicants/{applicant_id}/messageA body without data, one that isn't JSON, or data.status other than successRead the history and check whether the message is already there. There's no duplicate check.
PUT /applicants/{applicant_id}/interviewsNoneRetrying keeps a single interview while it's still upcoming. If the first attempt did go through, though, the retry counts as a reschedule: the job application moves to the team's rescheduled status, and that status's automations run. Check the job application's applicant_state first. Don't retry with a date in the past, because each attempt adds a new interview.

A 400 means the request has to change before it can succeed, so don't retry it unchanged. A 404 on a per-application path often means the job application's applicant_id was sent instead of its id. Don't rely on getting an error, though: a wrong id can also match a different job application.