# 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](/docs/guide-welcome-email).

## Steps

<Steps>
  <Step title="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.

    ```json title="202 Accepted"
    {
      "message_id": "<message ID>",
      "conversation": { "id": "<conversation ID>", "...": "..." },
      "delivery": { "status": "queued" },
      "suppressed": []
    }
    ```

    ```ts title="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,
    });
    ```

  </Step>
  <Step title="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.

    <CodeGroup>

    ```ts title="server/intray-delivery.ts"
    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;
    }
    ```

    ```sh title="curl"
    curl "$INTRAY_API_URL/messages/$MESSAGE_ID" \
      -H "Authorization: Bearer $INTRAY_API_KEY"
    ```

    </CodeGroup>

  </Step>
  <Step title="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.

  </Step>
  <Step title="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.

    ```ts title="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.

  </Step>
</Steps>

## 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

| 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](/docs/conversation-api#read-delivery-status).

## Next

- [Send a welcome email and receive the replies](/docs/guide-welcome-email)
- [Reply to or close a conversation from your app](/docs/guide-update-from-app)
