Checking reachability
Asking whether an address can receive on a line's channel 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 the transport behind one of your lines — on an iMessage line, 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. Name the channel when your project has lines on
more than one — the same address can have different answers on two.
curl https://api.mapier.ai/v1/handle-check \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "address": "+14155550100", "channel": "imessage" }'A reachable address comes back like this:
{ "address": "+14155550100", "channel": "imessage", "available": true, "reason": null }Address format
The same grammar applies here as everywhere else in the API: a phone number,
whose canonical form is E.164, or an email address, canonically lowercase. On
an iMessage line the platform normalises what you send before it asks:
formatting is stripped from a number, a bare ten-digit number is read as a US
one, and an email address is lowercased. The address in the answer is the
normalised form.
| Address | Result |
|---|---|
+14155550100 | Accepted |
person@example.com | Accepted |
4155550100 | Accepted, as +14155550100 — a bare ten-digit number is read as US |
(415) 555-0100 | Accepted, as +14155550100 — formatting is stripped |
Person@Example.com | Accepted, as person@example.com |
not a handle | 422 validation_failed at address |
Still normalise once, at the edge of your own system, and store the canonical form the answer hands back: the ten-digit rule is an assumption that the number is American, and a number from anywhere else needs its country code.
Branch on available — and null is not false
available is the answer you make decisions with, and it has three values. true and false are
the transport's yes and no. null means the transport cannot answer synchronously — the honest
answer from one with no reachability probe — and rendering it as unreachable is a claim the
platform refused to make. reason rides beside it, and is not by itself a no.
On an iMessage line, false means Apple's directory said the address is not on
iMessage. A lookup Apple cannot answer is never a false: it comes back as a
503 line_unavailable with read_failed, below.
const { available } = await res.json();
if (available === false) return; // the transport said no
// available === null: nobody can tell until a message is acceptedWhen 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": { "code": "line_unavailable", "message": "…", "details": { "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 line | Yes, shortly |
connector_offline | The line's device is offline | Yes, with backoff |
deadline_exceeded | Apple did not answer in time | Yes |
session_closed | The line's connection closed mid-check | Yes |
response_rejected | The answer failed validation | Yes |
read_failed | The check failed on the line's side, or Apple had no answer even after a fresh lookup | Yes |
capability_unavailable | The line's device does not support handle checks | Not until the line's device supports them |
not_configured | Handle checking is not provisioned for this account | Not until it is provisioned — ask for it |
invalid_target | The transport 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 or Apple could not answer it, 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 parses the address
by the channel's own rules before the request leaves it, so a malformed address
comes back 422 validation_failed at address instead. If invalid_target does appear, the transport
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
Checks run one at a time per line: 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 = "https://api.mapier.ai";
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();
if (available === null) return "unknown";
return available ? "yes" : "no";
}
// A 503 is never an answer about the address.
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 = "https://api.mapier.ai";
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", channel: "imessage", recipientExternalId: address },
payload: { text },
}),
});
return { sent: true };
}Note that the send above carries no Idempotency-Key, so a repeated call is a
second message: a key is minted for each request that sends none. Derive one
from the work — welcome-${address}, say — and send it on every attempt, and a
retry is never delivered twice. See
Idempotency and retries. The send also uses a recipient
target, which carries plain text alone until the contact answers; see
Addressing a conversation for the pattern of switching to a
conversation target once the transport has confirmed the thread, where an
effect, a subject, a reply, a sticker or a rich link can follow.
It also narrows the addresses you can pass. Handle check accepts an email; a
send that opens a thread on a live iMessage line does not, and answers
422 validation_failed at the target field you wrote it at, with nothing
written. So sendIfReachable above is safe for phone
numbers only on a live line — for an email address a reachable answer still
leaves you with no way to open the thread from this API. In test an email
address sends.
A passing check is not a delivery guarantee. It says Apple believes the address can receive
iMessages right now. A succeeded settlement means the transport took the send, not that it
appeared on their phone — that is a receipt frame, and a provider's "not delivered" is a
delivery_failed frame; see Receipts and delivery verdicts.