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
Section titled “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) ─▶ customerEach 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?
Section titled “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.createdpayload (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 is simpler and you don’t need this. The bridge earns its keep once mail lives outside Zendesk.
Set up the message.created webhook
Section titled “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.
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": ["[email protected]"] }'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 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
Section titled “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.
import osimport requests
ZENDESK_SUBDOMAIN = os.environ["ZENDESK_SUBDOMAIN"] # e.g. "acme"ZENDESK_EMAIL = os.environ["ZENDESK_EMAIL"] # agent email for API authZENDESK_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"]const ZENDESK_SUBDOMAIN = process.env.ZENDESK_SUBDOMAIN; // e.g. "acme"const ZENDESK_EMAIL = process.env.ZENDESK_EMAIL; // agent email for API authconst 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 for the full field list.
Thread an agent reply back to the customer
Section titled “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.
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": "[email protected]" }], "reply_to_message_id": "<ORIGINAL_MESSAGE_ID>" }'import osimport requests
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"]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 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
Section titled “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.
curl --compressed --request GET \ --header 'Accept: application/json' \ --header 'Authorization: Bearer <NYLAS_API_KEY>'import osimport requests
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 earlierconst 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
Section titled “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 samemessage.createdcan arrive twice. QueryGET /api/v2/tickets?external_id=<message_id>before creating, or rely on the fact that a second create with the sameexternal_idis 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
.truncatedsuffix. Detect the missingbodyand refetch the full message before you setcomment.body, or your ticket opens with an empty description. - Don’t ticket your own outbound. When an agent reply sends, it also fires
message.createdfor the connected mailbox. Check thefoldersarray forSENTand 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}/tokenwith the token as the secret. Tokens are managed in the Zendesk Admin Center under Apps and integrations. requestercreates 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 spoofedFromaddress becomes a real user record. Validate the sender domain before creating tickets from untrusted inboxes.- HTML bodies need handling. Nylas returns the message
bodyas HTML. Zendesk accepts HTML incomment.html_body, so use that field instead ofcomment.bodywhen you want formatting preserved rather than escaped.
What’s next
Section titled “What’s next”- Monitor an inbox for support tickets for the webhook handler, signature verification, and routing rules this recipe builds on.
- Build a shared team inbox when several agents work the same mailbox without a ticketing tool.
- Customer support use cases for other support patterns on the Email API.
- Send an email for full options on the send endpoint, including attachments and tracking.
- Zendesk Tickets API covers the create, update, and list endpoints used here.