SixStar for projects

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:

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

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" }
StatuserrorWhat it means
400bad_requestA parameter or the body is not in the expected form
401keyThe key is missing, malformed, unknown, revoked or expired. All five get the same answer
403browserThe request carried an Origin header
403scopeThe key's scope does not allow this call
403lapsedYour team's access has ended
403inactiveA deploy needs an active period; your team's has ended
404not_foundNo such path, or no such alert for your team
405method_not_allowedThe path exists, but not for this method
429rateMore than 60 calls in the last minute. Wait for Retry-After
500serverSomething failed on our side. Try again later
503not_openThe service is not available right now

GET alerts

Scope: alerts:read. Your team's alerts, newest first.

ParameterFormDefault
sinceUTC time, YYYY-MM-DDTHH:MM:SSZ (milliseconds allowed)90 days ago
limit1 to 100100
cursorthe next value from the previous pagenone

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..."
}
FieldMeaning
idThe alert's id. Use it with GET alerts/<id>
kindWhich part of SixStar raised it: security, safe, limits, issuer, governance, btcxrpl or solana
alertThe alert type, for example SA-10
titleThe alert's title, for example "Owner changed"
levelurgent, notice, or null when the alert carries no level
networkThe EVM chain id, or null (Bitcoin, XRP Ledger and Solana alerts have none)
contractThe contract or address the alert is about, or null
transactionThe transaction it was found in, or null. For Bitcoin and XRP Ledger this is the payment's reference; for Solana, the signature
blockThe block it was found in, or null. For Solana, the slot
timeWhen the alert was raised, in UTC
textThe 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.

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

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:

HeaderValue
webhook-idThe message id, the same on every retry
webhook-timestampWhen this attempt was sent, in whole seconds since 1970 (UTC)
webhook-signaturev1, 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:

  1. Take the secret without whsec_ and base64-decode it. That is the key.
  2. 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.
  3. Compute HMAC-SHA256 of that with the key, and base64-encode it.
  4. Compare it with the value after v1,, using a constant-time comparison.
  5. 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 False

raw_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.