# Revup REST API, Webhooks & MCP

Revup ships a JSON REST API and signed webhooks so your booking system, POS or CRM can queue review
requests the moment a job finishes — and so a private message from an unhappy customer reaches your
helpdesk quickly. Every customer who rates is shown your public review link, whatever the rating.

## Authentication

Create a key in **Settings → API & Webhooks**. Send it on every call:

    Authorization: Bearer apk_...

Shown once at creation, stored as a SHA-256 hash. Revoke any time — revoking the key is the off switch.

Every write checks the key holder's role, exactly as the screen does: a viewer's key cannot add a contact or create a review request (`403 forbidden`).

## Endpoints

| Method & path | What it does |
|---|---|
| `GET  /api/ping` | Verify a key |
| `GET  /api/contacts` | Newest 500 contacts |
| `POST /api/contacts` | Create a contact (`name` + `email` **or** `phone`; optional `served_by`, the team member their asks are credited to) |
| `GET  /api/requests` | Newest 200 requests; `?status=` filters |
| `POST /api/requests` | **Queue (and by default send) review requests for a campaign**; optional `staff` credits the team member who did the job (3.2.0). Each request carries `staff` and `nps` |
| `GET  /api/feedback` | **Private messages from the rating page**; `?handled=false` for the open ones. Each carries `reply_draft`, `reply_by` and `reply_sent_at` (3.2.0) |
| `POST /api/feedback/{id}/draft` | Save a reply draft on a private message: `{"body": "..."}`. Nothing is sent; a person sends it from the inbox (3.2.0). `409` once the reply was sent |
| `GET  /api/testimonials` | Consented testimonials with ready-made share links; `?status=` pending, approved or hidden (3.2.0) |
| `GET  /api/openapi.json` | OpenAPI 3.0 description |

### Queue requests after a job completes

    curl -X POST https://reviews.example.com/api/requests \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"campaign_id":1,"contact_ids":[12,13,14]}'

Add `"send": false` to queue without sending — useful if you want a human to review the batch first.

**Duplicate-suppressed.** This reuses the same core the UI uses, so a contact who already has an open
request for that campaign is *not* queued a second time. The response reports `queued`, `sent`,
`pending` and `failed` counts so you can reconcile.

`pending` means the channel is not configured (no SMTP / no Twilio) — the request still exists and
carries a `gate_url` you can send yourself.

### Read the private messages

    curl -H "Authorization: Bearer apk_..." \
      "https://reviews.example.com/api/feedback?handled=false"

```json
{ "feedback": [ { "id": 4, "request_id": 19, "contact_id": 7,
                  "rating": 2, "message": "Waited 40 minutes.",
                  "handled": false, "created_at": "2026-08-06 11:44:02" } ], "count": 1 }
```

**These are the private messages.** A customer who rates below your threshold is shown your public
review link like everyone else, and is also offered a private message to you — it lands here. Pull
them into your helpdesk and close the loop.

The payload deliberately carries **no gate token**: a token is a capability to submit a rating, and
replaying it is not something an integration should be able to do.

## MCP — the agent endpoint (new in 3.0)

Revup speaks **MCP (Model Context Protocol)** on one route, so an assistant can watch your review
pipeline and queue follow-ups without anyone building a bridge first.

    POST /mcp
    Authorization: Bearer apk_...          ← the SAME revocable key the REST API uses
    Content-Type: application/json

Stateless streamable-HTTP: one JSON-RPC message per request, no session, no SSE. A `GET` gets
`405` with `Allow: POST`, as the transport requires — and that answer is unauthenticated, so a
client probing for MCP support learns you speak it before it has a key.

### Connect it to an assistant

Mint the key in the app first: **Settings → API keys**. Choose **Read only** when the assistant should
answer questions but never change anything — the endpoint then lists only the read tools and refuses the
rest by name, so a careless prompt cannot write. Full access behaves as before.

Every client needs the same three facts, and nothing in the handshake is vendor-specific:

| | |
|---|---|
| **The address** | `https://reviews.example.com/mcp` |
| **The key** | header `Authorization: Bearer apk_xxxx` |
| **The transport** | MCP over streamable HTTP, stateless |

**Claude** — one command, or the same URL and header as a custom connector in the desktop and web apps:

```sh
claude mcp add --transport http revup https://reviews.example.com/mcp \
  --header "Authorization: Bearer apk_xxxx"
```

**ChatGPT and the OpenAI API** — one entry in the Responses API's `tools` array (in ChatGPT itself, the
same URL and key go in as a connector):

