AI Support Guide

Zendesk

Connect Simpli services to Zendesk Support for AI-powered ticket handling.

Zendesk is the most common helpdesk platform we integrate with. This guide walks through connecting every Simpli service to Zendesk Support using triggers, webhooks, and the Zendesk API.

Zendesk remains your system of record. Simpli adds AI capabilities alongside it — auto-classifying tickets, drafting responses, scoring conversations, and tracking customer health.

Prerequisites

  • Zendesk Support account (any plan with API access)
  • Zendesk API token — create one at Admin Center > Apps and integrations > Zendesk API > Settings
  • AI capabilities deployed and running
  • A middleware endpoint — your own server or serverless function that sits between Zendesk and Simpli

Architecture

┌──────────┐    trigger/webhook    ┌──────────────┐     REST API      ┌─────────────────┐
│ Zendesk  │ ────────────────────► │  Middleware   │ ────────────────► │ Simpli Services  │
│ Support  │ ◄──────────────────── │  (your code)  │ ◄──────────────── │ Triage, Reply... │
└──────────┘    Zendesk API        └──────────────┘    AI results      └─────────────────┘

Zendesk fires webhooks on ticket events. Your middleware receives these, calls the relevant Simpli APIs, and pushes results back to Zendesk via its REST API. This keeps your Zendesk instance clean — no plugins or marketplace apps required.

Per-service integration

Triage — auto-classify and route tickets

When: A new ticket is created in Zendesk.

Flow: Zendesk trigger fires a webhook to your middleware. Middleware calls Triage /classify and /route, then updates the Zendesk ticket with the results — setting custom fields, adding tags, and reassigning the ticket to the right group.

import httpx

ZENDESK_SUBDOMAIN = "yourcompany"
ZENDESK_EMAIL = "bot@yourcompany.com"
ZENDESK_TOKEN = "your-api-token"
TRIAGE_URL = "http://localhost:8001"

async def classify_and_route(ticket_id: int, subject: str, body: str):
    async with httpx.AsyncClient() as client:
        # Classify the ticket
        classify_resp = await client.post(f"{TRIAGE_URL}/classify", json={
            "subject": subject,
            "body": body,
        })
        classification = classify_resp.json()

        # Route based on classification
        route_resp = await client.post(f"{TRIAGE_URL}/route", json={
            "ticket_id": str(ticket_id),
            "category": classification["category"],
            "urgency": classification["urgency"],
            "sentiment": classification["sentiment"],
        })
        routing = route_resp.json()

        # Update the Zendesk ticket
        await client.put(
            f"https://{ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/tickets/{ticket_id}",
            auth=(f"{ZENDESK_EMAIL}/token", ZENDESK_TOKEN),
            json={
                "ticket": {
                    "custom_fields": [
                        {"id": 12345, "value": classification["category"]},  # AI Category
                        {"id": 12346, "value": classification["urgency"]},   # AI Urgency
                        {"id": 12347, "value": classification["confidence"]},  # AI Confidence
                    ],
                    "tags": [
                        f"ai-category:{classification['category']}",
                        f"ai-urgency:{classification['urgency']}",
                    ],
                    "group_id": routing.get("group_id"),
                }
            },
        )

    return {"classification": classification, "routing": routing}

Replace the custom_fields IDs with the ones you create in Zendesk (see Configuration steps below).

Reply — AI draft responses

There are two ways to surface draft responses in Zendesk:

Approach 1: Internal note (webhook-driven)

After Triage classifies a ticket, your middleware calls Reply /api/v1/draft and posts the draft as an internal note. Agents see the suggested response and can copy or edit it before replying.

REPLY_URL = "http://localhost:8002"

async def draft_reply(ticket_id: int, conversation: list[dict]):
    async with httpx.AsyncClient() as client:
        # Generate a draft
        draft_resp = await client.post(f"{REPLY_URL}/api/v1/draft", json={
            "ticket_id": str(ticket_id),
            "conversation": conversation,
            "style": "friendly",
            "language": "en",
        })
        draft = draft_resp.json()

        # Post as internal note in Zendesk
        await client.post(
            f"https://{ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/tickets/{ticket_id}",
            auth=(f"{ZENDESK_EMAIL}/token", ZENDESK_TOKEN),
            json={
                "ticket": {
                    "comment": {
                        "body": (
                            f"**AI Draft Response** (confidence: {draft['confidence']:.0%})\n\n"
                            f"{draft['draft']}\n\n"
                            f"---\n_Copy and edit this draft before sending._"
                        ),
                        "public": False,  # Internal note
                    }
                }
            },
        )

    return draft

