# Connect Zendesk to email with Nylas

Source: https://developer.nylas.com/docs/cookbook/use-cases/sync/sync-zendesk-email/

Zendesk's native email connector ties tickets to one support address per brand. If you also need to act on a shared mailbox you don't host in Zendesk, a connected sales inbox, or a Microsoft 365 group, you have to bridge the gap yourself. This recipe wires inbound mail straight into the Zendesk Tickets API: a customer writes in, a ticket appears, and an agent's reply goes back out through the same mailbox the customer wrote to.

The whole loop runs on two Nylas primitives: a `message.created` webhook for the inbound side and the send endpoint for the outbound side. Zendesk's `POST /api/v2/tickets` does the ticket creation in between.

## How the email-to-ticket loop works

A Zendesk email integration built on Nylas has two halves. Inbound: a `message.created` webhook fires when mail arrives, your handler maps the message to a ticket, and `POST /api/v2/tickets` creates it. Outbound: when an agent replies in Zendesk, you send that reply with `reply_to_message_id` set, so it threads correctly in the customer's client.

```
customer email ─▶ message.created webhook ─▶ map fields ─▶ POST /api/v2/tickets
                                                                   │
agent reply in Zendesk ─▶ your handler ─▶ messages/send (reply_to_message_id) ─▶ customer
```

Each half is independent. If the outbound path breaks, tickets still get created; the inbound path keeps running. All 6 providers (Google, Microsoft, Yahoo, iCloud, IMAP, and EWS) flow through the same code because the Email API returns one message schema across every connected account, and the `message.created` webhook delivers up to 1 MB of message data per notification.

## Why route through Nylas instead of Zendesk's email connector?

Zendesk's built-in connector forwards or polls a single support address and converts those messages into tickets. The Email API gives you the raw message object and full send control over any connected mailbox across all 6 providers, which matters in three cases.

- You want tickets from a mailbox Zendesk doesn't own, like a Microsoft 365 shared mailbox or a `sales@` alias, without forwarding rules that strip headers.
- You need the `message.created` payload (headers, `reply_to`, `thread_id`) to drive routing logic before a ticket exists.
- You want replies to thread in the customer's mail client using `reply_to_message_id`, not arrive as a fresh email.

If your only inbox is a single Zendesk-hosted support address, the [native Zendesk email setup](https://support.zendesk.com/hc/en-us/articles/4408832543770) is simpler and you don't need this. The bridge earns its keep once mail lives outside Zendesk.

## Set up the message.created webhook

The inbound half starts with a webhook subscription on the `message.created` trigger, which Nylas fires within seconds of mail landing in any connected grant. The request below registers your HTTPS endpoint and returns a `webhook_secret` you use to verify every notification. One subscription covers all grants in your application.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/webhooks/' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "trigger_types": ["message.created"],
    "description": "Zendesk ticket bridge",
    "webhook_url": "<YOUR_WEBHOOK_URL>",
    "notification_email_addresses": ["you@example.com"]
  }'
```

Nylas verifies the endpoint with a one-time `challenge` query parameter that your server must echo back in a `200` response within 10 seconds. For the signature verification handler and challenge logic, follow the [monitor an inbox for support tickets](/docs/cookbook/use-cases/ingest/monitor-inbox-support-tickets/) recipe, then return here for the Zendesk-specific mapping. Don't duplicate the handler; it's the same plumbing.

## Map an inbound email to a Zendesk ticket

This is the core of the integration. Zendesk's create-ticket call is `POST /api/v2/tickets`, and it expects a `requester` object, a `subject`, and a `comment` whose `body` becomes the ticket's first message. You map 5 message fields onto that shape, and the call returns a `201` status on success. The table below is the contract; everything else is glue.

| Nylas message field | Zendesk ticket field | Notes |
| --- | --- | --- |
| `from[0].email` / `from[0].name` | `requester.email` / `requester.name` | Zendesk creates the user if they don't exist |
| `subject` | `subject` | Falls back to "(no subject)" when empty |
| `body` (or `snippet`) | `comment.body` | Becomes the first public comment |
| `id` | `external_id` | Links the ticket to the Nylas message |
| `date` | (custom field, optional) | Unix seconds; convert to ISO for display |

The handler below takes the delivered message object, builds the ticket payload, and posts it. Zendesk returns `201 Created` with a `ticket.id` you should store alongside the Nylas message ID so the outbound path can find both later.

```python
ZENDESK_SUBDOMAIN = os.environ["ZENDESK_SUBDOMAIN"]  # e.g. "acme"
ZENDESK_EMAIL = os.environ["ZENDESK_EMAIL"]          # agent email for API auth
ZENDESK_TOKEN = os.environ["ZENDESK_API_TOKEN"]