```json
{
  "type": "mcp",
  "server_label": "revup",
  "server_url": "https://reviews.example.com/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}
```

**Every other client spells the same three facts differently — copy the shape from its own
documentation, not from here.** VS Code is the clearest example of why: its
[configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(read 6 September 2026) puts servers in `.vscode/mcp.json` under a **`"servers"` object** — *"an
object that maps server names to their configurations"*. Pasted as-is,
a block written for another client will not load there. The id, the URL and the token are what travel; the JSON around
them belongs to whichever client you are configuring.

**A local model, n8n, or your own code** — n8n's **MCP Client** node takes the URL and the same
`Authorization: Bearer` header; a model running on your own machine reaches it through any MCP client, so
nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:

```sh
curl -X POST https://reviews.example.com/mcp \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet:
Revup implements the protocol, not an integration with a particular vendor.

### The tools

| Tool | Writes? | What it does |
|---|---|---|
| `list_requests` | no | Review requests with status, rating and the customer they belong to. Filter by `status`, `rated`, `limit`. |
| `request_detail` | no | One request, plus any private feedback left against it. |
| `create_request` | **YES** | Queues a review request for a customer and mints its one-time rating link. |
| `response_stats` | no | Response rate, average rating, how many raters opened your review page and how many left a private message — the dashboard's own figures — and the NPS summary (3.2.0). |
| `list_feedback` | no | The private messages, each with the rating, the customer's first name, any reply draft and your reply templates for that rating (3.2.0). |
| `draft_reply` | **YES (a draft)** | Saves a reply draft on one private message. It is never sent from here: a person reads, edits and sends it from the inbox (3.2.0). |

`status` is `queued` (scheduled inside a send window), `pending` (waiting to be sent or copied),
`sent`, or `failed` — the same four words the Requests screen uses.

### What the endpoint refuses, and why

- **It cannot change the rating page.** The threshold and the review URL are not writable over
  MCP. An agent may chase reviews; the page every customer sees stays with the owner.
- **No figure is recomputed.** `response_stats` returns `Revup::stats()` — the same call the
  dashboard makes — so an agent can never quote a response rate Revup disagrees with.
- **`create_request` queues, it does not send.** Sending goes out through your own SMTP, your own
  Twilio, or as a link you copy; that is the owner's action and the owner's cost.
- **It writes requests through the product's own creator**, so the one-open-request-per-customer
  rule and token minting apply exactly as they do in the browser. A second request for a customer
  who already has one open is refused.
- **Arguments must be single values**, unknown arguments are refused by name, and out-of-enum
  values are refused with the valid options listed.
- **Roles apply exactly as they do in the browser.** A viewer's key is refused `create_request`.

Every MCP write lands in the **audit trail** beside the browser and API writes, naming the key's
user as the actor and recording `via: mcp`.

### Example

    curl -X POST https://your-install/mcp \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
           "params":{"name":"response_stats","arguments":{}}}'

## Webhooks

Add a receiver in **Settings → API & Webhooks** and choose events:

| Event | Fires when |
|---|---|
| `request.sent` | A review request is dispatched |
| `feedback.received` | A customer sends you a private message from the rating page — **wire this to your helpdesk** |
| `rating.public` | A customer opens your public review page from the rating page (any rating; once per request) |
| `testimonial.approved` | You approve a testimonial a customer agreed to. Carries the quote, the name and `share` links for X, Facebook and LinkedIn, so your own automation can post it (3.2.0) |

Every delivery is signed:

    X-Revup-Event: feedback.received
    X-Revup-Signature: sha256=<hmac_sha256(raw_body, your_webhook_secret)>

Compare with `hash_hmac('sha256', $rawBody, $secret)` and reject on mismatch.

## Inbound: automatic asks (3.2.0)

Settings → **Automatic asks** gives each trigger a private address, `POST /hooks/in/{key}`. Another
app posts to it when a job, booking or sale ends; Revup finds or adds the customer and asks them
through the trigger's campaign — at once, or in the trigger's own send window.

- **Signed.** With a signing secret set, a delivery carries `X-<App>-Signature: sha256=<HMAC-SHA256 of
  the raw body>` (any header ending in `-Signature`); a wrong one answers `401` and creates nothing.
  Without a secret, the address itself is the key.
- **Generic JSON.** `email` or `phone` (and `name`, `staff`, `id`) at the top level or inside
  `customer`, `contact` or `data`. Form-encoded bodies work too.
- **Jobora.** `job.completed` (and `job.signed_off`). The customer's email is read from Jobora's own
  `GET /api/customers` with the key you give the trigger; the assigned technician is credited.
- **Slotly.** `appointment.created` and `appointment.rescheduled` queue the ask for the morning after
  the appointment ends, `appointment.cancelled` withdraws it, `payment.recorded` asks if nothing was
  queued. The customer is read from Slotly's `GET /api/v1/appointments/{id}`.
- **Once per customer.** A customer asked in the last N days (30 by default), by any app or person,
  is not asked again; the delivery answers `200` with `"outcome":"duplicate"` so the sender does not
  retry. Every delivery is logged on the Automatic asks page.

```bash
curl -X POST https://reviews.example.com/hooks/in/<key> \
  -H 'Content-Type: application/json' \
  -H "X-Signature: sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)" \
  -d "$BODY"
```

## Public widget (3.2.0)

When you switch the widget on (Testimonials), three public addresses answer; each sets no cookie and
is cacheable for five minutes. Switched off, they answer `404`.

| Address | What it is |
|---|---|
| `GET /widget.js` | The two-line embed's script: frames `/widget` and sizes it |
| `GET /widget` | The widget page: approved comments and, if chosen, the average of every rating |
| `GET /widget.json` | The same data as JSON, open to any site (CORS), for drawing it yourself |

## Email notifications

Revup already had its own SMTP settings and mailer, so 2.0 adds **no new mail configuration** — just a
notify address and one toggle in Settings: **email me the moment a customer sends a private message.**
That is the one alert where minutes matter.

## Errors

| Code | Meaning |
|---|---|
| `401` | Missing, unknown or revoked key |
| `422` | Validation — `detail` names the offending field |


## Connect it to Zapier, Make or n8n

RevUp talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.

### Before you start

- In RevUp, open **Settings → API & Webhooks**. Mint an API key (choose **Read only** if the other tool only reads), and add a webhook when you have its URL (below).
- Use your install's full address, for example `https://revup.yourcompany.com`.

### When something happens in RevUp: send it to another tool

1. In the other tool, create the trigger that gives you a URL to receive webhooks:
   - **Zapier:** the "Webhooks by Zapier" app, trigger **Catch Hook** (Webhooks by Zapier needs a paid Zapier plan).
   - **Make:** module **Webhooks → Custom webhook**.
   - **n8n:** the **Webhook** node.
2. Copy that URL into RevUp under **Settings → API & Webhooks**, and choose the event or events below.
3. Make the event happen once in RevUp, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `request.sent` | A review request is dispatched |
| `feedback.received` | A customer sends you a private message from the rating page — wire this to your helpdesk |
| `rating.public` | A customer opens your public review page from the rating page (any rating; once per request) |
| `testimonial.approved` | You approve a testimonial a customer agreed to. Carries the quote, the name and `share` links for X, Facebook and LinkedIn, so your own automation can post it (3.2.0) |

Every delivery carries `X-Revup-Signature`, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.

### When something happens elsewhere: act in RevUp

- **Zapier:** "Webhooks by Zapier", action **POST** (or **Custom Request**).
- **Make:** module **HTTP → Make a request**.
- **n8n:** the **HTTP Request** node.

Send the request to your install's address plus the path from the Endpoints table above, with the header `Authorization: Bearer <your API key>` and a JSON body. A read-only key cannot write.

The endpoints that change something:

- `POST /api/contacts`: Create a contact (`name` + `email` **or** `phone`; optional `served_by`, the team member their asks are credited to)
- `POST /api/requests`: **Queue (and by default send) review requests for a campaign**; optional `staff` credits the team member who did the job (3.2.0). Each request carries `staff` and `nps`
- `POST /api/feedback/{id}/draft`: Save a reply draft on a private message: `{"body": "..."}`. Nothing is sent; a person sends it from the inbox (3.2.0). `409` once the reply was sent

### Example recipes

- **request.sent → a row in Google Sheets:** webhook trigger on `request.sent` (A review request is dispatched), then the Google Sheets "Create row" action with the fields you want.
- **feedback.received → a message in Slack or Teams:** webhook trigger on `feedback.received` (A customer sends you a private message from the rating page — wire this to your helpdesk), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → RevUp:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/contacts` (Create a contact (`name` + `email` **or** `phone`; optional `served_by`, the team member their asks are credited to)).

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; RevUp's own automated tests check its API.