Approach 2: Zendesk App sidebar

Build a custom Zendesk app that calls Reply directly from the agent interface. The agent clicks a button, sees the draft in a sidebar panel, and inserts it into the composer. This is a better experience but requires building a Zendesk app — see the Zendesk Apps Framework documentation.

Sentiment — customer health tracking

When: A new comment is added to a ticket.

Flow: Zendesk trigger fires on comment added. Middleware calls Sentiment /analyze with the comment text, then tags the Zendesk user or organization with the health status.

SENTIMENT_URL = "http://localhost:8004"

async def analyze_comment(ticket_id: int, customer_id: str, comment_text: str):
    async with httpx.AsyncClient() as client:
        # Analyze sentiment
        sentiment_resp = await client.post(f"{SENTIMENT_URL}/analyze", json={
            "customer_id": customer_id,
            "text": comment_text,
            "channel": "web",
        })
        sentiment = sentiment_resp.json()

        # Build tags based on results
        tags_to_add = [f"sentiment:{sentiment['label']}"]
        if sentiment["escalation_risk"] >= 0.5:
            tags_to_add.append(f"escalation-risk:high")

        # Update Zendesk user tags
        await client.put(
            f"https://{ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/users/{customer_id}",
            auth=(f"{ZENDESK_EMAIL}/token", ZENDESK_TOKEN),
            json={
                "user": {
                    "tags": tags_to_add,
                }
            },
        )

    return sentiment

Agents can then create Zendesk views that surface tickets where the requester has escalation-risk:high, ensuring frustrated customers get attention fast.

QA — conversation scoring

When: A ticket status changes to Solved.

Flow: Middleware pulls the full conversation from Zendesk, sends it to QA /evaluate, and posts the score as an internal note.

QA_URL = "http://localhost:8003"

async def score_conversation(ticket_id: int):
    async with httpx.AsyncClient() as client:
        # Pull conversation from Zendesk
        comments_resp = await client.get(
            f"https://{ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/tickets/{ticket_id}/comments",
            auth=(f"{ZENDESK_EMAIL}/token", ZENDESK_TOKEN),
        )
        comments = comments_resp.json()["comments"]

        # Convert to Simpli message format
        messages = []
        for comment in comments:
            role = "customer" if comment.get("via", {}).get("channel") != "api" else "agent"
            messages.append({"role": role, "content": comment["body"]})

        # Evaluate the conversation
        qa_resp = await client.post(f"{QA_URL}/evaluate", json={
            "conversation_id": str(ticket_id),
            "agent_id": str(comments[-1].get("author_id", "")),
            "messages": messages,
        })
        evaluation = qa_resp.json()

        # Post score as internal note
        dimensions = evaluation.get("dimensions", {})
        dimension_lines = "\n".join(
            f"- **{k.title()}**: {v:.0%}" for k, v in dimensions.items()
        )
        await client.put(
            f"https://{ZENDESK_SUBDOMAIN}.zendesk.com/api/v2/tickets/{ticket_id}",
            auth=(f"{ZENDESK_EMAIL}/token", ZENDESK_TOKEN),
            json={
                "ticket": {
                    "comment": {
                        "body": (
                            f"**QA Score: {evaluation['overall_score']:.0%}**\n\n"
                            f"{dimension_lines}\n\n"
                            f"**Coaching notes:** {', '.join(evaluation.get('coaching_notes', []))}"
                        ),
                        "public": False,
                    }
                }
            },
        )

    return evaluation

KB — article sync

Sync your Zendesk Guide help center articles into Simpli KB, then use gap analysis to find missing content.

KB_URL = "http://localhost:8006"

async def sync_zendesk_articles():
    async with httpx.AsyncClient() as client:
        # Fetch articles from Zendesk Guide
        page_url = (
            f"https://{ZENDESK_SUBDOMAIN}.zendesk.com"
            f"/api/v2/help_center/articles?per_page=100"
        )

        while page_url:
            resp = await client.get(
                page_url,
                auth=(f"{ZENDESK_EMAIL}/token", ZENDESK_TOKEN),
            )
            data = resp.json()

            for article in data["articles"]:
                # Import each article into Simpli KB
                await client.post(f"{KB_URL}/articles", json={
                    "title": article["title"],
                    "content": article["body"],
                    "tags": [str(label) for label in article.get("label_names", [])],
                    "source": "zendesk-guide",
                })

            page_url = data.get("next_page")

        # After syncing, check for content gaps
        gaps_resp = await client.get(f"{KB_URL}/gaps")
        gaps = gaps_resp.json()

        for gap in gaps:
            print(
                f"Missing article: {gap['topic']} "
                f"({gap['ticket_count']} tickets, "
                f"e.g. {gap['sample_queries'][:2]})"
            )

        return gaps