def create_ticket(message: dict) -> dict:
    """Turn a Nylas message into a Zendesk ticket."""
    sender = (message.get("from") or [{}])[0]

    payload = {
        "ticket": {
            "subject": message.get("subject") or "(no subject)",
            "comment": {"body": message.get("body") or message.get("snippet", "")},
            "requester": {
                "name": sender.get("name") or sender.get("email", "Unknown"),
                "email": sender.get("email"),
            },
            "external_id": message["id"],  # link back to the Nylas message
        }
    }

    response = requests.post(
        f"https://{ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/tickets.json",
        json=payload,
        auth=(f"{ZENDESK_EMAIL}/token", ZENDESK_TOKEN),
        timeout=10,
    )
    response.raise_for_status()  # 201 on success
    return response.json()["ticket"]
```

```js [createTicket-Node.js]
const ZENDESK_SUBDOMAIN = process.env.ZENDESK_SUBDOMAIN; // e.g. "acme"
const ZENDESK_EMAIL = process.env.ZENDESK_EMAIL; // agent email for API auth
const ZENDESK_TOKEN = process.env.ZENDESK_API_TOKEN;

async function createTicket(message) {
  const sender = (message.from && message.from[0]) || {};

  const payload = {
    ticket: {
      subject: message.subject || "(no subject)",
      comment: { body: message.body || message.snippet || "" },
      requester: {
        name: sender.name || sender.email || "Unknown",
        email: sender.email,
      },
      external_id: message.id, // link back to the Nylas message
    },
  };

  const auth = Buffer.from(
    `${ZENDESK_EMAIL}/token:${ZENDESK_TOKEN}`,
  ).toString("base64");

  const response = await fetch(
    `https://${ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/tickets.json`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Basic ${auth}`,
      },
      body: JSON.stringify(payload),
    },
  );

  if (response.status !== 201) {
    throw new Error(`Zendesk create failed: ${response.status}`);
  }

  return (await response.json()).ticket;
}
```

The `external_id` field is the load-bearing piece. It lets you query Zendesk later with `GET /api/v2/tickets?external_id=<message_id>` to check whether a ticket already exists, which keeps duplicate webhook deliveries from creating two tickets for one email. See Zendesk's [Tickets API reference](https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/#create-ticket) for the full field list.

## Thread an agent reply back to the customer

When an agent answers in Zendesk, the customer should get a normal email reply in the same conversation, not a detached message. You send the reply through `POST /v3/grants/{grant_id}/messages/send` and set `reply_to_message_id` to the original message's ID. Nylas adds the right `In-Reply-To` and `References` headers so the reply threads in Gmail, Outlook, and standard IMAP clients.

The send request below uses the same mailbox the customer wrote to, so the `From` address stays consistent across all 6 providers. Set `reply_to_message_id` to the Nylas message ID you stored as the ticket's `external_id`, and `to` to the original sender. Sent mail lands in the mailbox's Sent folder like any other reply.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages/send' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>' \
  --header 'Content-Type: application/json' \
  --data '{
    "subject": "Re: Login issue on the dashboard",
    "body": "Thanks for reaching out. We reset your session, please try again.",
    "to": [{ "name": "Leyah Miller", "email": "leyah@example.com" }],
    "reply_to_message_id": "<ORIGINAL_MESSAGE_ID>"
  }'
```

```python
NYLAS_API_KEY = os.environ["NYLAS_API_KEY"]
GRANT_ID = os.environ["NYLAS_GRANT_ID"]


def send_reply(to_email: str, to_name: str, subject: str,
               body: str, original_message_id: str) -> dict:
    """Send an agent reply that threads to the original email."""
    response = requests.post(
        f"https://api.us.nylas.com/v3/grants/{GRANT_ID}/messages/send",
        headers={"Authorization": f"Bearer {NYLAS_API_KEY}"},
        json={
            "subject": f"Re: {subject}",
            "body": body,
            "to": [{"name": to_name, "email": to_email}],
            "reply_to_message_id": original_message_id,
        },
        timeout=10,
    )
    response.raise_for_status()
    return response.json()["data"]
```

```js [sendReply-Node.js]
const NYLAS_API_KEY = process.env.NYLAS_API_KEY;
const GRANT_ID = process.env.NYLAS_GRANT_ID;

async function sendReply(toEmail, toName, subject, body, originalMessageId) {
  const response = await fetch(
    `https://api.us.nylas.com/v3/grants/${GRANT_ID}/messages/send`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${NYLAS_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        subject: `Re: ${subject}`,
        body,
        to: [{ name: toName, email: toEmail }],
        reply_to_message_id: originalMessageId,
      }),
    },
  );

  if (!response.ok) {
    throw new Error(`Send failed: ${response.status}`);
  }

  return (await response.json()).data;
}
```

To trigger this from Zendesk rather than your own code, point a [Zendesk webhook and trigger](https://developer.zendesk.com/documentation/webhooks/) at a small endpoint that calls `send_reply`. Pass the comment body and the stored Nylas message ID from the ticket's `external_id`. For the reverse direction (logging the outbound reply back onto the ticket), use Zendesk's `PUT /api/v2/tickets/{ticket_id}` with a `comment` body.

## Backfill tickets from existing mail

Before you flip on the live webhook, you'll usually want to import the support mail already sitting in the mailbox. List recent messages with `GET /v3/grants/{grant_id}/messages`, which returns up to 200 messages per page, then run each one through the same `create_ticket` function. This gives Zendesk a starting history instead of an empty queue on day one.

The request below pulls the 50 most recent messages from the connected mailbox. Filter to the support address with the `to` query parameter, or narrow by time with `received_after` so you don't re-import years of archives. Pass the `external_id` check before creating each ticket to skip mail already ticketed.

```bash
curl --compressed --request GET \
  --url "https://api.us.nylas.com/v3/grants/<NYLAS_GRANT_ID>/messages?limit=50&to=support@example.com" \
  --header 'Accept: application/json' \
  --header 'Authorization: Bearer <NYLAS_API_KEY>'
