Checking reachability
Asking whether an address can receive iMessages before you send to it.
An address that is not on iMessage does not bounce. You send to it, the API
returns 202, the command settles, and nothing arrives — because "succeeded"
means iMessage accepted the send, not that it reached anyone. Nothing in the
send path tells you the number was Android-only or the Apple ID never existed.
POST /v1/handle-check is how you find out first. It
asks Apple's directory whether an address is reachable, and answers in the HTTP
response — no command, no queue, nothing on the stream.
Asking
The address goes in the body, not the URL. That is deliberate: reachability queries are personal data, and a path or query string ends up in access logs everywhere along the way.
curl $MAPIER_BASE_URL/v1/handle-check \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "address": "+14155550100" }'A reachable address comes back like this:
{ "address": "+14155550100", "available": true, "idStatus": 1 }Address format is strict
The same grammar applies here as everywhere else in the API: E.164 phone numbers, or lowercase, NFC-normalised email addresses. There is no normalising step that will forgive a human-formatted number for you.
| Address | Result |
|---|---|
+14155550100 | Accepted |
person@example.com | Accepted |
4155550100 | 400 invalid_address — no leading + |
(415) 555-0100 | 400 invalid_address — not E.164 |
Person@Example.com | 400 invalid_address — must be lowercase |
not a handle | 400 invalid_address |
Normalise once, at the edge of your own system, and store the canonical form.
Doing it at each call site is how one path ends up sending
Person@Example.com.
Branch on available, never on idStatus
available is the boolean you make decisions with. idStatus is Apple's raw status integer,
passed through unchanged. It has been observed as 0 and 1, but only its sign is
contractual — a future value could be any positive integer for a reachable address. Code that
tests idStatus === 1 will eventually be wrong; code that tests available will not.
const { available } = await res.json();
if (!available) return; // not on iMessageWhen the answer is neither
The check runs against a live Mac, so it can be unavailable in ways a pure API
cannot. Those come back as 503 with a reason:
{ "error": "handle_check_unavailable", "reason": "read_in_flight" }Every reason describes the service rather than the address — except one.
reason | Meaning | Retry? |
|---|---|---|
read_in_flight | Another check is already running on that Mac | Yes, shortly |
connector_offline | The Mac is not connected | Yes, with backoff |
deadline_exceeded | Apple did not answer in time | Yes |
session_closed | The session ended mid-read | Yes |
response_rejected | The answer failed validation | Yes |
read_failed | The read failed on the host | Yes |
capability_unavailable | The Mac does not support handle checks | Not until the host changes |
not_configured | Handle checking is not provisioned for this account | Not until it is provisioned — contact Mapier |
invalid_target | Apple definitively refused the address | No — this is the answer |
invalid_target is the only one that carries information about the address. It
is not a fault in the service; it is an answer, and the answer is that the
address will not work. Treat it exactly as available: false. Every other
reason means you learned nothing, so do not cache it and do not conclude the
address is bad.
Check serially, and cache
Reads are single-flight per Mac: one check runs at a time, and a second
concurrent request gets read_in_flight rather than queueing. Firing off
twenty checks in parallel to validate an import will mostly produce 503s.
Two habits follow from that:
- Check one address at a time. A simple sequential loop with a short retry
on
read_in_flightbeats any concurrency here. - Cache the result. Whether a number is on iMessage changes rarely — on the order of someone switching phones — so a result cached for hours or days is still accurate, and it keeps you clear of the 30-requests-per-minute limit on this endpoint. See Rate limits.
Checking before sending
Putting it together: a helper that returns a three-valued answer, because "I could not find out" is genuinely different from "no".
type Reachability = "yes" | "no" | "unknown";
async function checkAddress(address: string): Promise<Reachability> {
const res = await fetch(`${BASE}/v1/handle-check`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAPIER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ address }),
});
if (res.status === 200) {
const { available } = await res.json();
return available ? "yes" : "no";
}
if (res.status === 503) {
const { reason } = await res.json();
// The only 503 that is an answer rather than an outage.
return reason === "invalid_target" ? "no" : "unknown";
}
throw new Error(`handle-check failed: ${res.status}`);
}Then send only on a definite yes, and decide deliberately what unknown
should do — sending anyway is often the right call for a low-stakes message,
and the wrong one for an onboarding flow that assumes the message landed.
async function sendIfReachable(address: string, text: string) {
const reachable = await checkAddress(address);
if (reachable === "no") return { sent: false, reason: "not_on_imessage" };
if (reachable === "unknown") return { sent: false, reason: "check_unavailable" };
await fetch(`${BASE}/v1/commands/message.send`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAPIER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
target: { kind: "recipient", recipientExternalId: address },
payload: { text },
}),
});
return { sent: true };
}Note that the send above uses a recipient target, which is plain text only and is not retry-safe — a repeated call sends a second message. See Addressing a conversation for why, and for the pattern of switching to a conversation target once the thread exists.
A passing check is not a delivery guarantee. It says Apple believes the address can receive
iMessages right now. There are no delivery receipts in this API, so a succeeded settlement still
means "iMessage accepted the send", not "it appeared on their phone".