SixStar API and webhooks
This page is for developers on a team plan. It covers the two ways your own systems can talk to SixStar:
- The API, which your scripts call to read your team's alerts and to check or deploy your rules file.
- Webhooks, which SixStar calls to send each alert to your own server.
Everything is read only on the chain. Nothing here can move funds, sign a transaction or change a contract.
1. The API
Base URL
https://team.sixstarlab.com/api/v1/
Every path below is relative to it.
Keys
- Only the team owner makes and revokes keys, on the Account page under "API keys".
- A key is shown once, when it is made. Copy it then. We keep only a hash of it and its first 12 characters, so we cannot show it again.
- A key starts with
sxs_and is 47 characters long. - Each key has one scope:
alerts:read("Read alerts") reads your team's alerts.rules:write("Deploy rules") reads and replaces your team's rules file.
- A team has at most 10 live keys. A key expires 365 days after it is made. The Account page shows when each key was last used and when it expires.
- Revoking a key stops it at once.
Send the key in the Authorization header, and nowhere else:
Authorization: Bearer sxs_...
Calls come from servers, not browsers
The API is for scripts, pipelines and servers. A request that carries an Origin header (which every browser adds) is refused with 403 browser. There are no CORS headers, and cookies are never read. Only GET and POST are accepted.
Rate limit
60 calls a minute for each key. Call number 61 within 60 seconds gets 429 rate with Retry-After: 60. Calls answered with 429 do not count toward the limit, so a script that waits and tries again will get through.
Errors
Every error is JSON with one machine-readable code, never a sentence:
{ "error": "scope" }| Status | error | What it means |
|---|---|---|
| 400 | bad_request | A parameter or the body is not in the expected form |
| 401 | key | The key is missing, malformed, unknown, revoked or expired. All five get the same answer |
| 403 | browser | The request carried an Origin header |
| 403 | scope | The key's scope does not allow this call |
| 403 | lapsed | Your team's access has ended |
| 403 | inactive | A deploy needs an active period; your team's has ended |
| 404 | not_found | No such path, or no such alert for your team |
| 405 | method_not_allowed | The path exists, but not for this method |
| 429 | rate | More than 60 calls in the last minute. Wait for Retry-After |
| 500 | server | Something failed on our side. Try again later |
| 503 | not_open | The service is not available right now |
GET alerts
Scope: alerts:read. Your team's alerts, newest first.
| Parameter | Form | Default |
|---|---|---|
since | UTC time, YYYY-MM-DDTHH:MM:SSZ (milliseconds allowed) | 90 days ago |
limit | 1 to 100 | 100 |
cursor | the next value from the previous page | none |
Alerts older than 90 days are never returned, whatever since says. Any other parameter is ignored.
curl -sS -H "Authorization: Bearer $SIXSTAR_KEY" \ "https://team.sixstarlab.com/api/v1/alerts?since=2026-11-01T00:00:00Z&limit=50"
The answer:
{
"alerts": [
{
"id": "security:123",
"kind": "security",
"alert": "SA-10",
"title": "Owner changed",
"level": "urgent",
"network": 1,
"contract": "0x...",
"transaction": "0x...",
"block": 21000000,
"time": "2026-11-01T00:12:00.000Z",
"text": "..."
}
],
"next": "WyIyMDI2..."
}| Field | Meaning |
|---|---|
id | The alert's id. Use it with GET alerts/<id> |
kind | Which part of SixStar raised it: security, safe, limits, issuer, governance, btcxrpl or solana |
alert | The alert type, for example SA-10 |
title | The alert's title, for example "Owner changed" |
level | urgent, notice, or null when the alert carries no level |
network | The EVM chain id, or null (Bitcoin, XRP Ledger and Solana alerts have none) |
contract | The contract or address the alert is about, or null |
transaction | The transaction it was found in, or null. For Bitcoin and XRP Ledger this is the payment's reference; for Solana, the signature |
block | The block it was found in, or null. For Solana, the slot |
time | When the alert was raised, in UTC |
text | The full alert text, exactly as your channels received it |
Paging. When next is not null, pass it as cursor to get the following page. A page always continues exactly after the last alert of the previous one, so new alerts arriving in between never make you miss or repeat one. To poll for new alerts, keep the time of the newest alert you have and pass it as since on the next call.
GET alerts/<id>
Scope: alerts:read. One alert, in the same form, as { "alert": { ... } }. An id that is not one of your team's alerts is 404 not_found.
GET rules
Scope: rules:write. Your team's saved rules file:
{ "text": "...", "source": "file", "savedAt": "2026-11-01T00:00:00.000Z" }source is file when the rules were deployed through the API.
POST rules
Scope: rules:write. The request body is your rules file itself (YAML), at most 72,000 bytes.
POST rules?check=1checks the file against your team's own watches and saves nothing.POST rulesdeploys it. It is all or nothing: if any rule has an error, nothing changes.
curl --fail-with-body -sS -X POST \ -H "Authorization: Bearer $SIXSTAR_RULES_KEY" \ -H "Content-Type: application/yaml" \ --data-binary @sixstar-rules.yaml \ "https://team.sixstarlab.com/api/v1/rules?check=1"
A good check answers 200 with the rules it read and what would change. A good deploy answers:
{ "deployed": true, "count": 12, "diff": { "added": ["r-new"], "removed": [], "changed": ["r-limit"] } }A file with errors answers 400 with { "errors": [ ... ] }.
Managed rules. Once a deploy has saved your rules and your team holds a live rules:write key, the Rules section on the site shows "Managed in your repository" and the site's own Save is refused, so the repository stays the one source. Revoke the team's rules:write keys to edit the rules on the site again.
Ready-made pipelines. The Account page has a GitHub Actions file and a GitLab CI file you can copy. Each runs only curl: a pull or merge request checks sixstar-rules.yaml, and a push to the default branch deploys it. Keep the key as the pipeline secret SIXSTAR_RULES_KEY.
What we record about API calls
For each call: the key, the time, the method, the route and the status. We keep this for 90 days. We never record a header, a body or the key itself.
2. Webhooks
A webhook channel sends every alert your routing allows to an HTTPS address you run. Add it on the Account page under alert channels. Choose whether it gets every alert or only urgent ones.
Your address
- It must start with
https://and use port 443. - It must be a public host name. An IP address,
localhost, a private network name, or an address with a user name or password in it is refused. - Answer with any
2xxstatus within 10 seconds. Anything else counts as a failure. - We do not follow redirects. A
3xxanswer counts as a failure.
What we send
A POST with Content-Type: application/json, three signature headers, and this body:
{
"id": "msg_6f1c...",
"team": "Example team",
"watch": "Main contract",
"network": "Ethereum",
"chainId": 1,
"contract": "0x...",
"transaction": "0x...",
"block": 21000000,
"time": "2026-11-01T00:12:00.000Z",
"level": "urgent",
"title": "Owner changed",
"text": "...",
"test": false
}Any field that does not apply to an alert is null. network here is the network's name; chainId is its number. A test message from the Account page has "test": true.
Retries
If your server fails to answer with a 2xx, the message is kept and tried again after about 1 minute, then 2, 4 and 8 minutes, then every 15 minutes for as long as it keeps failing. Messages to one address always arrive in the order they were made, so one failing message holds back the ones behind it. Nothing is dropped unless you remove the channel.
If the oldest waiting message has been failing for an hour, your team's other channels get a notice. When the address works again and has received everything that waited, they get a second notice saying how many messages were delivered.
A retried message keeps the same webhook-id. Store the ids you have handled and ignore a repeat.
Verifying the signature
Every webhook carries three headers, in the Standard Webhooks format:
| Header | Value |
|---|---|
webhook-id | The message id, the same on every retry |
webhook-timestamp | When this attempt was sent, in whole seconds since 1970 (UTC) |
webhook-signature | v1, followed by the base64 HMAC-SHA256 signature |
When you add a webhook channel you get a signing secret that starts with whsec_. It is shown only once, so copy it then and keep it on your server only. If you lose it, remove the channel and add it again.
To check a message:
- Take the secret without
whsec_and base64-decode it. That is the key. - Join the id, the timestamp and the raw body with dots:
{webhook-id}.{webhook-timestamp}.{body}. Use the body exactly as received, before any JSON parsing. - Compute HMAC-SHA256 of that with the key, and base64-encode it.
- Compare it with the value after
v1,, using a constant-time comparison. - Refuse a timestamp more than 5 minutes from your own clock, so an old message cannot be replayed.
The header can hold several signatures separated by spaces; accept the message if any v1 one matches.
Node.js (18 or later)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySixStarWebhook(secret, headers, rawBody, toleranceSeconds = 300) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
return signatures.split(" ").some((entry) => {
const [version, value] = entry.split(",");
if (version !== "v1" || !value) return false;
const given = Buffer.from(value, "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}With Express, read the raw body (express.raw({ type: "application/json" })) and pass req.body.toString("utf8") as rawBody.
Python (3.9 or later)
import base64
import hashlib
import hmac
import time
def verify_sixstar_webhook(secret, headers, raw_body, tolerance_seconds=300):
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures:
return False
try:
age = abs(int(time.time()) - int(timestamp))
except ValueError:
return False
if age > tolerance_seconds:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, value = entry.partition(",")
if version == "v1" and hmac.compare_digest(value, expected):
return True
return Falseraw_body is the body as bytes: request.get_data() in Flask, await request.body() in FastAPI. Header names are case-insensitive, so read them the way your framework expects.
The official Standard Webhooks libraries for many languages verify these headers too.
Test it
On the Account page, press the channel's test button. Your server gets a signed message with "test": true. If it verifies, you are done.
3. Bot heartbeat
The Account page's "Bot heartbeat" gives each bot its own check-in URL, shown once with a ready curl line. Call it from your bot on every run. If a bot misses its interval, your channels get "A bot missed its check-in", and "A bot checks in again" when it is back. We keep only a hash of the URL's secret.
4. Help
Write to contact@sixstarlab.com. Never send us a key, a signing secret or a password.