Revoke in your own system

Send leaked keys from any app to your own endpoint, signed, so your system rotates them. ShellOrbit never sends the key.

Leak Alarm Available on Pro and Business Updated 25 October 2026

These pages describe ShellOrbit as it runs today. Breaking changes to the HTTP API are announced before they ship, and existing request and response shapes stay supported.

For a key no connected provider can act on, ShellOrbit can call your own HTTPS endpoint after the ten minutes in which you can cancel. Your system finds the key and rotates it. The request carries a masked value and a match code, never the key.

Connect

Settings, Connected accounts, Any other app. Owners and admins only.

  • URL: https, a public address, no redirects.
  • Scope: keys nothing else handles (default), or every leaked key.
  • When a key is found: ask me first, or send automatically after 10 minutes.

Before saving, ShellOrbit sends a leak.revoke_test request with a fake finding. Your endpoint must answer 2xx within 10 seconds, or nothing is saved and the status is shown.

The signing secret (whsec_...) is shown once. Rotate secret shows a new one once; for 24 hours requests carry a v1 signature for each secret, so verify any match.

The request

POST to your URL, Content-Type: application/json, with Standard Webhooks headers:

  • webhook-id: rvk_<finding id>_<n>, the same on every retry of one request. Deduplicate on it.
  • webhook-timestamp: Unix seconds. Refuse anything more than five minutes off.
  • webhook-signature: v1,<base64 HMAC-SHA256 of "id.timestamp.body">, keyed with the secret.
json
{
  "type": "leak.revoke_requested",
  "created_at": "2026-10-22T10:15:00Z",
  "data": {
    "finding_id": "fnd_01...",
    "rule": "stripe_live",
    "provider": "Stripe",
    "title": "Live secret key",
    "severity": "critical",
    "masked_value": "sk_live_…a1b2",
    "last4": "a1b2",
    "match": { "hmac_sha256": "base64...", "secret_version": 1, "sdk_key_label": "payments-api" },
    "location": { "kind": "site", "url": "https://shop.example.com/assets/app.js" },
    "first_seen_at": "2026-10-22T10:04:51Z",
    "dashboard_url": "https://app.shellorbit.com/leak-alarm#findings"
  }
}

sdk_key_label is present when the key matched a fingerprint your SDK registered.

Find the key: the match code

match.hmac_sha256 is HMAC-SHA256 of the full leaked key, made while ShellOrbit still held it in memory:

  • Key: the bytes of your signing secret, base64 decoded after whsec_, the same key the signature uses.
  • Message: the key exactly as written, UTF-8.
  • Output: standard base64.

Compute the same code over each of your own keys and compare. Keep earlier secrets for this: a code is made with the secret of its secret_version. Keys found before the webhook was connected have no code; fall back to masked_value and last4.

Verify and match

Node, with the standardwebhooks package:

js
import crypto from "node:crypto";
import express from "express";
import { Webhook } from "standardwebhooks";

const secret = process.env.SHELLORBIT_REVOKE_SECRET; // whsec_...
const wh = new Webhook(secret);
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const matchCode = (value) => crypto.createHmac("sha256", key).update(value, "utf8").digest("base64");

const app = express();
app.post("/shellorbit/revoke", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = wh.verify(req.body, req.headers);
  } catch {
    return res.status(400).end();
  }
  if (event.type === "leak.revoke_test") return res.status(200).end();

  const leaked = (await listMyKeys()).find((k) => matchCode(k.value) === event.data.match?.hmac_sha256);
  if (!leaked) return res.status(200).end(); // received; nothing of ours matched
  await rotate(leaked);
  res.json({ status: "revoked" });
});

Python:

python
import base64, hashlib, hmac, time

def _key(secret: str) -> bytes:
    return base64.b64decode(secret.removeprefix("whsec_"))

def verify(secret: str, headers, body: bytes) -> bool:
    msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    signed = f"{msg_id}.{ts}.".encode() + body
    expected = base64.b64encode(hmac.new(_key(secret), signed, hashlib.sha256).digest()).decode()
    return any(hmac.compare_digest(expected, sig[3:])
               for sig in headers["webhook-signature"].split() if sig.startswith("v1,"))

def match_code(secret: str, value: str) -> str:
    return base64.b64encode(hmac.new(_key(secret), value.encode(), hashlib.sha256).digest()).decode()

Go:

go
func verify(secret string, h http.Header, body []byte) bool {
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return false
	}
	id, ts := h.Get("webhook-id"), h.Get("webhook-timestamp")
	sec, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || time.Since(time.Unix(sec, 0)).Abs() > 5*time.Minute {
		return false
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "."))
	mac.Write(body)
	want := mac.Sum(nil)
	for _, s := range strings.Fields(h.Get("webhook-signature")) {
		if v, ok := strings.CutPrefix(s, "v1,"); ok {
			if got, err := base64.StdEncoding.DecodeString(v); err == nil && hmac.Equal(got, want) {
				return true
			}
		}
	}
	return false
}

func matchCode(key []byte, value string) string {
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(value))
	return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}

Answer

Revoke in your own system: Your answer, What happens
Your answerWhat happens
2xxHanded over. The finding shows Sent to your system.
2xx with {"status":"revoked"}The finding is marked revoked.
408, 429, 5xx, timeout or no connectionRetried, and the finding shows Retrying.
Any other 4xxFailed. You are alerted with the status.

Requests time out after 10 seconds, and at most 4 KB of your answer is read. Retries come 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours after the first try, then stop with an alert.

Cancel within the ten minutes and nothing is sent. Every request, retry and result is in the audit log.