API reference

The SkanQRCode API

Classifies a URL or IP as malicious, suspicious, or not_malicious, and recommends an action, allow, warn, or block, in under 500 ms. Checking an IP also returns recently associated hosts. Every tenant can keep its own private block list; an allow list is on Pro and Business. This page documents the HTTP contract, source of truth is the OpenAPI document generated straight from the API's own schemas, browsable in full at docs.skanqrcode.com.

# The only API host, no separate staging server
https://api.skanqrcode.com

Authentication

One header, every request.

Every operation requires a bearer key except GET /health, GET /openapi.json, and POST /v1/billing/webhook (Stripe-signed, not key-authenticated). Never pass the key as a query parameter.

Authorization: Bearer sk_live_<id>_<secret>

sk_live_...

Issued to Pro/Business tenants. Every response comes back environment: production, licensedForProduction: true.

sk_test_...

Issued to free/sandbox tenants, same host as above, no separate server. Every response comes back environment: sandbox, licensedForProduction: false.

The prefix is determined by plan, not chosen by the caller: the key you are issued already tells the API which one you have.

Quickstart

Check a target in one call.

var req = URLRequest(url: URL(string: "https://api.skanqrcode.com/v1/check")!)
req.httpMethod = "POST"
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
req.httpBody = try JSONEncoder().encode(["target": url])
let (data, _) = try await URLSession.shared.data(for: req)
let result = try JSONDecoder().decode(CheckResponse.self, from: data)
if result.action == "block" { return } // never UIApplication.shared.open(url)

Full SDKs and runnable examples: github.com/Skanqrcode/sdk-examples

Endpoints

One check, usage reporting, and per-tenant lists.

POST/v1/checkcheck

Classify a URL or IP address

Classifies one URL or IP as malicious, suspicious, or not_malicious, and recommends an action, allow, warn, or block, via a fixed mapping from verdict, identical on every plan. Always returns a verdict. Counts one unit of quota per successful call, cached results included; unparseable input and blocked requests are not billed.

Request

{
  "target": "https://example.com/login"
}

Response

{
  "verdict": "malicious",
  "action": "block",
  "mode": "url",
  "reasons": ["ACTIVE_THREAT_FEED_MATCH", "KNOWN_MALWARE_MATCH"],
  "finalUrl": null,
  "cached": false,
  "executionTimeMs": 38,
  "environment": "production",
  "licensedForProduction": true,
  "requestId": "req_01j9z8qaenp0s2c4d6f8g0h1jk"
}

Also accepts an IP address as target (e.g. "203.0.113.42"), with an optional userId field for per-user result caching. An IP check adds "mode": "ip" and a related array: up to 20 hosts recently associated with that IP, each with its own verdict.

GET/v1/usageusage

Read this month's usage and remaining quota

Returns a monthly usage summary for the calling tenant only. Defaults to the current calendar month (UTC) when the month query param is omitted. Reads from the billing aggregates, so it can lag real-time usage by up to about an hour, meant for reporting, not a hot-path quota decision.

Request

GET /v1/usage?month=2026-09

Response

{
  "tenantId": "ten_01j9z2k3f5g6h7a1b2c3d4e5f6",
  "month": "2026-09",
  "monthlyQuota": 50000,
  "totalRequests": 42817,
  "availableRequests": 7183
}
GET/v1/usage/hourlyusage

Read the hourly usage breakdown and rate-limit utilization

An hour-by-hour usage breakdown for one calendar month (UTC), for understanding when and how close to the per-minute rate limit usage happened. hourlyCapacity is a reading aid, not a separate quota, enforcement always happens per minute.

Request

GET /v1/usage/hourly?month=2026-09

Response

{
  "tenantId": "ten_01j9z2k3f5g6h7a1b2c3d4e5f6",
  "month": "2026-09",
  "rpmLimit": 30,
  "hourlyCapacity": 1800,
  "hours": [
    {
      "hour": "2026-09-01T09:00:00Z",
      "mode": "url",
      "total": 1766,
      "capacityUsedPercent": 98.1,
      "blockedRpm": 34,
      "blockedQuota": 0,
      "malicious": 5,
      "suspicious": 40,
      "notMalicious": 1721,
      "cached": 1200
    }
  ]
}
GET/v1/allow-listlists

List allow list entries

Lists this tenant's allow list entries, newest first. Pro and Business plans only. Entries are private to the tenant.

Request

GET /v1/allow-list?limit=100

Response

