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:readpermission 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_URLandINTRAY_API_KEYin your server environment. The URL already ends in/v1.- The
message_idfrom the send. If you have not sent an email through the API yet, start with Send a welcome email and receive the replies.
Steps
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.
{
"message_id": "<message ID>",
"conversation": { "id": "<conversation ID>", "...": "..." },
"delivery": { "status": "queued" },
"suppressed": []
}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,
});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;
}Decide what each status means for your app
| Status | What happened | What your app should do |
|---|---|---|
queued | Intray accepted the email and has not sent it yet | Check again later |
sent | Intray handed it to the mail provider | Check again later |
delivery_delayed | The recipient's server deferred it, and the provider keeps trying | Check again later |
delivered | The recipient's server accepted it | Show it as delivered. Stop the regular checks, or check once more a day later if you need to catch a late bounce |
bounced | The recipient's server rejected it | Stop checking and mark the address in your database |
complained | The recipient marked it as spam | Stop checking and stop all email to that address |
failed | Intray could not send it and has given up | Stop checking. Fix the cause before you send a new email |
unknown | Intray cannot tell whether the provider sent it | Do 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.
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.
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 thesuppressedarray in a202response 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
- Send an email through the API to an address you control and save the
message_id. - Call
readDeliveryStatuswith that ID straight away. Expectqueuedorsent. - Call it again after a minute. Expect
delivered, and check that the email is in the mailbox. - 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
| Response | Cause | Fix |
|---|---|---|
401 | The key is missing, malformed, rotated, or revoked | Check INTRAY_API_KEY. After a rotation, deploy the new key |
403 | The key lacks conversations:read | Create a key with that permission. Permissions cannot be edited later |
404 | The message ID is wrong, or the email belongs to an address the key does not cover | Check the stored ID and the addresses on the key |
429 | The key or the workspace used up its request limit | Wait for the Retry-After time, then slow the schedule down |
delivery is null | The ID belongs to an inbound email | Store the message_id from your own send, not an ID from an entry list |
Status stays queued | Intray has not handed the email to the provider yet | Read 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.