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.

Shell
pip install flask requests

You need a Prisma API key and webhook secret, and a Slack app (api.slack.com/apps → Create app) with:

1. Post each new review with buttons

Python
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.

Python
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 "", 200

A 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:

Python
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
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

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.

Get a free API key

2 locations free. Test keys accept replies without publishing, so the loop runs today.

Next step: see the 84 review platforms Prisma supports, and compare plans on Prisma Reviews pricing.

Get your API key in a minute

Free Spark plan: 2 locations, no card. Test keys return sample data on all 84 platforms, so you can build before connecting a real one.