{
  "entries": [
    {
      "id": "le_5b1e2d3c4f5a6b7c8d9e0f1a2b3c4d5e",
      "matchType": "domain",
      "value": "malicious-example.com",
      "createdAt": "2026-09-01T09:30:00Z"
    }
  ],
  "nextCursor": null
}
POST/v1/allow-listlists

Add an allow list entry

Adds a URL, host, domain, or IP to the allow list. An allowed target is returned as not_malicious, unless confirmed malicious, in which case it comes back suspicious (ALLOW_LIST_CONFLICT). Adding an entry that already exists returns the existing one. Requires an admin-scoped key. Pro and Business plans only. Takes effect within about a minute.

Request

{
  "matchType": "domain",
  "value": "malicious-example.com"
}

Response

{
  "id": "le_5b1e2d3c4f5a6b7c8d9e0f1a2b3c4d5e",
  "matchType": "domain",
  "value": "malicious-example.com",
  "createdAt": "2026-09-01T09:30:00Z"
}
DELETE/v1/allow-list/{entryId}lists

Remove an allow list entry

Removes one allow list entry by id. Requires an admin-scoped key. Pro and Business plans only.

Request

DELETE /v1/allow-list/le_5b1e2d3c4f5a6b7c8d9e0f1a2b3c4d5e

Response

204 No Content
GET/v1/block-listlists

List block list entries

Lists this tenant's block list entries, newest first. Available on every plan. Entries are private to the tenant.

Request

GET /v1/block-list?limit=100

Response

{
  "entries": [
    {
      "id": "le_5b1e2d3c4f5a6b7c8d9e0f1a2b3c4d5e",
      "matchType": "domain",
      "value": "malicious-example.com",
      "createdAt": "2026-09-01T09:30:00Z"
    }
  ],
  "nextCursor": null
}
POST/v1/block-listlists

Add a block list entry

Adds a URL, host, domain, or IP to the block list. A blocked target always comes back malicious (BLOCK_LIST_MATCH). Adding an entry that already exists returns the existing one. Requires an admin-scoped key. Available on every plan. Takes effect within about a minute.

Request

{
  "matchType": "domain",
  "value": "malicious-example.com"
}

Response

{
  "id": "le_5b1e2d3c4f5a6b7c8d9e0f1a2b3c4d5e",
  "matchType": "domain",
  "value": "malicious-example.com",
  "createdAt": "2026-09-01T09:30:00Z"
}
DELETE/v1/block-list/{entryId}lists

Remove a block list entry

Removes one block list entry by id. Requires an admin-scoped key. Available on every plan.

Request

DELETE /v1/block-list/le_5b1e2d3c4f5a6b7c8d9e0f1a2b3c4d5e

Response

204 No Content

Also present: GET /health (liveness, no key) and GET /openapi.json (this contract, as JSON, no key). POST /v1/billing/webhook exists for the billing provider only, not for API clients.

Errors

A closed set of codes, safe to branch on.

Every error response is { "error": { "code", "message", "requestId" } }. message is for a human; branch on code.

invalid_request400Malformed JSON, a bad field, an unparseable target, or a body over 8 KB.
unauthorized401The API key is missing, malformed, unknown, or has been revoked.
forbidden403The key lacks the scope required for this operation.
plan_feature_unavailable403The tenant's plan does not include this feature (e.g. the allow list on Free).
not_found404The resource does not exist for this tenant.
payment_required402The tenant is suspended for non-payment.
rate_limited429Per-minute rate limit exceeded. Retry after the interval in Retry-After.
quota_exceeded429Monthly included-request quota exhausted.
auth_unavailable503Auth is temporarily unavailable for a key not seen recently. Retry shortly.
internal500Unexpected failure. Never contains stack traces or internal hostnames.

Rate limits & headers

Every response tells you where you stand.

Requests per minute are enforced per plan; the monthly quota is separate and reported by GET /v1/usage. Both show up in these response headers, no separate call needed to check them.

RateLimit-Limit

The plan's requests-per-minute limit, or on a 429, whichever limit (per-minute or monthly) was exceeded.

RateLimit-Remaining

Requests left in the current window. 0 when limited.

RateLimit-Reset

Seconds until the limit resets.

Retry-After

On a 429 only: seconds to wait before retrying.

Server-Timing

Per-stage timings on /v1/check: auth, rate limit, quota, pipeline.

X-Request-Id

Identifier to quote when contacting support.

No API key yet?

Free is 1,000 API calls a month, sandboxed. No card.

Request early access