Run this script on a schedule (e.g., daily) to keep your Simpli KB in sync with Zendesk Guide and surface documentation gaps.

Complete middleware example

Here is a complete FastAPI middleware that handles the most common Zendesk webhook events:

import os
from fastapi import FastAPI, Request
import httpx

app = FastAPI(title="Simpli-Zendesk Middleware")

# Zendesk configuration
ZD_SUBDOMAIN = os.environ["ZENDESK_SUBDOMAIN"]
ZD_EMAIL = os.environ["ZENDESK_EMAIL"]
ZD_TOKEN = os.environ["ZENDESK_TOKEN"]
ZD_AUTH = (f"{ZD_EMAIL}/token", ZD_TOKEN)
ZD_BASE = f"https://{ZD_SUBDOMAIN}.zendesk.com/api/v2"

# Simpli service URLs
TRIAGE_URL = os.environ.get("TRIAGE_URL", "http://localhost:8001")
SENTIMENT_URL = os.environ.get("SENTIMENT_URL", "http://localhost:8004")
REPLY_URL = os.environ.get("REPLY_URL", "http://localhost:8002")
QA_URL = os.environ.get("QA_URL", "http://localhost:8003")


async def update_zendesk_ticket(client: httpx.AsyncClient, ticket_id: int, data: dict):
    """Helper to update a Zendesk ticket."""
    resp = await client.put(
        f"{ZD_BASE}/tickets/{ticket_id}",
        auth=ZD_AUTH,
        json={"ticket": data},
    )
    resp.raise_for_status()
    return resp.json()


@app.post("/zendesk/ticket-created")
async def on_ticket_created(request: Request):
    """Handle new ticket: classify, route, analyze sentiment, draft reply."""
    payload = await request.json()
    ticket_id = payload["id"]
    subject = payload.get("subject", "")
    body = payload.get("description", "")
    requester_id = str(payload.get("requester_id", ""))

    async with httpx.AsyncClient(timeout=30) as client:
        # Classify and route
        classification = (await client.post(
            f"{TRIAGE_URL}/classify",
            json={"subject": subject, "body": body},
        )).json()

        routing = (await client.post(
            f"{TRIAGE_URL}/route",
            json={
                "ticket_id": str(ticket_id),
                "category": classification["category"],
                "urgency": classification["urgency"],
                "sentiment": classification["sentiment"],
            },
        )).json()

        # Analyze sentiment
        sentiment = (await client.post(
            f"{SENTIMENT_URL}/analyze",
            json={"customer_id": requester_id, "text": f"{subject}\n{body}"},
        )).json()

        # Update Zendesk ticket with classification results
        tags = [
            f"ai-category:{classification['category']}",
            f"ai-urgency:{classification['urgency']}",
        ]
        if sentiment["escalation_risk"] >= 0.5:
            tags.append("escalation-risk:high")

        await update_zendesk_ticket(client, ticket_id, {
            "custom_fields": [
                {"id": 12345, "value": classification["category"]},
                {"id": 12346, "value": classification["urgency"]},
                {"id": 12347, "value": classification["confidence"]},
            ],
            "tags": tags,
            "group_id": routing.get("group_id"),
        })

        # Generate and post draft reply
        draft = (await client.post(
            f"{REPLY_URL}/api/v1/draft",
            json={
                "ticket_id": str(ticket_id),
                "conversation": [{"role": "customer", "content": f"{subject}\n{body}"}],
                "style": "friendly",
            },
        )).json()

        await update_zendesk_ticket(client, ticket_id, {
            "comment": {
                "body": (
                    f"**AI Draft** (confidence: {draft['confidence']:.0%})\n\n"
                    f"{draft['draft']}"
                ),
                "public": False,
            }
        })

    return {"status": "processed", "ticket_id": ticket_id}


