Creates a new message for a specific application. Messages can be sent to the candidate (outbound) or recorded as responses from the candidate (inbound). Supports both text (SMS) and email message types.
Message Direction:
- 'to_candidate': Outbound messages sent to the candidate (e.g., from AI system)
- 'from_candidate': Inbound messages/responses from the candidate
Message Delivery Control:
is_sent_by_vendor: default true if not sent in payloadis_sent_by_vendor=true: Vendor handles message deliveryis_sent_by_vendor=false: Apploi sends the message via SMS/Email infrastructure
Email Requirements: When message_type is 'email', the 'subject' field is required.
Authentication: Requires valid x-api-key header.
| Time | Status | User Agent | |
|---|---|---|---|
Retrieving recent requests… | |||
This endpoint adds a text or email message to a job application's message history in Apploi. It covers three cases:
- recording a message you have already sent to the applicant,
- asking Apploi to send a message to the applicant,
- recording a reply from the applicant that you received.
To read the history back, use GET /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.
- Every request needs a
sender_email, including replies from the applicant. 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.
Who delivers the message
is_sent_by_vendor decides whether Apploi only records the message or also delivers it:
is_sent_by_vendor | What Apploi does | Allowed directions |
|---|---|---|
true (the default) | Records the message in the history. You deliver it, or you already have. | to_candidate and from_candidate |
false | Records the message and delivers it. A text goes from an Apploi number to the applicant's mobile number. An email goes to the applicant's email address, and replies go to the user who created the job. | to_candidate only |
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.
Replace 98765432 with the id of one of your job applications and [email protected] with the email address of a user on its team. Set vendor_name to your company's name.
Recording a text you sent
Send "is_sent_by_vendor": true (or leave it out) for a message you deliver yourself.
The examples define a post_message function (postMessage in TypeScript), which the later examples reuse. It checks the body as well as the status code, because some errors on this endpoint arrive with a 200 status (see What the response means).
# 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 -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]"
}'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 post_message(job_application_id, message):
response = requests.post(
f"https://partners.apploi.com/applicants/{job_application_id}/message",
headers={"X-Api-Key": API_KEY},
json=message,
timeout=30,
)
if not response.ok:
# 400: fix the request before sending it again. 5xx: see "Retrying safely".
raise RuntimeError(f"Request failed with status {response.status_code}: {response.text}")
try:
body = response.json()
except ValueError:
# A server error can arrive with status 200 and a body that isn't JSON. See "Retrying safely".
raise RuntimeError(f"Unexpected response: {response.text[:200]}")
result = body.get("data")
if not result or result.get("status") != "success":
# Some errors arrive with status 200. See "What the response means".
raise RuntimeError(f"Message not accepted: {body}")
return result
recorded = 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]",
})
print(recorded["message"]) # "Message updated successfully"// 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 postMessage(jobApplicationId: number, message: object): Promise<any> {
const response = await fetch(
`https://partners.apploi.com/applicants/${jobApplicationId}/message`,
{
method: "POST",
headers: { "X-Api-Key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify(message),
},
);
if (!response.ok) {
// 400: fix the request before sending it again. 5xx: see "Retrying safely".
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. See "Retrying safely".
throw new Error(`Unexpected response: ${text.slice(0, 200)}`);
}
if (body.data?.status !== "success") {
// Some errors arrive with status 200. See "What the response means".
throw new Error(`Message not accepted: ${JSON.stringify(body)}`);
}
return body.data;
}
const recorded = 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]",
});
console.log(recorded.message); // "Message updated successfully"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 post_message(job_application_id, message)
uri = URI("https://partners.apploi.com/applicants/#{job_application_id}/message")
request = Net::HTTP::Post.new(uri)
request["X-Api-Key"] = API_KEY
request["Content-Type"] = "application/json"
request.body = JSON.generate(message)
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 30) do |http|
http.request(request)
end
# 400: fix the request before sending it again. 5xx: see "Retrying safely".
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. See "Retrying safely".
raise "Unexpected response: #{response.body[0, 200]}"
end
result = body["data"]
# Some errors arrive with status 200. See "What the response means".
raise "Message not accepted: #{body}" unless result.is_a?(Hash) && result["status"] == "success"
result
end
recorded = 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]"
})
puts recorded["message"] # "Message updated successfully"Having Apploi send an email
Send "is_sent_by_vendor": false to have Apploi deliver the message. An email needs a subject. Without one, the request fails with a 200 status (see What the response means).
Apploi sets status itself once it has queued the email, so the value you send is replaced.
# 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 -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": "msg7f3a91",
"message_type": "email",
"message_direction": "to_candidate",
"subject": "Interview invitation",
"message_body": "Thanks for applying. Could you come in for an interview on Tuesday at 10am?",
"status": "QUEUED",
"vendor_name": "Acme Messaging",
"is_sent_by_vendor": false,
"sender_email": "[email protected]"
}'sent = post_message(98765432, {
"external_id": "msg7f3a91",
"message_type": "email",
"message_direction": "to_candidate",
"subject": "Interview invitation", # required for email
"message_body": "Thanks for applying. Could you come in for an interview on Tuesday at 10am?",
"status": "QUEUED",
"vendor_name": "Acme Messaging",
"is_sent_by_vendor": False, # Apploi delivers it
"sender_email": "[email protected]",
})
print(sent["message"]) # "Message sent successfully"const sent = await postMessage(98765432, {
external_id: "msg7f3a91",
message_type: "email",
message_direction: "to_candidate",
subject: "Interview invitation", // required for email
message_body: "Thanks for applying. Could you come in for an interview on Tuesday at 10am?",
status: "QUEUED",
vendor_name: "Acme Messaging",
is_sent_by_vendor: false, // Apploi delivers it
sender_email: "[email protected]",
});
console.log(sent.message); // "Message sent successfully"sent = post_message(98765432, {
external_id: "msg7f3a91",
message_type: "email",
message_direction: "to_candidate",
subject: "Interview invitation", # required for email
message_body: "Thanks for applying. Could you come in for an interview on Tuesday at 10am?",
status: "QUEUED",
vendor_name: "Acme Messaging",
is_sent_by_vendor: false, # Apploi delivers it
sender_email: "[email protected]"
})
puts sent["message"] # "Message sent successfully"To have Apploi send a text instead, set message_type to text and leave out subject. The applicant needs a mobile number in Apploi. Without one, the request returns 400 (see Retrying safely). Once the text is sent, its external_id in the history is replaced by the delivery provider's id.
Recording a reply from the applicant
Record a reply the applicant sent you with "message_direction": "from_candidate". Leave out is_sent_by_vendor or set it to true: Apploi doesn't deliver replies, and false is refused.
sender_email is still required. Use the recruiter the applicant was replying to.
# 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 -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": "msg7f3a9c",
"message_type": "text",
"message_direction": "from_candidate",
"message_body": "Yes, Tuesday at 10am works for me.",
"status": "received",
"vendor_name": "Acme Messaging",
"sender_email": "[email protected]"
}'reply = post_message(98765432, {
"external_id": "msg7f3a9c",
"message_type": "text",
"message_direction": "from_candidate",
"message_body": "Yes, Tuesday at 10am works for me.",
"status": "received",
"vendor_name": "Acme Messaging",
"sender_email": "[email protected]", # the recruiter the applicant replied to
})const reply = await postMessage(98765432, {
external_id: "msg7f3a9c",
message_type: "text",
message_direction: "from_candidate",
message_body: "Yes, Tuesday at 10am works for me.",
status: "received",
vendor_name: "Acme Messaging",
sender_email: "[email protected]", // the recruiter the applicant replied to
});reply = post_message(98765432, {
external_id: "msg7f3a9c",
message_type: "text",
message_direction: "from_candidate",
message_body: "Yes, Tuesday at 10am works for me.",
status: "received",
vendor_name: "Acme Messaging",
sender_email: "[email protected]" # the recruiter the applicant replied to
})What the response means
Some failures on this endpoint arrive with a 200 status. They include the 200 rows below, and also 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. Treat a request as successful only when the body's data.status is success.
A successful response looks like this:
{
"data": {
"status": "success",
"message": "Message updated successfully"
}
}message is Message updated successfully when Apploi only recorded the message and Message sent successfully when Apploi is delivering it.
| Status | Body | Cause | What to do |
|---|---|---|---|
200 | data.status is success | The message was saved. | Nothing. |
200 | data.status is failed, with "message": "This operation is not allowed, because is_sent_by_vendor is false" | is_sent_by_vendor was false on a from_candidate message. Nothing was saved. | Send it again without is_sent_by_vendor. |
200 | {"developer_message": "422 Unprocessable Entity: Subject is required for email messages", "extra": null} | An email was sent without a subject. | Add a subject. |
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. |
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. |
200 | Any other body without data, or a body that isn't JSON | A temporary error on Apploi's side. | See Retrying safely. |
400 | {"message": "Invalid request body"} | A required field is missing, a value isn't one of the allowed options, or is_sent_by_vendor isn't true or false. | Check the body against the schema on this page. |
400 | {"developer_message": "400 Bad Request: sender_email is required.", "extra": null} | sender_email is missing. | Add it. |
400 | {"developer_message": "400 Bad Request: No user found with the provided sender_email.", "extra": null} | sender_email doesn't belong to an active user on the team that owns the job. | Use the email address of a user on that team. |
400 | {"developer_message": "400 Bad Request: Missing phone number for SMS message", "extra": null} | Apploi was asked to send a text, but the applicant has no mobile number. | Send an email instead, or deliver the text yourself. See Retrying safely. |
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. |
403 | {"message": "Forbidden"} | The X-Api-Key header is missing or invalid. | See Errors. |
5xx | Varies | Something failed on Apploi's side. | See Retrying safely. |
Field limits
A value longer than its limit can cause a 500 error.
external_id: up to 36 characters. It comes back on GET /applicants/{applicant_id}/message. If it contains a hyphen, only the part after the first hyphen comes back, so an id without hyphens is easiest to match.status: up to 16 characters.subject: up to 255 characters.- Send only the fields described in the body schema on this page. Other fields can cause a
500error.
Retrying safely
This endpoint doesn't detect duplicates, so sending the same message twice records it twice.
When Apploi delivers a message ("is_sent_by_vendor": false), the message is saved before delivery is attempted. If delivery then fails, the request returns an error (a 400 for a missing phone number, or a 5xx), but the message is still in the history with status set to failed.
Before retrying after a 5xx, after a 200 with no data that isn't one of the errors in the table above, or after a 400 on a message Apploi was delivering, read the history with GET /applicants/{applicant_id}/message and check whether the message is already there:
- Messages you deliver, emails Apploi sends, and texts Apploi failed to send keep your
external_id. Look for it. If the match hasstatusset tofailed, Apploi didn't deliver it, so treat it as not sent. After a400for a missing phone number, sending the same text again fails the same way: deliver it yourself, or send an email instead. - Texts Apploi sent successfully don't: their
external_idis replaced by the delivery provider's id. Look instead for ato_candidatetext with the samemessage_bodyand asent_atwithin the last few minutes. A5xxdoesn't prove the text wasn't sent. A request that takes longer than 29 seconds returns504even if the text goes out afterwards.
Send the message again only if it isn't there, or if you intend a second attempt.
400400 response
403403 response
404404 response
405405 response

