Sign in
Feedback docs

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#

EventWhendata
response.createdAn 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.updatedA later step of the same answer arrives.collector_id, response_id, and state set to completed on the step that finishes the answer.
message.createdA 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.limitYour 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, 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 send none, and imported answers 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.

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:

HeaderValue
Content-Typeapplication/json
OneStar-EventThe event type, such as response.created.
OneStar-SignatureThe 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 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.

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:

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.

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:

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.

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.