Prisma Reviews / Guides / Approve review replies in Slack
How to approve review replies in Slack and publish them by API
The Slack alerts guide posts each new review to a channel. This one closes the loop: the message has a Reply button, the button opens a modal where someone writes or edits the answer, and on submit the reply is published on the platform through Prisma. The person who knows the customer answers in 30 seconds, from the tool they already have open, and nobody shares a platform password. It needs a small Slack app and one endpoint; the code below is Python with Flask.
pip install flask requestsYou need a Prisma API key and webhook secret, and a Slack app (api.slack.com/apps → Create app) with:
- Bot token scopes:
chat:write(andchat:write.publicto post in channels without inviting the bot). Install it and copy the Bot User OAuth Token (xoxb-…). - Interactivity: on, with the Request URL
https://yourapp.example.com/slack/interactions. Copy the Signing Secret.
1. Post each new review with buttons
import hashlib, hmac, json, 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']}"}
SLACK = {"Authorization": f"Bearer {os.environ['SLACK_BOT_TOKEN']}", "Content-Type": "application/json; charset=utf-8"}
CHANNEL = os.environ.get("SLACK_CHANNEL", "#reviews")
REPLIABLE = {r["id"] for r in requests.get(f"{PRISMA}/platforms", headers=PH, timeout=30).json()["data"] if r.get("reply") == "api"}
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 review_blocks(r: dict, note: str | None = None):
stars = "★" * (r["rating"] or 0) + "☆" * (5 - (r["rating"] or 0))
head = f"*{stars}* {r['platform']} · {r.get('author') or 'a customer'}"
blocks = [{"type": "section", "text": {"type": "mrkdwn", "text": f"{head}\n*{r.get('title') or ''}*\n{(r.get('text') or '')[:2800]}"}}]
if note:
blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": note}]})
elif r["platform"] in REPLIABLE:
blocks.append({"type": "actions", "elements": [
{"type": "button", "text": {"type": "plain_text", "text": "Reply"}, "style": "primary", "action_id": "reply", "value": r["id"]},
{"type": "button", "text": {"type": "plain_text", "text": "Open"}, "url": r.get("url") or "https://prismareviews.com", "action_id": "open"}]})
else:
blocks.append({"type": "actions", "elements": [
{"type": "button", "text": {"type": "plain_text", "text": "Answer on the platform"}, "url": r.get("url") or "https://prismareviews.com", "action_id": "open"}]})
return blocks
@app.post("/webhooks/prisma")
def prisma_webhook():
if not verify_prisma(request.get_data(), request.headers.get("Prisma-Signature", "")):
abort(400)
event = request.get_json()
if event["type"] == "review.created":
r = requests.get(f"{PRISMA}/reviews/{event['data']['id']}", headers=PH, timeout=30).json()
requests.post("https://slack.com/api/chat.postMessage", headers=SLACK, timeout=30,
json={"channel": CHANNEL, "text": f"New {r['rating']}★ review on {r['platform']}", "blocks": review_blocks(r),
"metadata": {"event_type": "prisma_review", "event_payload": {"review_id": r["id"]}}})
return {"ok": True}GET /v1/platforms tells the app which platforms accept replies, so a Yelp or Tripadvisor review gets an “Answer on the platform” link instead of a Reply button that would fail.
2. Handle the button and the modal
Slack sends every click and every modal submission to the Request URL as a form field payload, signed with X-Slack-Signature. Answer within 3 seconds.
def verify_slack(req) -> bool:
ts = req.headers.get("X-Slack-Request-Timestamp", "0")
if abs(time.time() - int(ts)) > 300:
return False
base = f"v0:{ts}:{req.get_data(as_text=True)}".encode()
mine = "v0=" + hmac.new(os.environ["SLACK_SIGNING_SECRET"].encode(), base, hashlib.sha256).hexdigest()
return hmac.compare_digest(mine, req.headers.get("X-Slack-Signature", ""))
@app.post("/slack/interactions")
def slack_interactions():
if not verify_slack(request):
abort(401)
p = json.loads(request.form["payload"])
if p["type"] == "block_actions" and p["actions"][0]["action_id"] == "reply":
review_id = p["actions"][0]["value"]
meta = json.dumps({"review_id": review_id, "channel": p["channel"]["id"], "ts": p["message"]["ts"]})
requests.post("https://slack.com/api/views.open", headers=SLACK, timeout=10, json={"trigger_id": p["trigger_id"], "view": {
"type": "modal", "callback_id": "publish_reply", "private_metadata": meta,
"title": {"type": "plain_text", "text": "Reply to review"}, "submit": {"type": "plain_text", "text": "Publish"},
"blocks": [{"type": "input", "block_id": "reply", "label": {"type": "plain_text", "text": "Your reply (published as written)"},
"element": {"type": "plain_text_input", "action_id": "text", "multiline": True, "max_length": 4000}}]}})
return "", 200
if p["type"] == "view_submission" and p["view"]["callback_id"] == "publish_reply":
meta = json.loads(p["view"]["private_metadata"])
text = p["view"]["state"]["values"]["reply"]["text"]["value"].strip()
res = requests.post(f"{PRISMA}/reviews/{meta['review_id']}/reply", headers={**PH, "Idempotency-Key": str(uuid.uuid4())}, json={"text": text}, timeout=30)
if res.status_code == 422:
err = res.json()["error"]
return {"response_action": "errors", "errors": {"reply": err.get("message", "The platform rejected this reply.")}}
res.raise_for_status()
r = requests.get(f"{PRISMA}/reviews/{meta['review_id']}", headers=PH, timeout=30).json()
who = p["user"]["username"]
requests.post("https://slack.com/api/chat.update", headers=SLACK, timeout=10, json={"channel": meta["channel"], "ts": meta["ts"],
"text": "Reply queued", "blocks": review_blocks(r, note=f"✏️ Reply by @{who}, publishing… _{text[:200]}_")})
return {"response_action": "clear"}
return "", 200A too-long text comes back from Prisma as 422 reply_too_long with max_length (350 characters on Google Play, 80 on eBay), and the modal shows it under the field so the person can shorten it without losing what they wrote.
3. Mark the message when the reply is live
Replies are queued and confirmed by the platform. Subscribe to reply.published and reply.failed and update the Slack message. The simplest way to find it is to keep review_id → (channel, ts) from step 2 in your database; a sketch with a dict:
POSTED = {} # review_id -> (channel, ts), filled in view_submission
@app.post("/webhooks/prisma-replies")
def prisma_replies():
if not verify_prisma(request.get_data(), request.headers.get("Prisma-Signature", "")):
abort(400)
e = request.get_json()
if e["type"] in ("reply.published", "reply.failed") and e["data"]["id"] in POSTED:
ch, ts = POSTED[e["data"]["id"]]
r = requests.get(f"{PRISMA}/reviews/{e['data']['id']}", headers=PH, timeout=30).json()
note = "✅ Reply published" if e["type"] == "reply.published" else f"⚠️ Reply failed: {(r.get('reply') or {}).get('error') or 'see Prisma'}"
requests.post("https://slack.com/api/chat.update", headers=SLACK, timeout=10, json={"channel": ch, "ts": ts, "text": note, "blocks": review_blocks(r, note=note)})
return {"ok": True}Register both endpoints with Prisma:
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"]}'
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-replies", "events": ["reply.published", "reply.failed"]}'Guardrails worth keeping
- A person submits every reply. The modal is the approval. If you add AI drafts, pre-fill the field (
initial_value) and keep the human submit; Prisma publishes exactly the text it receives and never writes one itself. - Low ratings to the right people. Post 1- and 2-star reviews to a private channel with the location manager, the rest to the team channel.
- Who can reply. Check
p["user"]["id"]against an allow-list before opening the modal; the Slack channel is not an access control. - Test first. A
pk_test_key returns sample reviews and accepts replies without publishing anywhere, so the whole loop runs before you connect a real account.
The same webhook, other tools: HubSpot · Notion · Airtable · Zendesk · Intercom · Slack (alerts) · Microsoft Teams · Discord · Google Sheets.
FAQ
Can I reply to Google reviews from Slack?
Yes, with a small Slack app: the review is posted with a Reply button, the button opens a modal, and the submitted text is published through a reviews API that holds the platform access. With Prisma that works today on the App Store, Google Play, Judge.me, eBay and the other platforms with a reply API; Google Business Profile joins once Google approves Prisma’s access.
Which Slack scopes does the app need?
Only chat:write (and chat:write.public to post in channels the bot hasn’t joined). Interactivity is switched on in the app settings with a Request URL; no other scopes are needed to open modals.
What happens on platforms that don’t allow replies?
The message shows an Open button with the review URL instead of Reply. Prisma’s reply endpoint returns 422 reply_not_supported for those platforms, so the app checks the platform first and never offers a reply it can’t publish.
Read in Slack, answer in Slack, published on the platform.
2 locations free. Test keys accept replies without publishing, so the loop runs today.