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#
httpsonly, 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,.internalor.localhostare 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, 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.
{
"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 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 "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:
<t>.<raw body>Check every delivery before you trust it:
- Read the raw body as bytes, before any JSON parsing. A parsed and serialized body will not match.
- Split the header on commas and read
tandv1. - Refuse a
tmore than five minutes from your clock. Every attempt is signed again with a fresht, so retries pass. - Compute the HMAC with the whole secret,
whsec_prefix included, as text. Do not base64 decode it. - Compare in constant time.
Node#
Uses only the built in crypto module.
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:
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.
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.