Semhack API
/api/v1.Base URL
https://semhack.com/api/v1
OpenAPI 3.1
https://semhack.com/api/v1/openapi.json
Keys
Settings → API & webhooks
The OpenAPI document is generated from the same definitions the API validates requests with, so it is always current. Import it into Postman, Insomnia or a client generator.
Authentication
smh_live_ and are shown once: Semhack stores only a SHA-256 hash, so a lost key can't be recovered, only revoked and replaced.curl https://semhack.com/api/v1/me \
-H "Authorization: Bearer $SEMHACK_API_KEY"| Scope | Allows |
|---|---|
read | Every GET endpoint. |
write | Also starting audits and site audits, and changing an action's status. Write keys can read too. |
A key sees the whole workspace it was created in and nothing else. Keep it on a server, never in a browser or app: the API doesn't send CORS headers, so browsers can't call it from another site. Revoking a key works immediately.
Errors & pagination
Errors use HTTP status codes and one JSON shape. code is stable and safe to branch on; message is written for people. Quote request_id (also in the X-Request-Id header) when you contact us.
{
"error": {
"code": "invalid_request",
"message": "Invalid query.",
"details": [{ "path": "query.limit", "message": "Too big: expected number to be <=100" }],
"request_id": "req_Qk1x9mZ2aB7c"
}
}| Status | When |
|---|---|
400 invalid_request | Malformed query, path or body; details lists each problem. |
401 unauthorized | Missing, malformed, unknown or revoked key. |
402 plan_limit | Your plan's audits per month (or websites) are used up; upgrade_url links to Billing. |
403 forbidden | The key lacks the scope the endpoint needs. |
404 not_found | No such resource in your workspace (other workspaces' ids look exactly like missing ones). |
413 payload_too_large | Bodies over 16 KB. |
422 unprocessable | Valid JSON that can't be done, e.g. a URL that can't be audited. |
429 rate_limited | Too many requests; wait for Retry-After seconds. |
Lists take limit (1–100, default 20) and offset and return { data: [...], pagination: { limit, offset, total, has_more, next_offset } }. Request the next page with offset=next_offset until it is null. Timestamps are ISO 8601 in UTC; ids are UUIDs.
Rate limits
| Limit | Applies to |
|---|---|
| 120 requests per minute | Each API key, all endpoints together. |
| 20 failed requests per minute | Each IP address sending an invalid or revoked key; further requests get 429 for a minute. |
| 20 audits per 10 minutes | Each workspace (app and API together), plus your plan's audits per month. |
| 10 site audits per hour | Each workspace. Pages per site audit follow your plan. |
Every authenticated response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds). A 429 adds Retry-After.
Account
/api/v1/mescope: readThe workspace and key you are calling with
Handy for checking that a key works.
curl "https://semhack.com/api/v1/me" \
-H "Authorization: Bearer $SEMHACK_API_KEY"Returns 200 with a Me (schema in the OpenAPI spec).
Audits
/api/v1/auditsscope: readList audits
Newest first.
- limitquery · integer 1–100
- Items per page, 1–100. Default
20. - offsetquery · integer 0–100000
- How many items to skip. Default
0. - statusquery · "queued" | "running" | "done" | "failed"
- website_idquery · string
- Only audits of this website.
- qquery · string
- Substring of the URL, case-insensitive.
curl "https://semhack.com/api/v1/audits?status=done&limit=10" \
-H "Authorization: Bearer $SEMHACK_API_KEY"Returns 200 with a AuditList (schema in the OpenAPI spec).
/api/v1/auditsscope: writeAudit a page
Queues an audit of one URL and returns it with status queued. Poll GET /audits/{id} or subscribe to the audit.completed webhook. Counts toward your plan's audits per month (402 plan_limit when used up); a workspace can also start at most 20 audits per 10 minutes.
- urlrequiredbody · string
- The page to audit, e.g.
example.com/pricing.
curl -X POST "https://semhack.com/api/v1/audits" \
-H "Authorization: Bearer $SEMHACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "example.com/pricing"}'Returns 202 with a Audit (schema in the OpenAPI spec).
/api/v1/audits/{id}scope: readGet an audit with its checks
- idrequiredpath · string
- Resource id.
curl "https://semhack.com/api/v1/audits/3f0c1d2e-8a4b-4c6d-9e7f-1a2b3c4d5e6f" \
-H "Authorization: Bearer $SEMHACK_API_KEY"Returns 200 with a AuditDetail (schema in the OpenAPI spec).
Site audits
/api/v1/site-auditsscope: readList site audits
Multi-page crawls, newest first.
- limitquery · integer 1–100
- Items per page, 1–100. Default
20. - offsetquery · integer 0–100000
- How many items to skip. Default
0. - website_idquery · string
- Only site audits of this website.
curl "https://semhack.com/api/v1/site-audits?limit=5" \
-H "Authorization: Bearer $SEMHACK_API_KEY"Returns 200 with a SiteAuditList (schema in the OpenAPI spec).
/api/v1/site-auditsscope: writeStart a site audit
Crawls the website the URL belongs to, starting at its homepage, up to your plan's pages per site audit. If one is already queued or running for that website, returns it instead of starting another. Subscribe to site_audit.completed, or poll GET /site-audits/{id}. At most 10 new site audits per hour per workspace.
- urlrequiredbody · string
- Any URL of the website, e.g.
example.com. - page_limitbody · integer 1–5000
- Crawl fewer pages than your plan allows.
curl -X POST "https://semhack.com/api/v1/site-audits" \
-H "Authorization: Bearer $SEMHACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "example.com"}'Returns 202 with a SiteAudit (schema in the OpenAPI spec).
/api/v1/site-audits/{id}scope: readGet a site audit with its issues
- idrequiredpath · string
- Resource id.
curl "https://semhack.com/api/v1/site-audits/3f0c1d2e-8a4b-4c6d-9e7f-1a2b3c4d5e6f" \
-H "Authorization: Bearer $SEMHACK_API_KEY"Returns 200 with a SiteAuditDetail (schema in the OpenAPI spec).
Websites
/api/v1/websitesscope: readList audited websites
Most recently audited first.
- limitquery · integer 1–100
- Items per page, 1–100. Default
20. - offsetquery · integer 0–100000
- How many items to skip. Default
0.
curl "https://semhack.com/api/v1/websites" \
-H "Authorization: Bearer $SEMHACK_API_KEY"Returns 200 with a WebsiteList (schema in the OpenAPI spec).
Clients
/api/v1/clientsscope: readList clients
Client websites and their Search Console status, oldest first.
- limitquery · integer 1–100
- Items per page, 1–100. Default
20. - offsetquery · integer 0–100000
- How many items to skip. Default
0.
curl "https://semhack.com/api/v1/clients" \
-H "Authorization: Bearer $SEMHACK_API_KEY"Returns 200 with a ClientList (schema in the OpenAPI spec).
/api/v1/clients/{id}/performancescope: readSearch Console performance summary
Clicks, impressions, CTR and position for the last 28 days with data and the 28 days before. 404 until data has been imported.
- idrequiredpath · string
- Resource id.
curl "https://semhack.com/api/v1/clients/3f0c1d2e-8a4b-4c6d-9e7f-1a2b3c4d5e6f/performance" \
-H "Authorization: Bearer $SEMHACK_API_KEY"Returns 200 with a Performance (schema in the OpenAPI spec).
Findings
/api/v1/findingsscope: readThis week's actions
view=week (default) is the weekly queue the app shows: each client's top five actions, ranked by score. view=all lists every finding with the given statuses.
- limitquery · integer 1–100
- Items per page, 1–100. Default
20. - offsetquery · integer 0–100000
- How many items to skip. Default
0. - viewquery · "week" | "all"
- Default
week. - client_idquery · string
- Only this client's findings.
- statusquery · string
- Comma-separated. Default
open,doing. Only forview=all. - typequery · string
- Comma-separated finding types.
curl "https://semhack.com/api/v1/findings?view=all&status=open,doing&type=low-ctr" \
-H "Authorization: Bearer $SEMHACK_API_KEY"Returns 200 with a FindingList (schema in the OpenAPI spec).
/api/v1/findings/{id}scope: writeChange a finding's status
Mark an action done, dismiss it, or reopen it. Sends finding.status_changed to your webhooks when the status changes.
- idrequiredpath · string
- Resource id.
- statusrequiredbody · "open" | "done" | "dismissed"
doneordismissedtakes it out of the queue;openputs it back (and clears its assignee).
curl -X PATCH "https://semhack.com/api/v1/findings/3f0c1d2e-8a4b-4c6d-9e7f-1a2b3c4d5e6f" \
-H "Authorization: Bearer $SEMHACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "done"}'Returns 200 with a Finding (schema in the OpenAPI spec).
Webhooks
| Event | Sent when |
|---|---|
audit.completed | A page audit finished. data.audit has the score and counts. |
audit.failed | A page audit couldn't run. data.audit.error says why. |
site_audit.completed | A site audit finished, or was stopped and its crawled pages analyzed (status is cancelled then). |
site_audit.failed | A site audit couldn't run. data.site_audit.error says why. |
finding.created | New actions for a client: one event per recompute or audit with up to 50 findings, highest score first, and total. |
finding.status_changed | An action was marked done, dismissed, reopened or assigned, in the app or through the API. Includes previous_status. |
monitoring.alert | Monitoring raised alerts: site down or back up, SSL expiring, score drop, a check that started failing, robots or noindex, traffic drop. Only rules turned on in Notifications send events. |
webhook.test | Sent by “Send test” in Settings, to check your endpoint. |
{
"id": "evt_8c1f6b0f4e2a4d3c9b7a5e1d2c3b4a59",
"type": "audit.completed",
"created_at": "2026-11-05T09:41:00.000Z",
"organization_id": "org_…",
"data": {
"audit": {
"id": "3f0c1d2e-8a4b-4c6d-9e7f-1a2b3c4d5e6f",
"url": "https://example.com/pricing",
"host": "example.com",
"status": "done",
"score": 82,
"counts": { "pass": 31, "warn": 6, "fail": 3, "info": 4, "total": 44 },
"…": "see the Audit schema in the OpenAPI spec"
}
}
}| Header | Value |
|---|---|
Semhack-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>, see below |
Semhack-Event | The event type. |
Semhack-Delivery | This delivery's id; differs on every resend. |
Answer with any 2xx within 10 seconds. Anything else, a timeout or a redirect counts as a failure and is retried 5 times, after 1 min, 5 min, 30 min, 2 h, 8 h. The delivery log in Settings shows every attempt with its status code, and you can resend any delivery. An endpoint whose deliveries fail 20 times in a row is paused until you resume it. Events can arrive more than once and out of order: de-duplicate on id and use created_at to order them.
Verifying signatures
whsec_…). Semhack computes an HMAC-SHA256 of <t>.<raw body> with it and sends Semhack-Signature: t=<t>,v1=<hex>. Recompute it over the raw request body, compare in constant time, and reject timestamps more than 5 minutes away so old deliveries can't be replayed. After you rotate a secret the old one stops working at once.import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.SEMHACK_WEBHOOK_SECRET; // whsec_…, from Settings → API
const TOLERANCE_SECONDS = 300;
function verifySemhackSignature(secret, header, rawBody, now = Date.now()) {
if (!header) return false;
const parts = header.split(",").map((p) => p.trim().split("="));
const t = Number(parts.find(([k]) => k === "t")?.[1]);
const signatures = parts.filter(([k]) => k === "v1").map(([, v]) => v ?? "");
if (!Number.isInteger(t) || signatures.length === 0) return false;
// Reject old (replayed) deliveries.
if (Math.abs(Math.floor(now / 1000) - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest();
return signatures.some((s) => {
const given = Buffer.from(s, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
const app = express();
// Verify against the raw body: re-serialized JSON wouldn't match the signature.
app.post("/semhack-webhook", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verifySemhackSignature(SECRET, req.get("Semhack-Signature"), rawBody)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(rawBody);
// Deliveries can repeat (retries, "Resend"): de-duplicate on event.id.
switch (event.type) {
case "audit.completed":
console.log(`${event.data.audit.url} scored ${event.data.audit.score}`);
break;
case "finding.created":
console.log(`${event.data.total} new actions for ${event.data.client.name}`);
break;
}
res.sendStatus(204); // Answer within 10 seconds; do slow work afterwards.
});
app.listen(3000);
Slack
https://hooks.slack.com/services/…). The same events arrive as readable messages with links into Semhack. Slack messages aren't signed; the webhook URL itself is the secret, so Semhack never shows it in full after you save it.Questions or something missing? Email hello@semhack.com.