Public API v1 · Pro, Enterprise, Agency
RevioReputation API and webhooks
A 1-star review lands in RevioReputation. Your CRM, your Slack and your team chat know nothing about it until someone opens the inbox. Every day it waits, people who look you up read it without your side of the story.
Connect them. We send a signed POST to your address when we see a new review, and your system can read reviews and post a reply you wrote. One format for reviews from every site your locations are connected to, and the API comes with your plan, not a separate contract.
Included in the Pro, Enterprise and Agency plans. Written for the owner and for the developer who builds the connection.
What you can do
- Read your reviews and locations, with filters and pages.
- Post a reply you wrote to a review. It goes out exactly as you send it.
- Get a webhook when a new review arrives, when a reply is published, and when a new review has 1 or 2 stars.
What version 1 does not do
- No AI-written replies through the API. AI drafts stay inside RevioReputation.
- No editing or deleting a reply after it is published.
- No creating or deleting reviews.
- No OAuth sign-in for other apps. You connect with a key.
- No SDK. It is plain HTTPS and JSON, so any language works.
Quick start
- In RevioReputation open Integrations > Developers and create a key. Pick its scopes. Copy the key right away: we show it once.
- Check the key. Every request goes to
https://revioreputation.com/api/v1/public.
curl https://revioreputation.com/api/v1/public/me \
-H "Authorization: Bearer $REVIO_API_KEY" {
"organization": { "id": "6d4b5e20-1a2b-4c3d-8e9f-0a1b2c3d4e5f", "name": "Casa Marisol" },
"key": {
"id": "0c9f8e7d-6b5a-4f3e-9d2c-1b0a9f8e7d6c",
"name": "Zapier",
"displayPrefix": "rk_live_ab12",
"scopes": ["reviews:read", "webhooks:manage"],
"expiresAt": null
},
"plan": "PRO"
} - In the same tab add a webhook address and press Send test event. You see the status code your server returned, right there.
Keys and scopes
Send the key in a header on every request: Authorization: Bearer rk_live_.... Only the header
works. Don't put the key in a URL.
Server-to-server only. Never put an API key in a browser page or a mobile app: anyone who opens it can copy the key.
| Scope | What it allows |
|---|---|
reviews:read | List reviews and read one review. |
reviews:reply | Post a reply to a review. |
locations:read | List your locations and the platforms connected to each. |
webhooks:manage | Add, list, remove and test webhook addresses made with this key. |
| any valid key | GET /me |
- You pick the scopes when you create a key, and they never change. Need another scope? Create another key.
- We keep only a hash of the key, so we can't show it to you again. Lost it? Revoke it and create a new one.
- A key never expires, or expires after 30, 90 or 365 days. You choose when you create it.
- Up to 10 active keys per organization.
-
Revoking a key works on the next request: it gets
401 KEY_REVOKED. Webhook addresses made with that key are removed with it. -
The API follows the plan of the organization owner. If the plan goes below Pro, requests get
403 PLAN_REQUIREDand webhooks stop.
Limits
- 60 requests a minute per key. Every response that counts against the limit has
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(Unix seconds when the minute resets). If our counter is briefly unavailable, the headers are missing and nothing is counted. - Over the limit you get
429 RATE_LIMITEDwithRetry-Afterin seconds. Wait that long and retry. - Too many failed key attempts from one IP address also get
429. - Lists: up to 100 items a page, 25 if you don't say.
- Reply text: up to 4,096 characters.
- Webhook addresses: up to 10 per organization.
- Test event: one per address every 5 seconds, 20 a minute per organization.
Errors
Every error has the same shape. Branch on code: the message is for people and can change.
{
"error": {
"code": "INSUFFICIENT_SCOPE",
"message": "This key cannot do this. Create a key with the reviews:reply scope.",
"requestId": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c"
}
}
Every response also has an X-Request-Id header with the same id as requestId. If a call
keeps failing, send us that id and we can find it in our logs.
Broken requests get the same shape: a body that is not valid JSON is 400 INVALID_JSON, a body over
100 kB is 413 PAYLOAD_TOO_LARGE, and a path that does not exist is
404 NOT_FOUND.
Any route can return 408 TIMEOUT when a request takes too long. The result is then unknown: retry,
and for a reply use the same Idempotency-Key so it is never posted twice.
| Code | HTTP | What it means |
|---|---|---|
MISSING_KEY | 401 | No "Authorization: Bearer" header. |
INVALID_KEY | 401 | The key is wrong or was typed with a mistake. |
KEY_REVOKED | 401 | Someone revoked this key. Create a new one. |
KEY_EXPIRED | 401 | The key reached its expiry date. Create a new one. |
PLAN_REQUIRED | 403 | The organization is not on Pro, Enterprise or Agency. The body has requiredTier. |
INSUFFICIENT_SCOPE | 403 | The key does not have the scope this route needs. |
FORBIDDEN | 403 | This action is not allowed for this key. |
TIMEOUT | 408 | The request took too long and the result is unknown. Retry. For a reply, retry with the same Idempotency-Key. |
RATE_LIMITED | 429 | Too many requests. Wait the Retry-After seconds. |
VALIDATION_FAILED | 400 | A field or filter is missing, unknown or out of range. The message says which. |
INVALID_JSON | 400 | The body is not valid JSON. |
PAYLOAD_TOO_LARGE | 413 | The body is over 100 kB. |
INVALID_CURSOR | 400 | The cursor is broken or belongs to another organization. Start again without it. |
INVALID_URL | 400 | The webhook address is not a public https address on an allowed port. |
NOT_FOUND | 404 | No such review or address in your organization, or no such path. |
ALREADY_REPLIED | 409 | The review already has a published reply. |
MANUAL_POST_REQUIRED | 409 | The API does not publish on this platform. The body has platformUrl, where you post it yourself. |
INTEGRATION_REQUIRED | 400 | The location is not connected to that platform in RevioReputation. |
PLATFORM_POST_FAILED | 422 | On Google the platform refused the reply and nothing was published, so you can retry. On Facebook and Instagram the reply may have gone out anyway, and the message says: "The platform did not confirm the reply. It may already be visible there: check before posting again." |
PLATFORM_REPLY_UNSUPPORTED | 400 | Replying on this platform is not available. |
IDEMPOTENCY_MISMATCH | 422 | This Idempotency-Key was already used with a different request. |
REQUEST_IN_PROGRESS | 409 | The same reply is being published right now. Retry in a few seconds. |
IDEMPOTENCY_UNAVAILABLE | 503 | Requests with an Idempotency-Key are briefly unavailable. Nothing was published. |
DUPLICATE_URL | 409 | This address is already subscribed in RevioReputation or by another key. |
TOO_MANY_ENDPOINTS | 409 | The organization already has 10 webhook addresses. |
CONFLICT | 409 | The request conflicts with the current state. |
UNPROCESSABLE | 422 | The request is well formed but cannot be processed. |
ERROR | any | Any other refusal. Read the HTTP status and the message. |
INTERNAL_ERROR | 500 | Something broke on our side. Retry, and send us the requestId if it keeps happening. |
Endpoints
All paths start with https://revioreputation.com/api/v1/public.
| Route | Scope | What it does |
|---|---|---|
GET/me | any key | The organization, key and plan behind your key. Use it to test a connection. |
GET/locations | locations:read | Your locations. |
GET/reviews | reviews:read | Reviews, newest first, with filters and a cursor. |
GET/reviews/{id} | reviews:read | One review. |
POST/reviews/{id}/reply | reviews:reply | Publish your reply on the platform. |
GET/webhooks | webhooks:manage | Webhook addresses made with this key. |
POST/webhooks | webhooks:manage | Subscribe an address to events. |
DELETE/webhooks/{id} | webhooks:manage | Remove an address made with this key. |
POST/webhooks/{id}/test | webhooks:manage | Send a ping to the address now. |
Who am I: GET /me
Returns the organization, the key and the plan. Zapier and Make can use it as their connection test. The request is in the quick start above.
Locations: GET /locations
Each location comes with its city, country and the platforms connected to it.
curl https://revioreputation.com/api/v1/public/locations \
-H "Authorization: Bearer $REVIO_API_KEY" Reviews: GET /reviews and GET /reviews/{id}
Newest first, by the time RevioReputation received them. To get the next page, send nextCursor back
as cursor with the same filters. Stop when nextCursor is null.
| Filter | What it does |
|---|---|
locationId | One location. |
platform | One platform, for example GOOGLE. All values are in the OpenAPI file. |
status | new, pending, replied, ignored or archived. |
minRating, maxRating | 1 to 5. With either of them you only get reviews with stars, not comments. |
createdAfter | Received by RevioReputation after this moment (ISO 8601). |
reviewedAfter | Written by the customer after this moment (ISO 8601). |
limit | 1 to 100, 25 if you leave it out. |
cursor | The nextCursor of the previous page. |
# First page: the 2 newest reviews with 1 or 2 stars
curl "https://revioreputation.com/api/v1/public/reviews?maxRating=2&limit=2" \
-H "Authorization: Bearer $REVIO_API_KEY"
# Next page: send nextCursor back as cursor, with the same filters
curl "https://revioreputation.com/api/v1/public/reviews?maxRating=2&limit=2&cursor=eyJvIjoiNmQ0YjVlMjAi..." \
-H "Authorization: Bearer $REVIO_API_KEY" {
"data": [
{
"id": "2f1c9a4e-8b7d-4c3a-9e21-5d6f7a8b9c0d",
"locationId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"platform": "GOOGLE",
"contentType": "REVIEW",
"rating": 2,
"text": "Good paella, but we waited 40 minutes for a table on Saturday.",
"language": "en",
"author": { "name": "Maria G." },
"reviewedAt": "2026-11-07T19:12:00.000Z",
"createdAt": "2026-11-07T19:40:03.000Z",
"status": "new",
"reply": null,
"url": "https://maps.google.com/?cid=1234567890"
}
],
"nextCursor": "eyJvIjoiNmQ0YjVlMjAi..."
} reviewedAt is when the customer wrote it, createdAt is when we received it.
rating is null for comments and mentions without stars. reply stays
null until a reply is published, and then reply.source tells you who published it.
Polling without missing a review
If you can't receive webhooks, poll with createdAfter. A review can show up in the list a little
after the time in its createdAt, so ask from a few minutes before your last check and skip the ids
you already have.
// Poll for new reviews. Ask from a few minutes before your last check (the overlap)
// and skip the ids you already have: a review can appear in the list shortly after its createdAt.
// Keep `seen` and `lastCheck` in your database; set lastCheck to the moment this run started.
const API = 'https://revioreputation.com/api/v1/public';
const OVERLAP_MS = 10 * 60 * 1000;
async function fetchNewReviews(lastCheck, seen) {
const createdAfter = new Date(lastCheck.getTime() - OVERLAP_MS).toISOString();
const fresh = [];
let cursor = null;
for (;;) {
const url = new URL(`${API}/reviews`);
url.searchParams.set('createdAfter', createdAfter);
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.REVIO_API_KEY}` } });
if (res.status === 429) {
const wait = Number(res.headers.get('Retry-After') || 60);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
continue;
}
if (!res.ok) {
// A proxy in between can return an HTML page instead of our JSON error.
const body = await res.json().catch(() => null);
throw new Error(`RevioReputation API ${res.status}: ${body?.error?.code ?? 'no JSON error body'}`);
}
const page = await res.json();
for (const review of page.data) {
if (seen.has(review.id)) continue;
seen.add(review.id);
fresh.push(review);
}
if (!page.nextCursor) return fresh;
cursor = page.nextCursor;
}
} Posting a reply
POST /reviews/{id}/reply with {"text": "..."}, up to
4,096 characters. We trim spaces at the ends and publish the rest exactly as you
sent it. We don't rewrite or check your words. The response is the review with its reply, and a
review.replied webhook goes out.
curl -X POST https://revioreputation.com/api/v1/public/reviews/2f1c9a4e-8b7d-4c3a-9e21-5d6f7a8b9c0d/reply \
-H "Authorization: Bearer $REVIO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-ticket-48213" \
-d '{"text": "Thank you, Maria. You are right, the wait on Saturday was too long. We are sorry."}' Where it can post
The API publishes on Google, Facebook and Instagram. On Facebook and Instagram that means pages already connected in RevioReputation: new Facebook and Instagram connections are not open yet, they wait for Meta to approve our app.
On TripAdvisor, Trustpilot, Yelp, Booking.com and Airbnb you paste the reply yourself. For those the API returns 409 MANUAL_POST_REQUIRED with
platformUrl, the page where you paste it.
LinkedIn gets the same 409 with a link to the post, or to your LinkedIn feed when the post link is unknown. A
person publishes every reply there, in RevioReputation or on LinkedIn itself.
When it refuses
409 ALREADY_REPLIED: the review already has a published reply. Changing a published reply is not possible in version 1.400 INTEGRATION_REQUIRED: the location is not connected to that platform in RevioReputation.422 PLATFORM_POST_FAILED: on Google, the platform said no and nothing was published, so you can retry. On Facebook and Instagram a reply is a new comment, and a slow platform looks the same as a no. There the message is "The platform did not confirm the reply. It may already be visible there: check before posting again." A retry with the same Idempotency-Key returns this same response. Look at the post first, then send a new key only if the reply is not there.-
408 TIMEOUT: the platform took too long, so we don't know yet if the reply went out. Retry with the sameIdempotency-Key: you get the response of the first request, never a second post.
Retrying safely: Idempotency-Key
Add an Idempotency-Key header (1 to 100 characters, for example your ticket
id). We remember it for 24 hours per API key. A retry with the same key and the same body gets the
first response back and never posts twice.
- Same key, different review or text:
422 IDEMPOTENCY_MISMATCH. Use a new key for a new reply. - The first request is still running:
409 REQUEST_IN_PROGRESS. Retry in a few seconds.
Webhooks
Add an address in Integrations > Developers, or with the API below, and pick the events. We send each event to it as a JSON POST.
An event goes out when RevioReputation sees the review during its regular sync with the platform. That is not the
moment the customer wrote it: use reviewedAt for that.
Only new reviews send review.created: written in the last 7 days, and not earlier
than 24 hours before the platform was connected. Older reviews, like the ones the
first import brings in, send no events. Read them with GET /reviews.
| Event | When |
|---|---|
review.created | RevioReputation got a new review. |
review.replied | A reply was published: in RevioReputation, through the API, by autopilot or on the platform itself. |
review.negative | A new review with 1 or 2 stars. It comes together with review.created. |
ping | Only from the "Send test event" button and POST /webhooks/{id}/test. |
- The address must be public
https, on port 443, or on port 1024 or higher that is not a database port. - Up to 10 addresses per organization.
- Each address gets its own signing secret (
whsec_...). We show it once.
What we send
{
"id": "evt_8QmZ3kLp0aBcDeFgHiJkLmNo",
"type": "review.negative",
"apiVersion": "2026-11-01",
"createdAt": "2026-11-07T19:40:03.000Z",
"organizationId": "6d4b5e20-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
"data": {
"review": {
"id": "2f1c9a4e-8b7d-4c3a-9e21-5d6f7a8b9c0d",
"locationId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"platform": "GOOGLE",
"contentType": "REVIEW",
"rating": 2,
"text": "Good paella, but we waited 40 minutes for a table on Saturday.",
"language": "en",
"author": { "name": "Maria G." },
"reviewedAt": "2026-11-07T19:12:00.000Z",
"createdAt": "2026-11-07T19:40:03.000Z",
"status": "new",
"reply": null,
"url": "https://maps.google.com/?cid=1234567890"
},
"location": { "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "name": "Casa Marisol" }
}
} data.review has the same fields as GET /reviews/{id}, frozen at the moment of the event. A retry sends exactly the same body.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | RevioReputation-Webhooks/1.0 |
Revio-Event-Id | The id of the event, same as id in the body. Use it to skip repeats. |
Revio-Event-Type | Same as type in the body. |
Revio-Delivery-Id | One event going to one address, across its retries. |
Revio-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. How to check it is below. |
How your server should respond
- Any
2xxwithin 10 seconds and the delivery counts. We don't read your body and don't follow redirects. 410 Goneswitches the address off right away. Use it when you remove your endpoint.- Any other status, or no response, is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 24 h. That is 7 attempts over about 33 hours. Then the delivery is marked failed.
- An address is switched off after 20 failed attempts in a row, or when it has been failing for 3 days without a single success. The organization owner gets an email, and you can turn it back on in Integrations > Developers.
The same event can arrive more than once, so skip any Revio-Event-Id you have already handled. Events
are not sent in a fixed order. If order matters to you, compare createdAt or read the review again.
Changing the secret in Integrations > Developers has no overlap: the old secret stops matching at once. Until your server has the new secret, its checks fail and the deliveries go into retries, so update both sides together.
Test it
Send test event in the tab, or POST /webhooks/{id}/test, sends a
ping right away and shows the status code your server returned. A ping is not retried. One test per
address every 5 seconds, 20 a minute per organization.
Managing addresses with the API
POST /webhooks subscribes an address and returns its secret, only in this response. The address belongs
to the key that made it: GET, DELETE and test only see addresses made with
the same key, and revoking the key removes them. Addresses added in RevioReputation are not visible to keys.
Subscribing the same address again with the same key turns it back on with a new secret.
curl -X POST https://revioreputation.com/api/v1/public/webhooks \
-H "Authorization: Bearer $REVIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/revio-hook", "events": ["review.negative"], "description": "Bad reviews to the CRM"}' {
"id": "7b3e1f20-4c5d-4e6f-8a9b-0c1d2e3f4a5b",
"url": "https://example.com/revio-hook",
"description": "Bad reviews to the CRM",
"events": ["review.negative"],
"source": "api",
"status": "ACTIVE",
"disabledReason": null,
"consecutiveFailures": 0,
"lastSuccessAt": null,
"lastFailureAt": null,
"lastError": null,
"createdAt": "2026-11-09T10:00:00.000Z",
"secret": "whsec_..."
} curl -X POST https://revioreputation.com/api/v1/public/webhooks/7b3e1f20-4c5d-4e6f-8a9b-0c1d2e3f4a5b/test \
-H "Authorization: Bearer $REVIO_API_KEY" Checking the signature
- Take the raw body exactly as it arrived, before any JSON parsing.
- Read
tandv1from theRevio-Signatureheader. - Compute HMAC-SHA256 with your secret over
t, a dot and the raw body, as hex. - Accept the request if it equals a
v1value andtis within 300 seconds of your clock. The time check stops someone from replaying an old request.
Each request carries exactly one v1.
// Node.js: check Revio-Signature against the RAW body, not the parsed JSON
const crypto = require('crypto');
function verifyRevio(rawBody, header, secret, now = Date.now() / 1000) {
const pairs = header.split(',').map((part) => part.split('='));
const t = Number(pairs.find(([k]) => k === 't')?.[1]);
const signatures = pairs.filter(([k, v]) => k === 'v1' && /^[0-9a-f]{64}$/.test(v)).map(([, v]) => v);
if (!Number.isInteger(t) || Math.abs(now - t) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
return signatures.some((v) => crypto.timingSafeEqual(Buffer.from(v, 'hex'), expected));
}
// Express:
// app.post('/revio', express.raw({ type: 'application/json' }), (req, res) => {
// const ok = verifyRevio(req.body.toString('utf8'), req.get('Revio-Signature') || '', process.env.REVIO_WEBHOOK_SECRET);
// if (!ok) return res.sendStatus(400);
// const event = JSON.parse(req.body.toString('utf8'));
// // skip event.id if you have already handled it, then do your work
// res.sendStatus(200);
// }); # Python: check Revio-Signature against the RAW body, not the parsed JSON
import hashlib, hmac, re, time
def verify_revio(raw_body, header, secret, now=None):
pairs = [part.partition("=") for part in header.split(",")]
t = next((int(v) for k, _, v in pairs if k == "t" and v.isascii() and v.isdigit()), None)
signatures = [v for k, _, v in pairs if k == "v1" and re.fullmatch("[0-9a-f]{64}", v)]
now = time.time() if now is None else now
if t is None or abs(now - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
# Flask:
# @app.post("/revio")
# def revio():
# raw = request.get_data(as_text=True) # not request.json
# if not verify_revio(raw, request.headers.get("Revio-Signature", ""), os.environ["REVIO_WEBHOOK_SECRET"]):
# return "", 400
# event = json.loads(raw)
# # skip event["id"] if you have already handled it, then do your work
# return "", 200 Test your code with this vector
With now set to the timestamp below, your function must return true. Change one character of the body
and it must return false. Our own tests run both samples above on these exact values.
secret: whsec_RevioTestVector0000000000000000000000
timestamp: 1762596000
header: t=1762596000,v1=6a6659438f766fec367bfd8f2db0734d95de679cf7ca1649b2341aa571ac6814
body: {"id":"evt_TestVector000000000000","type":"ping","apiVersion":"2026-11-01","createdAt":"2025-11-08T10:00:00.000Z","organizationId":"00000000-0000-4000-8000-000000000000","data":{"message":"Test event from RevioReputation"}} Zapier and Make
There is no RevioReputation app in Zapier or Make yet. You can still connect both today with their own webhook tools.
Get new reviews into a Zap or a scenario
- Zapier: start a Zap with Webhooks by Zapier and the Catch Hook trigger. Make: start a scenario with Webhooks and Custom webhook.
- Copy the URL they give you and add it as an address in Integrations > Developers. Pick the events.
-
Press Send test event to check the connection. The test is a
ping: itsdatahas only amessageand no review fields. - To map review fields, wait for the first real event, or send the sample body from the Webhooks section to the hook URL yourself with curl.
These steps don't check the Revio-Signature header. Treat the hook URL like a password and keep it
private.
Post a reply from a Zap or a scenario
Use Webhooks by Zapier with Custom Request, or the HTTP module
with Make a request in Make. Send a POST to /reviews/{id}/reply with the
Authorization header and a JSON body with text, as in the curl example above.
OpenAPI file
Every route, field and error code is in one OpenAPI 3 file: /openapi/public-v1.json. We generate it from the code that serves the API. Import it into Postman, Insomnia or any OpenAPI tool.
Changelog
Every webhook body has apiVersion. If we ever change the payload in a way that breaks existing code, it
gets a new date here.
| Date | Change |
|---|---|
2026-11-01 | Version 1: reviews, locations, replies and webhooks. |
Questions
Something you need that version 1 doesn't do? Write to info@revioreputation.com and
tell us what you are building. If a call failed, add its requestId.