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:

200 OK
{ "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.

AddressResult
+14155550100Accepted
person@example.comAccepted
4155550100Accepted, as +14155550100 — a bare ten-digit number is read as US
(415) 555-0100Accepted, as +14155550100 — formatting is stripped
Person@Example.comAccepted, as person@example.com
not a handle422 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 accepted

When 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:

503 Service Unavailable
{
  "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.

reasonMeaningRetry?
read_in_flightAnother check is already running on that lineYes, shortly
connector_offlineThe line's device is offlineYes, with backoff
deadline_exceededApple did not answer in timeYes
session_closedThe line's connection closed mid-checkYes
response_rejectedThe answer failed validationYes
read_failedThe check failed on the line's side, or Apple had no answer even after a fresh lookupYes
capability_unavailableThe line's device does not support handle checksNot until the line's device supports them
not_configuredHandle checking is not provisioned for this accountNot until it is provisioned — ask for it
invalid_targetThe transport refused the address grammarNo — 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_flight beats 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.

On this page