```

```python
NYLAS_API_KEY = os.environ["NYLAS_API_KEY"]
GRANT_ID = os.environ["NYLAS_GRANT_ID"]


def backfill(support_address: str) -> None:
    """Create tickets for recent support mail."""
    response = requests.get(
        f"https://api.us.nylas.com/v3/grants/{GRANT_ID}/messages",
        headers={"Authorization": f"Bearer {NYLAS_API_KEY}"},
        params={"limit": 50, "to": support_address},
        timeout=10,
    )
    response.raise_for_status()

    for message in response.json()["data"]:
        create_ticket(message)  # reuse the mapping from earlier
```

```js [backfill-Node.js]
const NYLAS_API_KEY = process.env.NYLAS_API_KEY;
const GRANT_ID = process.env.NYLAS_GRANT_ID;

async function backfill(supportAddress) {
  const url = new URL(
    `https://api.us.nylas.com/v3/grants/${GRANT_ID}/messages`,
  );
  url.searchParams.set("limit", "50");
  url.searchParams.set("to", supportAddress);

  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${NYLAS_API_KEY}` },
  });
  if (!response.ok) throw new Error(`List failed: ${response.status}`);

  const { data } = await response.json();
  for (const message of data) {
    await createTicket(message); // reuse the mapping from earlier
  }
}
```

## Things to know about the Zendesk bridge

A few details decide whether this integration stays clean or fills Zendesk with duplicates and orphaned threads. Most of them come from how Zendesk and email behave, not from Nylas.

- **Dedupe on `external_id`, every time.** Nylas guarantees at-least-once webhook delivery, so the same `message.created` can arrive twice. Query `GET /api/v2/tickets?external_id=<message_id>` before creating, or rely on the fact that a second create with the same `external_id` is a strong signal to skip. One email should never become two tickets.
- **Watch the 1 MB truncation.** When a message exceeds 1 MB (large attachments, heavy HTML), Nylas strips the body and adds the `.truncated` suffix. Detect the missing `body` and refetch the full message before you set `comment.body`, or your ticket opens with an empty description.
- **Don't ticket your own outbound.** When an agent reply sends, it also fires `message.created` for the connected mailbox. Check the `folders` array for `SENT` and skip those, or you'll create a ticket every time your team answers one.
- **Zendesk auth uses an API token, not a password.** Authenticate as `{email}/token` with the token as the secret. Tokens are managed in the Zendesk Admin Center under Apps and integrations.
- **`requester` creates users silently.** If the sender's email isn't a known Zendesk user, the create call provisions one. That's usually what you want for support, but it means a typo'd or spoofed `From` address becomes a real user record. Validate the sender domain before creating tickets from untrusted inboxes.
- **HTML bodies need handling.** Nylas returns the message `body` as HTML. Zendesk accepts HTML in `comment.html_body`, so use that field instead of `comment.body` when you want formatting preserved rather than escaped.

## What's next

- [Monitor an inbox for support tickets](/docs/cookbook/use-cases/ingest/monitor-inbox-support-tickets/) for the webhook handler, signature verification, and routing rules this recipe builds on.
- [Build a shared team inbox](/docs/cookbook/email/shared-team-inbox/) when several agents work the same mailbox without a ticketing tool.
- [Customer support use cases](/docs/cookbook/use-cases/industries/customer-support/) for other support patterns on the Email API.
- [Send an email](/docs/v3/email/send-email/) for full options on the send endpoint, including attachments and tracking.
- [Zendesk Tickets API](https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/) covers the create, update, and list endpoints used here.