Webhooks
Webhooks tell your server when something changes, so you don’t have to poll. You register an HTTPS endpoint with the API, choose the events, and Prisma sends a signed POST for each one.
Events
| Event | When | data |
|---|---|---|
review.created | A new review is found on a connected location. | id (review), location_id, platform |
review.updated | A review’s content changed on the platform, or a reply appeared or changed there. | id, location_id, platform |
reply.published | A reply sent through Prisma is live on the platform. | id (review), location_id, platform |
reply.failed | The platform refused a reply, or every retry failed. | id (review), platform, error |
connection.updated | An OAuth connection became active, or a connection became reauth_required. | id (connection), platform, status, plus locations_created or reason |
Events carry IDs, not full objects. Fetch the current state with GET /v1/reviews/{id} or GET /v1/connections.
Register an endpoint
POST /v1/webhooks
| Body param | Required | Description |
|---|---|---|
url | yes | Your endpoint. Must start with https://. |
events | no | Array of event types, or ["*"] for all of them (the default). At least one if you send it. |
curl -X POST https://api.prismareviews.com/v1/webhooks \
-H "Authorization: Bearer $PRISMA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://yourapp.com/webhooks/prisma", "events": ["review.created", "reply.failed", "connection.updated"]}'const res = await fetch("https://api.prismareviews.com/v1/webhooks", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.PRISMA_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://yourapp.com/webhooks/prisma",
events: ["review.created", "reply.failed", "connection.updated"],
}),
});
const endpoint = await res.json();
// Store endpoint.secret now: it is never shown againimport os
import requests
resp = requests.post(
"https://api.prismareviews.com/v1/webhooks",
json={
"url": "https://yourapp.com/webhooks/prisma",
"events": ["review.created", "reply.failed", "connection.updated"],
},
headers={"Authorization": f"Bearer {os.environ['PRISMA_API_KEY']}"},
timeout=30,
)
endpoint = resp.json()
print(endpoint["secret"]) # store it now: it is never shown againResponse 201
{
"id": "we_01KJ8R1A3B5C7D9E1F3G5H7J9K",
"url": "https://yourapp.com/webhooks/prisma",
"events": ["review.created", "reply.failed", "connection.updated"],
"secret": "whsec_3q2Xk9LmP0aB7cD1eF4gH6jK8mN2pQ5r",
"created_at": "2026-10-21T09:20:00.000Z"
}The signing secret is returned only in this response. Prisma stores it encrypted and never shows it again.
Webhook endpoints belong to the workspace, not to a key: endpoints created with a test key and with a live key are the same list, and each endpoint receives events from both modes. Fetch the object with a key of the matching mode (a test review returns 404 to a live key).
List endpoints
GET /v1/webhooks
curl https://api.prismareviews.com/v1/webhooks \
-H "Authorization: Bearer $PRISMA_API_KEY"{
"data": [
{
"id": "we_01KJ8R1A3B5C7D9E1F3G5H7J9K",
"url": "https://yourapp.com/webhooks/prisma",
"events": ["review.created", "reply.failed", "connection.updated"],
"active": true,
"created_at": "2026-10-21T09:20:00.000Z"
}
]
}Delete an endpoint
DELETE /v1/webhooks/{id}
curl -X DELETE https://api.prismareviews.com/v1/webhooks/we_01KJ8R1A3B5C7D9E1F3G5H7J9K \
-H "Authorization: Bearer $PRISMA_API_KEY"{ "id": "we_01KJ8R1A3B5C7D9E1F3G5H7J9K", "deleted": true }404 resource_not_found if there is no endpoint with that ID in your workspace. Deliveries still pending for it are dropped. There is no update endpoint: to change the URL or the events, create a new endpoint and delete the old one.
The delivery
Each event is a POST with a JSON body:
{
"id": "evt_01KJ8S2B4C6D8E0F2G4H6J8K0M",
"type": "review.created",
"created_at": "2026-10-21T09:00:03.000Z",
"data": {
"id": "rev_01KJ8Q2M4T7HX3VB9CWD6NRZ5E",
"location_id": "loc_01KJ8P9W2C4D6F8G0H2J4K6M8N",
"platform": "google"
}
}More data examples:
{ "id": "rev_01KJ8Q2M4T7HX3VB9CWD6NRZ5E", "platform": "google_play", "error": "Google Play API 400: ..." }{ "id": "con_01KJ8P7R5S3T1V9W7X5Y3Z1A2B", "platform": "google", "status": "reauth_required", "reason": "Google rejected the access token" }Headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Prisma-Webhooks/1 |
Prisma-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Answer with any 2xx status within 10 seconds. Do the slow work after answering, for example in a queue.
Retries
A delivery fails on a non-2xx status, a timeout or a connection error. Failed deliveries are retried with exponential backoff: 30 seconds, then doubling up to one hour between attempts. Prisma stops after 30 attempts or when the event is older than your plan’s retry window, whichever comes first:
| Plan | Retry window |
|---|---|
| Spark | 24 hours |
| Beam | 72 hours |
| Spectrum, Aurora | 7 days |
Each retry is signed again with a fresh timestamp and keeps the same event id. De-duplicate by id.
Verify the signature
Prisma-Signature is t=<timestamp>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <timestamp>.<raw body> with your endpoint’s secret. Compute it over the raw request body, before any JSON parsing, compare in constant time and reject events whose timestamp is more than 5 minutes from your clock.
import crypto from "node:crypto";
export function verifyPrismaSignature(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}import hashlib
import hmac
import time
def verify_prisma_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, sig = parts.get("t"), parts.get("v1")
if not t or not sig or abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)With Express, read the raw body with express.raw({ type: "application/json" }) on the webhook route, verify, then JSON.parse it.
FAQ
Why doesn’t the webhook include the whole review?
Events carry IDs only, so a retried or late event never overwrites newer data. Fetch the current object with GET /v1/reviews/{id}.
Can I get the signing secret again?
No. It is shown once, when you create the endpoint. If you lose it, delete the endpoint and create a new one.
Can events arrive twice or out of order?
Yes, as with most webhook systems. A delivery that timed out is retried with the same event id, so de-duplicate by id and use created_at to order events.
Get new reviews pushed to your server.
Webhooks on every plan, including the free Spark plan.