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 draftApproach 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 sentimentAgents 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 evaluationKB — 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 gapsRun 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 9000Your 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 name | Type | Values |
|---|---|---|
| AI Category | Dropdown | billing, technical, account, feature_request, general |
| AI Urgency | Dropdown | low, medium, high, critical |
| AI Confidence | Decimal | — |
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:
- The ticket gets custom field values (AI Category, AI Urgency, AI Confidence)
- Tags like
ai-category:billingappear on the ticket - An internal note with a draft reply is posted
- 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 theRetry-Afterheader 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
- Integration Overview — Architecture and other platform guides
- Generic Webhook — Build a platform-agnostic integration