Retrieves all messages for a specific application. Returns both messages sent to the candidate (outbound) and responses from the candidate (inbound). Messages are ordered by sent date, newest first.
Authentication: Requires valid x-api-key header.
Response Example:
{
"data": [
{
"external_id": "msg7f3a90",
"message_id": "TWVzc2FnZUlkOjU1NTExMDE=",
"message_body": "Thank you for applying! We'd like to schedule an interview.",
"message_direction": "to_candidate",
"message_type": "text",
"sent_at": "Thu, 23 Oct 2025 13:12:47 GMT",
"status": "SENT",
"subject": null
},
{
"external_id": "msg7f3a9c",
"message_id": "TWVzc2FnZUlkOjU1NTExMDI=",
"message_body": "I am available for an interview next week.",
"message_direction": "from_candidate",
"message_type": "text",
"sent_at": "Thu, 23 Oct 2025 13:12:47 GMT",
"status": "DELIVERED",
"subject": null
},
{
"external_id": "msg7f3a91",
"message_id": "TWVzc2FnZUlkOjU1NTExMDU=",
"message_body": "Hey your interview is scheduled for next week.",
"message_direction": "to_candidate",
"message_type": "email",
"sent_at": "Thu, 23 Oct 2025 13:12:47 GMT",
"status": "DELIVERED",
"subject": "Interview Scheduled"
}
]
}| Time | Status | User Agent | |
|---|---|---|---|
Retrieving recent requests… | |||
This endpoint returns every message on one job application. That covers texts and emails in both directions, whether they were recorded through this API or sent by a recruiter in Apploi. To record or send a message, use POST /applicants/{applicant_id}/message.
Before you start:
applicant_idis the job application's id, theidfield of an entry returned by /applicants. It is not theapplicant_idfield from that response, which identifies the person.- Messaging must be turned on for the team. Ask your Apploi contact to enable it. Until it's on, requests for that team's job applications return the error described under When something goes wrong.
For the full workflow around job applications (finding them, updating their status, messaging applicants and scheduling interviews), see the Applicant & Status Management guide.
Examples
The examples read your API key from an environment variable named APPLOI_API_KEY. Before running them, set APPLOI_API_KEY in your own environment to the Partner API key you received from Apploi. The examples do not set it for you, and the key should never be written into your code.
Reading the message history
Replace 98765432 with the id of one of your job applications.
The examples define a get_messages function (getMessages in TypeScript), which the next example reuses. It checks the body as well as the status code, because some errors on this endpoint arrive with a 200 status (see When something goes wrong).
# 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>"
curl -sS https://partners.apploi.com/applicants/98765432/message \
-H "X-Api-Key: $APPLOI_API_KEY" \
-w "\nHTTP status: %{http_code}\n"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"]
def get_messages(job_application_id):
response = requests.get(
f"https://partners.apploi.com/applicants/{job_application_id}/message",
headers={"X-Api-Key": API_KEY},
timeout=30,
)
response.raise_for_status()
try:
body = response.json()
except ValueError:
# A server error can arrive with status 200 and a body that isn't JSON. Retry later.
raise RuntimeError(f"Unexpected response, retry later: {response.text[:200]}")
if "data" not in body:
# Some errors arrive with status 200, for example when messaging isn't enabled.
raise RuntimeError(f"Request failed: {body.get('developer_message')}")
return body["data"] # newest first
for message in get_messages(98765432):
print(message["sent_at"], message["message_direction"], message["message_type"], message["message_body"])// 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;
async function getMessages(jobApplicationId: number): Promise<any[]> {
const response = await fetch(
`https://partners.apploi.com/applicants/${jobApplicationId}/message`,
{ headers: { "X-Api-Key": API_KEY } },
);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}: ${await response.text()}`);
}
const text = await response.text();
let body: any;
try {
body = JSON.parse(text);
} catch {
// A server error can arrive with status 200 and a body that isn't JSON. Retry later.
throw new Error(`Unexpected response, retry later: ${text.slice(0, 200)}`);
}
if (!("data" in body)) {
// Some errors arrive with status 200, for example when messaging isn't enabled.
throw new Error(`Request failed: ${body.developer_message}`);
}
return body.data; // newest first
}
for (const message of await getMessages(98765432)) {
console.log(message.sent_at, message.message_direction, message.message_type, message.message_body);
}require "json"
require "net/http"
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")
def get_messages(job_application_id)
uri = URI("https://partners.apploi.com/applicants/#{job_application_id}/message")
request = Net::HTTP::Get.new(uri)
request["X-Api-Key"] = API_KEY
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 30) do |http|
http.request(request)
end
raise "Request failed with status #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
body = begin
JSON.parse(response.body)
rescue JSON::ParserError
# A server error can arrive with status 200 and a body that isn't JSON. Retry later.
raise "Unexpected response, retry later: #{response.body[0, 200]}"
end
# Some errors arrive with status 200, for example when messaging isn't enabled.
raise "Request failed: #{body['developer_message']}" unless body.key?("data")
body["data"] # newest first
end
get_messages(98765432).each do |message|
puts [message["sent_at"], message["message_direction"], message["message_type"], message["message_body"]].join(" ")
endThe response holds the messages in data, newest first. An application with no messages returns an empty list.
{
"data": [
{
"message_id": "TWVzc2FnZUlkOjU1NTEyMzQ=",
"external_id": "msg7f3a9c",
"message_direction": "from_candidate",
"message_type": "text",
"message_body": "Yes, Tuesday at 10am works for me.",
"subject": null,
"status": "received",
"sent_at": "Tue, 29 Sep 2026 14:05:12 GMT"
},
{
"message_id": "TWVzc2FnZUlkOjU1NTEyMDE=",
"external_id": "msg7f3a91",
"message_direction": "to_candidate",
"message_type": "email",
"message_body": "Thanks for applying. Could you come in for an interview on Tuesday at 10am?",
"subject": "Interview invitation",
"status": "sent",
"sent_at": "Tue, 29 Sep 2026 13:40:03 GMT"
}
]
}What the fields hold:
message_directionisto_candidatefor messages sent to the applicant andfrom_candidatefor the applicant's replies.message_typeistextoremail.subjectis only set on emails.message_idis Apploi's id for the message. Treat it as an opaque string. It is the reliable way to tell messages apart.external_idis the id the sender supplied. It can benull, for example on messages a recruiter sent from Apploi. If the id you sent contained a hyphen, only the part after the first hyphen is returned, somsg-7f3a-9ccomes back as7f3a-9c. When Apploi delivered a text for you,external_idholds the delivery provider's id rather than yours.statusis free text. On messages recorded through this API it is whatever the sender sent, such asSENTorDELIVERED. On emails Apploi sent, it issent, orfailedif sending failed. On texts Apploi sent, it comes from the delivery provider.sent_atis a UTC date in HTTP date format, such asTue, 29 Sep 2026 14:05:12 GMT, not ISO 8601. Python'semail.utils.parsedate_to_datetime, JavaScript'snew Date(...)and Ruby'sTime.httpdateall parse it.
Picking out new replies
The endpoint has no filter, so each call returns the whole history. To act on new replies from the applicant, keep the message_ids you have already handled and skip them on the next call.
These examples reuse get_messages (Python and Ruby) / getMessages (TypeScript) from the example above.
# 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>"
# Only the applicant's replies (requires jq)
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):
replies = [
message
for message in get_messages(job_application_id)
if message["message_direction"] == "from_candidate"
and message["message_id"] not in handled_message_ids
]
return list(reversed(replies)) # oldest first, in the order they were sent
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 itasync function newReplies(jobApplicationId: number, handledMessageIds: Set<string>): Promise<any[]> {
const messages = await getMessages(jobApplicationId);
return messages
.filter(
(message) =>
message.message_direction === "from_candidate" && !handledMessageIds.has(message.message_id),
)
.reverse(); // oldest first, in the order they were sent
}
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
}require "set"
def new_replies(job_application_id, handled_message_ids)
get_messages(job_application_id)
.select { |message| message["message_direction"] == "from_candidate" }
.reject { |message| handled_message_ids.include?(message["message_id"]) }
.reverse # oldest first, in the order they were sent
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
endWhen something goes wrong
Some errors on this endpoint arrive with a 200 status, so check for data as well as the status code. They include the errors in the table below, whose body has a developer_message and no data. They also include server errors other than a plain 500, such as a 502 or 503 while Apploi is deploying, whose body may not be JSON at all.
| Status | Body | Cause | What to do |
|---|---|---|---|
200 | {"developer_message": "422 Unprocessable Entity: Feature is not enabled for this team", "extra": null} | Messaging isn't turned on for the team that owns the job. | Ask your Apploi contact to enable it. Don't retry. |
200 | {"developer_message": "422 Unprocessable Entity: Invalid permission for API!!!", "extra": null} | The job application belongs to a team your API key can't reach. | Check the id. Use one returned by /applicants for a team your key can read. |
200 | Any other body without data, or a body that isn't JSON | A temporary error on Apploi's side. | Wait, then retry with backoff. |
404 | {"developer_message": "404 Not Found: Application not found or candidate not found", "extra": null} | No job application has that id. | Check that you sent the job application's id, not its applicant_id. Don't retry. |
403 | {"message": "Forbidden"} | The X-Api-Key header is missing or invalid. | See Errors. |
5xx | Varies | Something failed on Apploi's side. | Wait, then retry with backoff. This endpoint only reads, so retrying is safe. |
403The X-Api-Key header is missing or invalid.
404No job application has this id. Send the job application's id, not its applicant_id.
405405 response

