Prisma Reviews / Guides / Reviews to Zendesk
Turn customer reviews into Zendesk tickets, and reply from the ticket
A 1-star review is a support case that happens to be public. Treating it like one means a ticket in the queue, the same SLAs, the agent who knows the customer, and an answer on the platform when the case is solved. This guide builds both directions with Zendesk’s APIs: a Prisma webhook creates a ticket for each review under 4 stars, and a Zendesk trigger sends the agent’s answer back, which Prisma publishes on the platforms that allow replies. Python with Flask, about 70 lines.
pip install flask requestsYou need a Prisma API key and webhook secret, a Zendesk API token (Admin Center → Apps and integrations → Zendesk API) with the agent email it belongs to, and your subdomain.
1. One custom field in Zendesk
Admin Center → Objects and rules → Tickets → Fields → Add field → Multi-line, title Review reply, visible to agents only. Note its field id (360…). This is where the agent writes the public answer; ticket comments stay internal.
2. Create a ticket for each low-rated review
import base64, hashlib, hmac, os, time, uuid, requests
from flask import Flask, request, abort
app = Flask(__name__)
PRISMA = "https://api.prismareviews.com/v1"
PH = {"Authorization": f"Bearer {os.environ['PRISMA_API_KEY']}"}
ZD = f"https://{os.environ['ZENDESK_SUBDOMAIN']}.zendesk.com/api/v2"
ZAUTH = (f"{os.environ['ZENDESK_EMAIL']}/token", os.environ["ZENDESK_API_TOKEN"])
REPLY_FIELD = int(os.environ["ZENDESK_REVIEW_REPLY_FIELD_ID"])
def verify_prisma(raw: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(os.environ["PRISMA_WEBHOOK_SECRET"].encode(), f"{parts['t']}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"]) and abs(time.time() - int(parts["t"])) < 300
def ticket_exists(review_id: str) -> bool:
r = requests.get(f"{ZD}/tickets.json", auth=ZAUTH, params={"external_id": review_id}, timeout=30)
r.raise_for_status()
return bool(r.json().get("tickets"))
@app.post("/webhooks/prisma")
def prisma_webhook():
if not verify_prisma(request.get_data(), request.headers.get("Prisma-Signature", "")):
abort(400)
e = request.get_json()
if e["type"] != "review.created" or ticket_exists(e["data"]["id"]):
return {"ok": True}
r = requests.get(f"{PRISMA}/reviews/{e['data']['id']}", headers=PH, timeout=30).json()
if (r.get("rating") or 5) >= 4:
return {"ok": True}
body = (f"{r['rating']}★ review on {r['platform']} by {r.get('author') or 'a customer'} ({r['created_at'][:10]})\n\n"
f"{r.get('title') or ''}\n{r.get('text') or ''}\n\nOpen: {r.get('url') or '-'}\n\n"
"Write the public answer in the 'Review reply' field and solve the ticket to publish it.")
ticket = {"subject": f"{r['rating']}★ {r['platform']} review: {(r.get('title') or r.get('text') or '')[:60]}",
"comment": {"body": body, "public": False}, "external_id": r["id"], "priority": "high" if r["rating"] <= 2 else "normal",
"tags": ["review", f"review_{r['platform']}", f"stars_{r['rating']}"]}
requests.post(f"{ZD}/tickets.json", auth=ZAUTH, json={"ticket": ticket}, timeout=30).raise_for_status()
return {"ok": True}The review id goes in external_id, so a redelivered webhook finds the existing ticket and stops. The comment is internal (public: False) and the requester defaults to the API user, so nobody outside the team gets an email. Zendesk’s API allows 200 to 700 requests a minute depending on the plan.
3. Send the answer back when the ticket is solved
In Zendesk, Admin Center → Apps and integrations → Webhooks → Create webhook: endpoint https://yourapp.example.com/zendesk/solved, method POST, JSON, and copy its signing secret. Then Objects and rules → Triggers → Add trigger:
- Conditions (all): Ticket is Updated · Status changed to Solved · Tags contain
review· Review reply is present. - Action: Notify webhook, with the body:
{"review_id": "{{ticket.external_id}}", "ticket_id": "{{ticket.id}}", "reply": "{{ticket.ticket_field_360000000000}}", "agent": "{{current_user.name}}"}(replace the number with your field id). Then the endpoint:
def verify_zendesk(req) -> bool:
ts = req.headers.get("X-Zendesk-Webhook-Signature-Timestamp", "")
sig = base64.b64encode(hmac.new(os.environ["ZENDESK_WEBHOOK_SECRET"].encode(), (ts + req.get_data(as_text=True)).encode(), hashlib.sha256).digest()).decode()
return hmac.compare_digest(sig, req.headers.get("X-Zendesk-Webhook-Signature", ""))
@app.post("/zendesk/solved")
def zendesk_solved():
if not verify_zendesk(request):
abort(401)
d = request.get_json()
text = (d.get("reply") or "").strip()
if not d.get("review_id") or not text:
return {"ok": True}
res = requests.post(f"{PRISMA}/reviews/{d['review_id']}/reply", headers={**PH, "Idempotency-Key": f"zd-{d['ticket_id']}"}, json={"text": text}, timeout=30)
note = "Reply queued for publishing on the platform." if res.ok else f"Not published: {res.json().get('error', {}).get('message', res.status_code)}"
requests.put(f"{ZD}/tickets/{d['ticket_id']}.json", auth=ZAUTH, json={"ticket": {"comment": {"body": note, "public": False}}}, timeout=30)
return {"ok": True}The Idempotency-Key is the ticket id, so if Zendesk retries the webhook the reply is published once. When the platform doesn’t allow replies (422 reply_not_supported) or the text is too long (422 reply_too_long with max_length), the ticket gets an internal note saying so, and the agent answers on the platform with the link from the first comment.
4. Register the Prisma webhook
curl -X POST https://api.prismareviews.com/v1/webhooks -H "Authorization: Bearer $PRISMA_API_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://yourapp.example.com/webhooks/prisma", "events": ["review.created"]}'Optionally subscribe a second endpoint to reply.published and reply.failed and add a final internal note on the ticket (find it by external_id), so the record shows when the answer actually went live.
Variants
- Route by location: add the Prisma
location_idas a tag (loc_…) and a Zendesk trigger that assigns the group by tag. - All reviews, not just bad ones: drop the rating check and set priority
lowfor 4 and 5 stars; a view filtered onstars_5becomes the “thank you” queue. - Same with n8n: Prisma Reviews trigger → IF rating < 4 → Zendesk node (Create ticket), and a Webhook node for the solved trigger → Prisma Reviews node (Reply). The Prisma node is verified on n8n Cloud (n8n setup).
The same webhook, other tools: HubSpot · Notion · Airtable · Intercom · Slack (approve replies) · Slack (alerts) · Microsoft Teams · Discord · Google Sheets.
FAQ
Can Zendesk import Google or App Store reviews?
Not natively for most platforms. Reviews become tickets through Zendesk’s Tickets API (POST /api/v2/tickets), created by your integration when a reviews webhook fires. Store the review id in the ticket’s external_id to avoid duplicates.
How does the agent’s answer get back to the review platform?
The agent writes the public answer in a custom ticket field and solves the ticket. A Zendesk trigger calls a Zendesk webhook with the ticket’s external_id and that field; your endpoint verifies the signature and publishes the text with POST /v1/reviews/{id}/reply on platforms that allow replies.
Why a custom field and not the ticket comment?
Ticket comments are for the conversation inside the team and may contain notes that must never be public. A dedicated Review reply field makes the published text explicit: what is in it is exactly what the customer will read on the platform.
Bad reviews in the support queue, answers back on the platform.
2 locations free. Test keys accept replies without publishing, so the loop runs today.