Check whether an email was delivered

Use this guide when your app sends email through Intray and needs to know what happened to it afterwards. Typical reasons are showing "Delivered" in an admin screen, stopping email to an address that bounced, and deciding whether it is safe to send again. The result is a small function that reads the status of one email and a schedule for calling it.

Intray does not send webhooks to your server yet. Your app finds out about delivery by reading the message again, so this guide uses polling.

Before you start

  • A key with the conversations:read permission on the address that sent the email. Create it in Settings, Developers. The key belongs in your server's secret store and never in browser code.
  • INTRAY_API_URL and INTRAY_API_KEY in your server environment. The URL already ends in /v1.
  • The message_id from the send. If you have not sent an email through the API yet, start with Send a welcome email and receive the replies.

Steps

1

Save the message ID when you send

A send returns 202 Accepted. That status means Intray put the email in its queue. It does not mean the email reached the recipient. Store message_id on your own record so that you can look the email up later.

202 Accepted
{
  "message_id": "<message ID>",
  "conversation": { "id": "<conversation ID>", "...": "..." },
  "delivery": { "status": "queued" },
  "suppressed": []
}
server/send-welcome.ts
const result = await response.json();
if (!response.ok) throw new Error(result.error.message);

await db.emails.update(email.id, {
  intrayMessageId: result.message_id,
  deliveryStatus: result.delivery.status,
});
2

Read the status of one email

GET /messages/{id} returns the email with its current delivery status. On outbound email delivery.status is always one of the values in the table below.

export type DeliveryStatus =
  | "queued"
  | "sent"
  | "unknown"
  | "delivery_delayed"
  | "delivered"
  | "failed"
  | "bounced"
  | "complained";

export async function readDeliveryStatus(
  messageId: string
): Promise<DeliveryStatus> {
  const response = await fetch(
    `${process.env.INTRAY_API_URL}/messages/${messageId}`,
    { headers: { Authorization: `Bearer ${process.env.INTRAY_API_KEY}` } }
  );
  const message = await response.json();
  if (!response.ok) throw new Error(message.error.message);
  // `delivery` is null on inbound email. This guide reads outbound email.
  if (!message.delivery) throw new Error("Not an outbound email");
  return message.delivery.status;
}
3

Decide what each status means for your app

StatusWhat happenedWhat your app should do
queuedIntray accepted the email and has not sent it yetCheck again later
sentIntray handed it to the mail providerCheck again later
delivery_delayedThe recipient's server deferred it, and the provider keeps tryingCheck again later
deliveredThe recipient's server accepted itShow it as delivered. Stop the regular checks, or check once more a day later if you need to catch a late bounce
bouncedThe recipient's server rejected itStop checking and mark the address in your database
complainedThe recipient marked it as spamStop checking and stop all email to that address
failedIntray could not send it and has given upStop checking. Fix the cause before you send a new email
unknownIntray cannot tell whether the provider sent itDo not resend automatically. See the section on unknown below

A status only moves forward. A late delivered event never replaces bounced, and an email that was delivered can still become bounced or complained afterwards, because some servers reject mail after accepting it and a recipient can report spam days later.

4

Poll on a schedule that ends

Run the check from a background job. A status changes most often soon after the send, so check often at first and then slow down. The schedule below makes nine reads per email over 24 hours.

server/jobs/check-delivery.ts
import { readDeliveryStatus } from "../intray-delivery";

// Minutes after the send at which to check.
const CHECK_AT_MINUTES = [1, 5, 15, 60, 180, 360, 720, 1080, 1440];
const SETTLED = new Set(["delivered", "bounced", "complained", "failed"]);

export async function checkDelivery(emailId: string, attempt: number) {
  const email = await db.emails.get(emailId);
  const status = await readDeliveryStatus(email.intrayMessageId);
  await db.emails.update(emailId, { deliveryStatus: status });

  if (status === "bounced" || status === "complained") {
    await db.users.update(email.userId, {
      emailBlockedReason: status,
      emailBlockedAt: new Date(),
    });
  }

  const next = CHECK_AT_MINUTES[attempt + 1];
  if (SETTLED.has(status) || next === undefined) return;
  await jobs.schedule("checkDelivery", {
    emailId,
    attempt: attempt + 1,
    runAt: new Date(email.sentAt.getTime() + next * 60_000),
  });
}

Reads count toward the request limit of 60 per minute for each key and 600 per minute for the workspace. If you send many emails at once, spread the checks out or run them through a queue with a rate limit. A 429 response includes Retry-After: 60, and the job should wait that long before it reads again.

Why unknown must not be resent automatically

unknown means Intray cannot tell whether the mail provider took the email, so the email may have gone out. Intray never sends it a second time on its own, because a duplicate is worse than a delay for most email.

Treat unknown the same way in your app. Keep checking on the normal schedule, because a later event from the provider can move the status to sent and then to delivered. If the status is still unknown when the schedule ends, have a person decide. When you do send again, send a new email with a new Idempotency-Key. Repeating the old request with the old key returns the original response and sends nothing.

What suppression means

Intray keeps a list of recipients that your workspace must not email. An address is added when an email to it has a permanent bounce, when the recipient reports spam, when the recipient uses the opt-out link, or when someone on your team blocks it by hand in Settings. The list applies to the whole workspace, across every key and every address.

Your app meets suppression in two places.

  • At send time. If the recipient is on the list, the send is refused with 422. The error message names the suppressed address. The API accepts one recipient for each email during the pilot, so the suppressed array in a 202 response is empty in practice.
  • After the email was queued. If the recipient is added to the list while the email waits in the queue, Intray stops it before it reaches the provider. The status becomes failed.

A suppressed address stays suppressed until someone removes it. Only a block that a team member added by hand can be removed in Settings. Bounces, complaints, and opt-outs cannot be removed there.

What to store in your own database

Record bounced and complained against the address in your own data, as the polling job above does. Skip those addresses before you call Intray. That avoids a 422 for every later send, and it lets your product tell the user that their email address needs attention. For a bounce, ask the user to confirm or change the address. For a complaint, do not email the address again, even after the user changes other settings.

Check that it works

  1. Send an email through the API to an address you control and save the message_id.
  2. Call readDeliveryStatus with that ID straight away. Expect queued or sent.
  3. Call it again after a minute. Expect delivered, and check that the email is in the mailbox.
  4. Open the conversation in Intray. The delivery state under the email matches what the API returned.

Do not test bounces by sending to invented addresses on real domains. Bounces count against your workspace's sending health.

When it fails

ResponseCauseFix
401The key is missing, malformed, rotated, or revokedCheck INTRAY_API_KEY. After a rotation, deploy the new key
403The key lacks conversations:readCreate a key with that permission. Permissions cannot be edited later
404The message ID is wrong, or the email belongs to an address the key does not coverCheck the stored ID and the addresses on the key
429The key or the workspace used up its request limitWait for the Retry-After time, then slow the schedule down
delivery is nullThe ID belongs to an inbound emailStore the message_id from your own send, not an ID from an entry list
Status stays queuedIntray has not handed the email to the provider yetRead can_send for the address with GET /addresses. If it is true and the status has not moved after an hour, send us the message ID

Every error carries a request_id. Include it when you ask us about a problem. The full list of fields and status codes is in the Conversation API reference.

Next