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" }All but the last describe the service rather than the address, and none of them is an answer about reachability.
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 — ask for it |
invalid_target | The connector refused the address grammar | No — fix the address |
A 503 is never a verdict on the address. It means the question could not
be put to Apple, so you learned nothing: do not cache it, and do not conclude
the address is bad. The only answer about reachability is a 200 with
available.
invalid_target is the one reason that is about your address rather than the
service, and you should not normally see it — the endpoint validates the same
address grammar before the request leaves it, so a malformed address comes back
400 invalid_address instead. If invalid_target does appear, the connector
build disagrees with this service about what a valid handle looks like. That is
worth reporting, and it is still not evidence the address is off iMessage.
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".
const BASE = process.env.MAPIER_BASE_URL!;
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";
}
// No 503 reason is an answer about the address — every one of them means the
// question never reached Apple.
if (res.status === 503) return "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.
const BASE = process.env.MAPIER_BASE_URL!;
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.
It also narrows the addresses you can pass. Handle check accepts an email; a
recipient target does not, and answers 422 recipient_not_allowed. So
sendIfReachable above is safe for phone numbers only — for an email address a
reachable answer still leaves you with no way to open the thread from this API.
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".