@app.post("/zendesk/ticket-solved")
async def on_ticket_solved(request: Request):
    """Handle solved ticket: pull conversation and run QA evaluation."""
    payload = await request.json()
    ticket_id = payload["id"]

    async with httpx.AsyncClient(timeout=30) as client:
        # Pull comments from Zendesk
        resp = await client.get(
            f"{ZD_BASE}/tickets/{ticket_id}/comments",
            auth=ZD_AUTH,
        )
        comments = resp.json()["comments"]

        messages = [
            {
                "role": "agent" if c.get("via", {}).get("channel") == "api" else "customer",
                "content": c["body"],
            }
            for c in comments
        ]

        # Evaluate conversation quality
        evaluation = (await client.post(
            f"{QA_URL}/evaluate",
            json={
                "conversation_id": str(ticket_id),
                "agent_id": str(comments[-1].get("author_id", "")),
                "messages": messages,
            },
        )).json()

        # Post QA score as internal note
        dims = evaluation.get("dimensions", {})
        dim_text = " | ".join(f"{k}: {v:.0%}" for k, v in dims.items())
        await update_zendesk_ticket(client, ticket_id, {
            "comment": {
                "body": (
                    f"**QA Score: {evaluation['overall_score']:.0%}** ({dim_text})\n\n"
                    f"Notes: {', '.join(evaluation.get('coaching_notes', []))}"
                ),
                "public": False,
            }
        })

    return {"status": "scored", "ticket_id": ticket_id}

Run this with:

pip install fastapi uvicorn httpx
uvicorn middleware:app --host 0.0.0.0 --port 9000

Your webhook URL will be https://your-server.example.com/zendesk/ticket-created (and ticket-solved). Make sure HTTPS is configured — Zendesk requires it for webhook targets.

Zendesk configuration steps

1. Create custom ticket fields

In Admin Center > Objects and rules > Tickets > Fields, create:

Field nameTypeValues
AI CategoryDropdownbilling, technical, account, feature_request, general
AI UrgencyDropdownlow, medium, high, critical
AI ConfidenceDecimal

Note the field IDs — you will need them in your middleware code (replace the 12345, 12346, 12347 placeholders).

2. Create a webhook

In Admin Center > Apps and integrations > Webhooks, create a new webhook:

  • Name: Simpli AI Middleware
  • Endpoint URL: https://your-server.example.com/zendesk/ticket-created
  • Request method: POST
  • Request format: JSON
  • Authentication: Bearer token or basic auth (configure your middleware to validate this)

Create a second webhook for the solved trigger pointing to /zendesk/ticket-solved.

3. Create triggers

In Admin Center > Objects and rules > Business rules > Triggers:

Trigger 1 — AI Classify New Ticket:

  • Conditions (all): Ticket is Created
  • Actions: Notify webhook "Simpli AI Middleware" with JSON body:
{
  "id": "{{ticket.id}}",
  "subject": "{{ticket.title}}",
  "description": "{{ticket.description}}",
  "requester_id": "{{ticket.requester.id}}"
}

Trigger 2 — AI Score Solved Ticket:

  • Conditions (all): Status changed to Solved
  • Actions: Notify webhook "Simpli AI Solved" with JSON body:
{
  "id": "{{ticket.id}}"
}

4. Test with a sample ticket

Create a test ticket in Zendesk and verify:

  1. The ticket gets custom field values (AI Category, AI Urgency, AI Confidence)
  2. Tags like ai-category:billing appear on the ticket
  3. An internal note with a draft reply is posted
  4. When you solve the ticket, a QA score appears as an internal note

Rate limits and best practices

Zendesk API rate limits vary by plan but are typically 700 requests per minute. Keep these in mind:

  • Batch where possible. If processing multiple tickets, use Zendesk's bulk update endpoint (PUT /api/v2/tickets/update_many) instead of individual updates.
  • Handle 429 responses. When Zendesk returns 429 Too Many Requests, read the Retry-After header and back off:
resp = await client.put(url, auth=ZD_AUTH, json=data)
if resp.status_code == 429:
    retry_after = int(resp.headers.get("Retry-After", 60))
    await asyncio.sleep(retry_after)
    resp = await client.put(url, auth=ZD_AUTH, json=data)
  • Use webhook signatures. Zendesk signs webhook payloads — verify the signature in your middleware to prevent spoofed requests. See Zendesk's webhook security documentation.
  • Keep middleware idempotent. Zendesk may retry webhooks on failure. Use the ticket ID as an idempotency key to avoid duplicate processing.
  • Log everything. Store the Simpli API responses alongside the Zendesk ticket ID so you can debug classification or routing issues later.

Next steps

On this page