Shypset API
Start a security scan with one request, poll until it is done and read the findings and a ready-made fix prompt as JSON. Built for your scripts, your CI and the AI agent that writes your code.
- Version
- v1
- Auth
- Bearer API key
- Format
- JSON
- Spec
- OpenAPI 3.1
Overview#
The API does what you do in the app: start a scan of a URL, read the report and get fix prompts. It works with a personal key of your account, so a request acts as you and uses your plan: the same scan quota, the same report and the same locked or open prompts as in the app.
- Works on every plan, including Free with its 3 scans a month. Scans started through the API and in the app share one quota.
- The key belongs on a server or in a CI secret. Never put it in a website, mobile app or anything that runs on a user’s device: the API sends no CORS headers, so browsers can’t call it anyway, and a leaked key lets anyone scan on your account.
- Every scan is the same non-intrusive external check as in the app. Deeper checks run only on a domain you verified in the app.
Base URL: https://dqyrveqnsggmckcabgmm.supabase.co/functions/v1/api-v1. All paths below start with /v1.
Quickstart#
Create a key, then start a scan, poll it and get a fix prompt. $SCAN_ID is the scan_id from the first answer.
export SHYPSET_API_KEY="shp_live_<your key>"
BASE=https://dqyrveqnsggmckcabgmm.supabase.co/functions/v1/api-v1
# 1. Start a scan
curl -sS -X POST "$BASE/v1/scans" \
-H "Authorization: Bearer $SHYPSET_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com"}'
# -> 202 {"scan_id": "...", "status": "queued", "poll_url": "..."}
# 2. Poll every 3-5 seconds until status is "completed" or "failed"
curl -sS "$BASE/v1/scans/$SCAN_ID" \
-H "Authorization: Bearer $SHYPSET_API_KEY"
# 3. One fix prompt for the open issues, written for your AI tool
curl -sS "$BASE/v1/scans/$SCAN_ID/fix-prompt?target=cursor" \
-H "Authorization: Bearer $SHYPSET_API_KEY"// Save as scan.mjs (top-level await) and run: node scan.mjs. Node 18+ (global fetch).
// Run it on a server or in CI, never in a browser.
const BASE = 'https://dqyrveqnsggmckcabgmm.supabase.co/functions/v1/api-v1';
const auth = { Authorization: `Bearer ${process.env.SHYPSET_API_KEY}` };
const sleep = s => new Promise(r => setTimeout(r, s * 1000));
async function api(path, init = {}) {
const res = await fetch(BASE + path, { ...init, headers: { ...auth, ...init.headers } });
const body = await res.json();
if (!res.ok) {
const err = new Error(`${res.status} ${body.error.code}: ${body.error.message}`);
err.code = body.error.code;
throw err;
}
return { body, res };
}
// 1. Start a scan
const { body: created } = await api('/v1/scans', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://example.com' }),
});
// 2. Poll until it is done
let scan;
for (;;) {
const { body, res } = await api(`/v1/scans/${created.scan_id}`);
scan = body;
if (scan.status === 'completed' || scan.status === 'failed') break;
await sleep(Math.max(3, Number(res.headers.get('Retry-After')) || 3));
}
console.log(scan.status, scan.report.risk_score, scan.report.counts);
// 3. Fix prompt for the open issues
if (scan.status === 'completed') {
const { body: fix } = await api(`/v1/scans/${scan.id}/fix-prompt?target=cursor`);
console.log(fix.prompt);
}# pip install requests. Run on a server or in CI, never in client code.
import os, time, requests
BASE = "https://dqyrveqnsggmckcabgmm.supabase.co/functions/v1/api-v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SHYPSET_API_KEY']}"
def api(method, path, **kwargs):
res = session.request(method, BASE + path, timeout=30, **kwargs)
body = res.json()
if not res.ok:
raise RuntimeError(f"{res.status_code} {body['error']['code']}: {body['error']['message']}")
return body, res
# 1. Start a scan
created, _ = api("POST", "/v1/scans", json={"url": "https://example.com"})
# 2. Poll until it is done
while True:
scan, res = api("GET", f"/v1/scans/{created['scan_id']}")
if scan["status"] in ("completed", "failed"):
break
time.sleep(max(3, int(res.headers.get("Retry-After", 3))))
print(scan["status"], scan["report"]["risk_score"], scan["report"]["counts"])
# 3. Fix prompt for the open issues
if scan["status"] == "completed":
fix, _ = api("GET", f"/v1/scans/{scan['id']}/fix-prompt", params={"target": "cursor"})
print(fix["prompt"])Authentication#
Create a key in the app: Settings → API keys. The full key is shown once, right after you create it; we keep only its hash. You can have up to 5 active keys, for example one per CI or agent, and revoke any of them there. A revoked key stops working right away. When you create a key, choose when it expires: in 7, 30 or 90 days, in 1 year, or never. An expired key stops working too and no longer counts toward the 5.
Send the key in the Authorization header of every request:
Authorization: Bearer shp_live_<your key>Keep it in an environment variable such as SHYPSET_API_KEY or in your CI’s secrets, not in code. A missing, malformed, unknown, revoked or expired key always gets the same answer: 401 invalid_token.
Requests and errors#
- Bodies are JSON (
Content-Type: application/json), at most 16 KB. - Every answer is JSON with
Cache-Control: no-store. - An error is always an
errorobject with a stablecodeto branch on and a human-readablemessage; some errors add fields such aslimitorupgrade_url.
{
"error": {
"code": "invalid_url",
"message": "Invalid URL format"
}
}Any endpoint can answer:
| Status | Code | Meaning |
|---|---|---|
| 401 | invalid_token | The key is missing, malformed, unknown, revoked or expired: one answer for all of them. Comes with WWW-Authenticate: Bearer. |
| 404 | not_found | Unknown path. |
| 405 | method_not_allowed | Wrong method for this path. The Allow header lists the right ones. OPTIONS is a 405 too: the API does not answer CORS preflights. |
| 429 | rate_limited | Too many requests a minute. Fields limit, retry_after_seconds and scope (token_read_minute, user_read_minute or user_scan_minute). Wait for Retry-After seconds. |
| 500 | internal_error | Something failed on our side. Safe to retry. |
| 503 | auth_unavailablerate_limit_unavailable | A dependency is down and the API fails closed instead of skipping a check. Retry-After: 5. |
Rate limits and quotas#
Two kinds of limits apply.
| Limit | Value | Answer |
|---|---|---|
| Reads (every GET) | 60 a minute per key and 120 a minute per account | 429 rate_limited |
| Starting scans | 10 a minute per account | 429 rate_limited |
| Scans per plan | Your plan’s quota, the same as in the app | 429 free_monthly, pro_monthly, … |
The per-minute limits use a sliding 60-second window. Every 429 has a Retry-After header in seconds: wait that long before the next try. A quota 429 also says when the quota resets (reset_at).
Endpoints
Start a scan#
POST/v1/scans
Starts a scan of a public URL and answers at once with its id; the scan runs in the background. It is the same scan as in the app: it counts against your plan’s monthly quota, and the deeper checks run only on a domain you verified in the app.
The answer carries poll_url (also sent as the Location header): poll it for the result.
Parameters
| Name | In | Description |
|---|---|---|
urlstring · required | body | The site to scan: a public http(s) URL on port 80 or 443. |
project_iduuid | body | Attach the scan to one of your projects. The project’s domain must match the URL. |
Idempotency-Keystring | header | 1–100 printable ASCII characters, no spaces. A retry with the same key returns the same scan instead of starting (and paying for) another one. See Idempotency. |
Request
curl -sS -X POST "$BASE/v1/scans" \
-H "Authorization: Bearer $SHYPSET_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: deploy-2026-11-08-1" \
-d '{"url": "https://example.com"}'const res = await fetch(`${BASE}/v1/scans`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SHYPSET_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'deploy-2026-11-08-1',
},
body: JSON.stringify({ url: 'https://example.com' }),
});
const { scan_id, poll_url } = await res.json(); // 202res = requests.post(
f"{BASE}/v1/scans",
headers={
"Authorization": f"Bearer {os.environ['SHYPSET_API_KEY']}",
"Idempotency-Key": "deploy-2026-11-08-1",
},
json={"url": "https://example.com"},
timeout=30,
)
scan_id = res.json()["scan_id"] # 202Response
{
"scan_id": "3f6c1a52-8d0e-4b7a-9c21-5e4f0a9b7d13",
"status": "queued",
"poll_url": "https://dqyrveqnsggmckcabgmm.supabase.co/functions/v1/api-v1/v1/scans/3f6c1a52-8d0e-4b7a-9c21-5e4f0a9b7d13"
}{
"error": {
"code": "free_monthly",
"message": "You have used all 3 free scans this month. Upgrade to Pro for up to 200 scans a month.",
"limit": 3,
"reset_at": "2026-12-01T00:00:00+00:00",
"upgrade_url": "https://www.shypset.com/pricing"
}
}status- A new scan is
queued(waiting for a scanner) orscanning. An idempotent replay (Idempotent-Replayed: true) returns the current status of the scan it started, so it can also becompletedorfailed. poll_url- Full URL of
GET /v1/scans/{id}for this scan.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json | The body is not a JSON object. |
| 400 | invalid_url | Missing or malformed URL, a private or local address, a port other than 80/443, or a domain that does not resolve. |
| 400 | invalid_project | project_id is not one of your projects, or its domain does not match the URL. |
| 400 | tos_required | Your account has not accepted the current Terms. Open the app once and accept them. |
| 400 | invalid_idempotency_key | Idempotency-Key is not 1–100 printable ASCII characters. |
| 403 | account_blocked | The account is blocked. |
| 403 | domain_opted_out | The owner of this domain asked us not to scan it. |
| 409 | idempotency_conflict | This Idempotency-Key was already used with a different body. |
| 409 | idempotency_in_progress | A request with this key is still being processed. Retry-After: 2. |
| 413 | body_too_large | The body is over 16 KB. |
| 429 | free_monthlypro_monthlypro_dailyhostname_hourlysite_hourly_global | Your plan’s scan quota, the same codes and limits as in the app. Fields limit and reset_at, on Free also upgrade_url. Retry-After until the reset. |
| 503 | scanner_paused | Scanning is paused for maintenance. Retry-After: 600. |
| 503 | profile_unavailableidempotency_unavailableplan_unavailablequota_unavailableproject_unavailableblocklist_unavailable | A dependency of the quota or safety checks is down; no scan was started and nothing was charged. Retry later. |
Get a scan: progress or the report#
GET/v1/scans/{id}
While the scan runs, the answer is its progress and a Retry-After: 3 header. When it is done, status is completed or failed and report holds the report exactly as the app shows it to your account.
A completed scan also carries previous_scan_id and diff: the comparison with the previous completed scan of the same hostname that you can see. Both are null when there is no previous scan.
Someone else’s scan answers 404, the same as a scan that does not exist.
Parameters
| Name | In | Description |
|---|---|---|
iduuid · required | path | The scan_id from POST /v1/scans. |
Request
curl -sS "$BASE/v1/scans/3f6c1a52-8d0e-4b7a-9c21-5e4f0a9b7d13" \
-H "Authorization: Bearer $SHYPSET_API_KEY"const res = await fetch(`${BASE}/v1/scans/${scanId}`, {
headers: { Authorization: `Bearer ${process.env.SHYPSET_API_KEY}` },
});
const scan = await res.json();
if (scan.status === 'completed') console.log(scan.report.risk_score, scan.diff);scan = requests.get(
f"{BASE}/v1/scans/{scan_id}",
headers={"Authorization": f"Bearer {os.environ['SHYPSET_API_KEY']}"},
timeout=30,
).json()
if scan["status"] == "completed":
print(scan["report"]["risk_score"], scan["diff"])Response
{
"id": "3f6c1a52-8d0e-4b7a-9c21-5e4f0a9b7d13",
"url": "https://example.com",
"status": "scanning",
"queue_position": null,
"progress": {
"modules_completed": 41,
"modules_total": 108,
"module_status": { "security_headers": "completed", "tls_grade": "completed" }
}
}{
"id": "3f6c1a52-8d0e-4b7a-9c21-5e4f0a9b7d13",
"url": "https://example.com",
"status": "completed",
"tier": "full",
"report": {
"scan_id": "3f6c1a52-8d0e-4b7a-9c21-5e4f0a9b7d13",
"url": "https://example.com",
"status": "completed",
"error_message": null,
"risk_score": 72,
"total_findings": 9,
"verified_checks_available": 0,
"created_at": "2026-11-08T10:15:04.123456+00:00",
"counts": { "critical": 1, "high": 1, "medium": 3, "low": 2, "info": 2 },
"findings": [
{
"id": "c1d2e3f4-0a1b-4c5d-8e9f-a0b1c2d3e4f5",
"severity": "critical",
"category": "secrets",
"title": "OpenAI API Key Found in JavaScript",
"description": "...",
"evidence": "sk-proj-****",
"remediation": "..."
}
],
"...": "more report sections"
},
"previous_scan_id": "9b2e7d40-1c5a-4f3e-8a6b-0d7c2e1f4a58",
"diff": {
"new": 1,
"fixed": 3,
"still_open": 8,
"new_by_severity": { "critical": 1, "high": 0, "medium": 0, "low": 0, "info": 0 }
}
}statusqueuedorscanningwhile it runs, thencompletedorfailed.queue_position- Place in the scan queue while
queued(1 = next), otherwisenull. tierfull(Pro, or a Launch Pass for this domain) orteaser(Free). See Free plan.report.risk_score- 0–100, higher is safer. The letter grade (A+ to F) is in
GET /v1/scansasgrade. report.counts- Findings per severity over the whole scan, on every plan.
report.findings- Sorted by severity, then id, so the order is stable between calls. Evidence is already masked: secrets are never returned in full.
report.verified_checks_available- How many deeper checks did not run because the domain is not verified. Verify it in the app (free on every plan).
diffnew,fixedandstill_openfindings sinceprevious_scan_id, andnew_by_severity. Counted over the findings your plan shows; on Free a finding that only dropped out of the top 5 is not counted as fixed.
Errors
| Status | Code | Meaning |
|---|---|---|
| 404 | not_found | No such scan, or it is not yours. |
| 500 | internal_error | The report failed to load. Retry. |
Get a fix prompt#
GET/v1/scans/{id}/fix-prompt
A prompt to paste into your AI tool (or to hand to your agent) that explains the issues and how to fix them, built by the same generator as the prompts in the report.
Without finding_id it covers the open issues of the scan, at most the 15 most severe (truncated says the rest was cut). With finding_id it covers that one finding.
Parameters
| Name | In | Description |
|---|---|---|
iduuid · required | path | The scan id. The scan must be completed. |
targetstring | query | The tool the prompt is written for: lovable, bolt, v0, replit, cursor, windsurf, claude (Claude / ChatGPT) or generic. By default, the platform detected on the site. |
finding_idstring | query | The id of one finding from report.findings. |
Request
curl -sS "$BASE/v1/scans/3f6c1a52-8d0e-4b7a-9c21-5e4f0a9b7d13/fix-prompt?target=claude" \
-H "Authorization: Bearer $SHYPSET_API_KEY" | jq -r .promptconst res = await fetch(`${BASE}/v1/scans/${scanId}/fix-prompt?target=claude`, {
headers: { Authorization: `Bearer ${process.env.SHYPSET_API_KEY}` },
});
const { prompt, included, truncated, locked_count } = await res.json();fix = requests.get(
f"{BASE}/v1/scans/{scan_id}/fix-prompt",
params={"target": "claude"},
headers={"Authorization": f"Bearer {os.environ['SHYPSET_API_KEY']}"},
timeout=30,
).json()
print(fix["prompt"])Response
{
"scan_id": "3f6c1a52-8d0e-4b7a-9c21-5e4f0a9b7d13",
"target": "claude",
"scope": "all",
"prompt": "...",
"included": 7,
"truncated": false,
"locked_count": 0
}{
"error": {
"code": "upgrade_required",
"message": "On the Free plan only the most severe issue has a fix prompt. Upgrade to Pro, or buy a Launch Pass for this domain, to unlock all of them.",
"upgrade_url": "https://www.shypset.com/pricing"
}
}scopeall(nofinding_id) orsingle.included- How many issues the prompt covers.
truncatedtruewhen more than 15 issues were open and the rest were cut.locked_count- Issues you can see in the report whose prompt your plan does not open (Free).
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_target | target is not one of the values above. |
| 400 | invalid_finding_id | finding_id is not a valid finding id. |
| 403 | upgrade_required | On Free only the most severe issue has a prompt. Field upgrade_url. |
| 404 | not_found | No such scan, or it is not yours. |
| 404 | finding_not_found | No issue with this finding_id in the scan. |
| 409 | scan_not_ready | The scan has not completed (field status). While it runs, Retry-After: 3; a failed scan never gets prompts. |
| 409 | nothing_to_fix | The scan found no issues to fix. |
List your scans#
GET/v1/scans
Your scan history, newest first: your own scans and the scans in your projects, the same list as the dashboard. Summary fields only; get a scan for its findings.
A page may hold fewer than limit items even when more exist. Keep requesting with cursor set to next_cursor until next_cursor is null. How far back it goes follows your plan’s history.
Parameters
| Name | In | Description |
|---|---|---|
urlstring | query | Only scans of this hostname: example.com or a full URL. |
project_iduuid | query | Only scans of one of your projects. |
limitinteger | query | 1–50, default 20. |
cursorstring | query | next_cursor of the previous page. Opaque. |
Request
curl -sS "$BASE/v1/scans?url=example.com&limit=20" \
-H "Authorization: Bearer $SHYPSET_API_KEY"
# next page
curl -sS "$BASE/v1/scans?url=example.com&limit=20&cursor=$NEXT_CURSOR" \
-H "Authorization: Bearer $SHYPSET_API_KEY"const items = [];
let cursor = null;
do {
const qs = new URLSearchParams({ url: 'example.com', limit: '50' });
if (cursor) qs.set('cursor', cursor);
const res = await fetch(`${BASE}/v1/scans?${qs}`, {
headers: { Authorization: `Bearer ${process.env.SHYPSET_API_KEY}` },
});
const page = await res.json();
items.push(...page.items);
cursor = page.next_cursor;
} while (cursor);import os, requests
BASE = "https://dqyrveqnsggmckcabgmm.supabase.co/functions/v1/api-v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SHYPSET_API_KEY']}"
items, cursor = [], None
while True:
params = {"url": "example.com", "limit": 50}
if cursor:
params["cursor"] = cursor
page = session.get(f"{BASE}/v1/scans", params=params, timeout=30).json()
items += page["items"]
cursor = page["next_cursor"]
if cursor is None:
breakResponse
{
"items": [
{
"id": "3f6c1a52-8d0e-4b7a-9c21-5e4f0a9b7d13",
"url": "https://example.com",
"status": "completed",
"risk_score": 72,
"grade": "B",
"critical_count": 1,
"high_count": 1,
"medium_count": 3,
"low_count": 2,
"created_at": "2026-11-08T10:15:04.123456+00:00",
"completed_at": "2026-11-08T10:16:12.654321+00:00"
}
],
"next_cursor": null
}risk_score, gradenulluntil the scan has completed.next_cursor- Pass it as
cursorfor the next page;nullon the last page.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_url | url is not a hostname or a URL. |
| 400 | invalid_project | project_id is not one of your projects. |
| 400 | invalid_limit | limit is not an integer from 1 to 50. |
| 400 | invalid_cursor | cursor is not a next_cursor value from this endpoint. |
Polling#
A scan takes a while, so POST /v1/scans answers right away and you poll poll_url. Ask every 3–5 seconds: a running scan answers with Retry-After: 3, and polling faster only spends your read limit. Stop when status is completed or failed, and give up after a few minutes in your script rather than polling forever.
For history, follow next_cursor until it is null. A short page does not mean the last page.
Idempotency#
A network error after POST /v1/scans leaves you not knowing whether the scan started. Send an Idempotency-Key header (1–100 printable ASCII characters, no spaces), and retry with the same key and exactly the same body:
- For 24 hours the same key returns the same scan with
Idempotent-Replayed: true, and no second scan is charged. - The same key with a different body is
409 idempotency_conflict. - While the first request is still being processed:
409 idempotency_in_progresswithRetry-After: 2. - A refused request (for example a quota 429) does not keep the key: retrying with it later can start the scan.
Keys are per account. Use something unique per intended scan, such as your deploy id.
Free plan#
On Free the API returns the same shortened report as the app (tier: "teaser"):
- the 5 most severe findings, with titles and short descriptions but without evidence and remediation;
- the full counts per severity, so you know how many issues there are;
- a fix prompt only for the most severe issue; the others answer
403 upgrade_required, andlocked_counttells how many are locked.
Pro, or a Launch Pass for the scanned domain, returns the full report (tier: "full") and every prompt. See plans.
MCP server#
Claude Code, Cursor and other MCP clients can use Shypset as tools: start a scan, read the report and get the fix prompt without leaving the agent. It is the same API with the same key, plan limits and answers, over MCP (Streamable HTTP, JSON responses) at https://dqyrveqnsggmckcabgmm.supabase.co/functions/v1/api-v1/mcp.
Claude Code: put your key in SHYPSET_API_KEY and run once. Your shell fills in the key, and Claude Code keeps it in its own config, not in your project.
claude mcp add --transport http shypset \
https://dqyrveqnsggmckcabgmm.supabase.co/functions/v1/api-v1/mcp \
--header "Authorization: Bearer $SHYPSET_API_KEY"Cursor: add this to ~/.cursor/mcp.json (or .cursor/mcp.json in a project you don’t commit the key to). Cursor reads the key from the SHYPSET_API_KEY environment variable.
{
"mcpServers": {
"shypset": {
"url": "https://dqyrveqnsggmckcabgmm.supabase.co/functions/v1/api-v1/mcp",
"headers": {
"Authorization": "Bearer ${env:SHYPSET_API_KEY}"
}
}
}
}Tools:
| Tool | Arguments | What it does |
|---|---|---|
scan_url | url, project_id?, idempotency_key? | Starts a scan, like POST /v1/scans, and returns scan_id. A scan takes about 1–2 minutes and counts against your plan’s quota. |
get_report | scan_id | Status while the scan runs; when it is done, the grade, counts per severity, findings and diff against the previous scan, as GET /v1/scans/{id} (Free: the same top 5 findings). |
get_fix_prompt | scan_id, finding_id?, target? | The fix prompt, as GET /v1/scans/{id}/fix-prompt (Free: only the most severe issue). |
list_scans | url?, project_id?, limit?, cursor? | Your recent scans, optionally of one site, as GET /v1/scans. |
A refusal comes back as a tool error with the same code as the REST API, for example free_monthly, rate_limited, invalid_url or upgrade_required; wrong argument types are invalid_arguments. A missing or wrong key is HTTP 401 before any tool runs. Tool calls count against the same per-minute limits: scan_url as starting a scan, the others as reads. Then ask your agent, for example:
Use the shypset MCP tools to scan https://example.com, wait for the report and fix
what it finds in this codebase, most severe first. Tell me which issues are locked on my plan.If Claude Code shows the server as “needs authentication”, the key is missing or invalid: the server takes only the API key, there is no OAuth sign-in.
For AI agents#
If your agent supports MCP, the MCP server is the shortest way. Any other agent can call the API directly. Put your key in the SHYPSET_API_KEY environment variable of the agent’s shell, then paste this prompt into Claude Code, Cursor or any agent that can run curl:
Scan my site with the Shypset API and fix what it finds.
- Docs: https://www.shypset.com/docs/api (OpenAPI: https://www.shypset.com/openapi/v1.yaml)
- Base URL: https://dqyrveqnsggmckcabgmm.supabase.co/functions/v1/api-v1
- My API key is in the SHYPSET_API_KEY environment variable. Send it as
"Authorization: Bearer $SHYPSET_API_KEY". Never print it, never write it to a file
and never put it in client-side code.
- My site: https://example.com
1. POST /v1/scans with {"url": "<my site>"} and an Idempotency-Key header.
2. Poll GET /v1/scans/{scan_id} every 5 seconds until status is "completed" or "failed".
3. GET /v1/scans/{scan_id}/fix-prompt?target=claude (use target=cursor in Cursor) and follow
that prompt: fix the issues in this codebase, most severe first. Do not invent issues
that are not in the report.
4. If you get 403 upgrade_required, or locked_count is above 0, fix what you can see and
tell me which issues are locked on my plan.
5. Tell me what you changed for each finding. After I deploy, scan again and report
diff.fixed and diff.new from GET /v1/scans/{scan_id}.Agents and tools can read the machine-readable OpenAPI spec and the short llms.txt.
From CI#
There is no ready-made GitHub Action yet, but you can call the API from CI like this. The step scans your site after a deploy and fails the job when the scan finds new critical issues compared with the previous scan. Add your key as the SHYPSET_API_KEY repository secret; the step needs curl and jq, both on GitHub-hosted runners.
# .github/workflows/shypset.yml (a step after your deploy job)
- name: Shypset scan
shell: bash
env:
SHYPSET_API_KEY: ${{ secrets.SHYPSET_API_KEY }}
SITE_URL: https://example.com
run: |
set -euo pipefail
BASE=https://dqyrveqnsggmckcabgmm.supabase.co/functions/v1/api-v1
AUTH="Authorization: Bearer $SHYPSET_API_KEY"
# A refusal (for example 429 free_monthly) fails the step with the API's error.
RESP=$(curl -sS --fail-with-body -X POST "$BASE/v1/scans" -H "$AUTH" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: gh-${{ github.run_id }}-${{ github.run_attempt }}" \
-d "{\"url\": \"$SITE_URL\"}") || { echo "::error::Scan not started: $RESP"; exit 1; }
SCAN_ID=$(echo "$RESP" | jq -r .scan_id)
[ -n "$SCAN_ID" ] && [ "$SCAN_ID" != null ] || { echo "::error::No scan_id: $RESP"; exit 1; }
STATUS=queued
SCAN='{}'
for i in $(seq 1 120); do
sleep 5
SCAN=$(curl -sS --fail-with-body "$BASE/v1/scans/$SCAN_ID" -H "$AUTH") || continue
STATUS=$(echo "$SCAN" | jq -r .status)
case "$STATUS" in completed|failed) break ;; esac
done
[ "$STATUS" = completed ] || { echo "::error::Scan did not complete (status: $STATUS)"; exit 1; }
echo "score=$(echo "$SCAN" | jq .report.risk_score)"
# diff is null on the first scan of a site: nothing to compare, nothing new.
NEW_CRITICAL=$(echo "$SCAN" | jq '.diff.new_by_severity.critical // 0')
if [ "$NEW_CRITICAL" -gt 0 ]; then
echo "::error::$NEW_CRITICAL new critical issue(s) since the previous scan"
exit 1
fiEach run starts a scan and counts against your plan’s quota, so run it on deploys to production rather than on every push.
Get your API key
Create a key in Settings and start your first scan from the terminal.