# Webhooks

Signed webhook events for new answers, replies and limits, and how to check the signature.

A webhook tells your server the moment something happens: a new answer, a reply in a conversation, or a change in your monthly answers. OneStar sends a signed JSON POST to an https address you choose. Webhooks are part of Max.

## Add a webhook

In OneStar, open **Settings**, **Feedback API**, and under **Webhooks** paste your address and choose **Add webhook**.

- The signing secret is shown once. Copy it into your server's configuration right away. It starts with `whsec_`.
- A new webhook takes every event. Click an event's chip to turn it off or on for that webhook.
- **Turn off** stops sending; **Turn on** starts again. **Remove** deletes the webhook.
- A workspace keeps up to 5 webhooks, each to its own address.

There is no way to show a secret again. To get a new one, remove the webhook and add the same address again.

## Which addresses work

- `https` only, on the standard port: no port number in the address, and no user name or password in it.
- A public host name. IP addresses, `localhost`, and names ending in `.local`, `.internal` or `.localhost` are refused.
- The name must resolve to public addresses. OneStar checks this when you add the webhook and again before every delivery.
- Redirects are not followed. A redirect counts as a failed delivery.

To receive events on your own machine while you build, run a tunnel that gives your local server a public https address, and add that address as the webhook.

## Events

| Event | When | `data` |
|---|---|---|
| `response.created` | An answer arrives. The widget sends an answer a step at a time, so this often comes with the first tap only. | `collector_id`, `response_id`, `version_id`, `channel`, and `state` set to `completed` when the answer was sent whole. |
| `response.updated` | A later step of the same answer arrives. | `collector_id`, `response_id`, and `state` set to `completed` on the step that finishes the answer. |
| `message.created` | A new message in a conversation about an answer: a reply from your workspace, or the person writing back. | `collector_id`, `response_id`, `message_id`. |
| `collector.limit` | Your monthly answers change state: `near` when they near the allowance, `allowance` when they pass it, `ceiling` when they reach the most your plan stores and new answers are refused. | `reason`, `month` (such as `2026-10`). It is about your whole workspace, so it names no form. |

The event whose `data.state` is `completed` is the one with everything the person answered. Act on that one when you want the full answer, and treat earlier events as a heads up.

`collector_id` is the form's `id` in the [REST API](https://onestar.sh/docs/feedback/rest-api.md), not its `f_` public id. `channel` is `embed` for the widget on your site and `hosted` for the form's own page.

Some answers never send an event: [test answers](https://onestar.sh/docs/feedback/testing.md) send none, and [imported answers](https://onestar.sh/docs/feedback/import.md) send no answer events (an import can still send `collector.limit`).

## Payload

Events carry ids and counts only, never what anyone wrote and never a contact.

```json response.created
{
  "id": "evt_1042",
  "type": "response.created",
  "created_at": "2026-10-30T18:44:03.120044+00:00",
  "data": {
    "collector_id": "9c2e41b07d5a4f31a8e6b2c9d0f71e54",
    "response_id": "5c0e7d2a9b8f4e61a3d2c4b7e9f01a6d",
    "version_id": "b41e0f7c2d9a4c6e8f1a3b5d7c9e0f2a",
    "channel": "embed",
    "state": "completed"
  }
}
```

`id` names the event and stays the same when a delivery is retried, so use it to skip an event you already handled. Events can arrive out of order: a retried `response.created` may come after its `response.updated`.

Every delivery has these headers:

| Header | Value |
|---|---|
| `Content-Type` | `application/json` |
| `OneStar-Event` | The event type, such as `response.created`. |
| `OneStar-Signature` | The time and the signature, such as `t=1761849843,v1=9b1c...`: `t` in Unix seconds, `v1` in hex. |

## Read the answer

To get what the person wrote, ask the [REST API](https://onestar.sh/docs/feedback/rest-api.md) for the form's answers and find the one whose `id` is the event's `response_id`. Answers come newest first, so a fresh one is on the first page. The list covers the versions that ask the same questions as the live one; an answer to an earlier version with other questions is listed when you pass its `version`.

```bash curl
curl "https://api.onestar.sh/v1/owner/forms/9c2e41b07d5a4f31a8e6b2c9d0f71e54/answers?days=1" \
  -H "Authorization: Bearer os_sk_..."
```

## Check the signature

`v1` is the HMAC SHA256, in hex, of the timestamp, a dot and the raw request body, keyed with the signing secret:

```text signed text
<t>.<raw body>
```

Check every delivery before you trust it:

1. Read the raw body as bytes, before any JSON parsing. A parsed and serialized body will not match.
2. Split the header on commas and read `t` and `v1`.
3. Refuse a `t` more than five minutes from your clock. Every attempt is signed again with a fresh `t`, so retries pass.
4. Compute the HMAC with the whole secret, `whsec_` prefix included, as text. Do not base64 decode it.
5. Compare in constant time.

### Node

Uses only the built in `crypto` module.

```js verify-webhook.mjs
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_S = 300;

// rawBody: the request body as a Buffer, exactly as it arrived.
export function verifyOneStar(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
  const parts = {};
  for (const item of (header || "").split(",")) {
    const at = item.indexOf("=");
    if (at > 0) parts[item.slice(0, at)] = item.slice(at + 1);
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1 || Math.abs(now - t) > TOLERANCE_S) return false;
  const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex"));
  const given = Buffer.from(parts.v1);
  return expected.length === given.length && timingSafeEqual(expected, given);
}
```

With Express, take the body raw on this route:

```js server.mjs
import express from "express";
import { verifyOneStar } from "./verify-webhook.mjs";

const app = express();

app.post("/onestar", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyOneStar(req.body, req.get("OneStar-Signature"), process.env.ONESTAR_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  res.sendStatus(200);
  // Handle event.type and event.data after answering.
});

app.listen(3000);
```

### Python

Uses only the standard library.

```python verify_webhook.py
import hashlib
import hmac
import time

TOLERANCE_S = 300


def verify_onestar(raw_body: bytes, header: str, secret: str, now: int | None = None) -> bool:
    """raw_body: the request body as bytes, exactly as it arrived."""
    parts = dict(item.split("=", 1) for item in (header or "").split(",") if "=" in item)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    now = int(time.time()) if now is None else now
    if abs(now - t) > TOLERANCE_S:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

In Flask, pass `request.get_data()`; in Django, `request.body`; in FastAPI, `await request.body()`.

## Answer quickly

Answer with any 2xx status within a few seconds, and do slow work after you answer. The body of your answer is ignored.

A delivery that gets no 2xx in time, an error status, a redirect or no connection is retried later, with longer waits between tries, over several hours. After the last try it is given up.

After many failed deliveries in a row, the webhook stops: Settings shows it as stopped with its last error, and OneStar sends you a notification. Fix your endpoint, then choose **Turn on**. Deliveries that were still waiting are sent then. Events that happened while the webhook was off or stopped are not sent later; read them through the [REST API](https://onestar.sh/docs/feedback/rest-api.md).
