Skip to content

Cykanner Breach Monitoring API

Programmatic access to your organization's monitored emails, scans, breaches, and alert configuration. All endpoints live under /api/v1 and speak JSON exclusively — every request and response body is application/json (multipart is only used for the CSV import endpoint below).

Authentication

Create a key from Dashboard → Settings → API Keys. The full key is shown exactly once, at creation — store it in a secrets manager, not in source control. Send it as a bearer token on every request:

curl https://cykanner.io/api/v1/emails \
  -H "Authorization: Bearer cyk_live_a1b2c3d4e5f6a7b8_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"

A key authenticates as its organization, not as a specific user — it has the same access any session-authenticated dashboard user of that organization has. There are no scopes in v1: every key can read and write everything below. If a key is compromised, revoke it immediately from the same dashboard page — revocation takes effect on the very next request (no caching).

A missing or unrecognized Authorization header on a request that also carries a valid session cookie (e.g. a browser calling this from a logged-in tab) falls back to the cookie. But once an Authorization header is present at all, it is authoritative — an invalid, malformed, or revoked key fails the request outright and never silently falls back to a cookie.

Rate limits & errors

Scope Limit Applies to
Per source IP 100 req / min Every request to /api/v1/*
Per API key 100 req / min Bearer-authenticated requests, additive to the IP limit
Per organization 10 / hour POST /api/v1/scans
Per organization 20 / hour Each CSV import endpoint (preview, commit)
Per organization 60 / hour PATCH /api/v1/emails/:id
Per organization 60 / hour POST /api/v1/emails and DELETE /api/v1/emails share one bucket
Per organization 60 / hour POST /api/v1/alert-configs and PATCH /api/v1/alert-configs/:id share one bucket, counted only for Slack/webhook writes that resolve a hostname (email-only, notification-flag-only, and IP-literal webhook URLs are unaffected)

Every rate limit returns a 429 with a Retry-After header (seconds until the window resets):

HTTP/1.1 429 Too Many Requests
Retry-After: 42
Content-Type: application/json

{"error":"Rate limit exceeded"}

An unauthenticated or rejected request returns 401:

HTTP/1.1 401 Unauthorized
Content-Type: application/json

{"error":"Authentication required"}

An organization whose trial has expired or subscription has lapsed gets 402 instead of the normal response, with a machine-readable reason (trial_expired, subscription_inactive, price_not_recognized, or collection_paused — the subscription is active but Stripe billing is paused; contact support to resume):

HTTP/1.1 402 Payment Required
Content-Type: application/json

{"error":"Payment required","reason":"trial_expired"}

Every other error (validation, not found, conflict, server error) responds with the same shape — { "error": "<message>" }, occasionally with an extra machine-readable code field noted per-endpoint below.

Pagination

List endpoints accept limit (1–100, default 50) and offset (default 0) query parameters, and echo the filtered total as total in the response body.

Data retention

Scan history and the activity log are available for the last 365 days. Responses include retention.visibleSince; older records are not returned. Older records are currently kept, not deleted; any change to how long records are kept will be announced on this page.

Endpoints

POST /api/v1/scans

Trigger a new breach scan for the organization.

No request body.

201 Created
{
  "success": true,
  "scanJobId": 4821
}

Errors: 429 (org scan limit, 10/hour), 409 a scan is already in progress, 400 no active monitored emails, 500 failed to queue.

GET /api/v1/scans

List the organization's scans from the last 365 days, newest first.

Query: limit, offset, type (manual/ scheduled/catalog_triggered) and status (pending/ running/completed/ failed) — all optional.

200 OK
{
  "scans": [
    {
      "id": 4821,
      "type": "manual",
      "status": "completed",
      "totalEmails": 12,
      "scannedEmails": 12,
      "newBreachesFound": 1,
      "findingsItemized": true,
      "errorMessage": null,
      "startedAt": "2026-08-20T09:00:01.000Z",
      "completedAt": "2026-08-20T09:00:14.000Z",
      "createdAt": "2026-08-20T09:00:00.000Z"
    }
  ],
  "total": 1,
  "retention": { "days": 365, "visibleSince": "2025-10-04T09:00:00.000Z" }
}

newBreachesFound is the number of findings this scan newly detected. findingsItemized records whether those findings were linked to the scan when its results were saved; when it is true, they can be listed with GET /api/v1/breaches?scanId={id}. That link is recorded once, when the scan's results are saved, and is not updated afterwards: if you later remove a monitored email, its findings are deleted, so the scanId list can be shorter than newBreachesFound while findingsItemized stays true. When findingsItemized is false, newBreachesFound is the count the scan reported and its findings can't be listed per scan (scans recorded before itemization, or that didn't finish checking any email) — a 0 there does not confirm the scan found nothing. Re-checks of findings you already have are not itemized per scan; see each finding's lastSeenAt.

GET /api/v1/activity

List the organization's account activity from the last 365 days, newest first.

Query: limit, offset and category (monitoring/ findings/alerts/ teams/api_keys) — all optional.

200 OK
{
  "activity": [
    {
      "id": 9132,
      "createdAt": "2026-08-20T09:05:00.000Z",
      "category": "findings",
      "action": "breach.acknowledge",
      "summary": "Marked \"Example Breach\" for jane@example.com as handled",
      "actor": { "type": "user", "label": "owner@example.com" }
    }
  ],
  "total": 1,
  "retention": { "days": 365, "visibleSince": "2025-10-04T09:00:00.000Z" }
}

actor.type is one of user, api_key, system, removed or support. support (labeled "Cykanner support") marks an entry whose actor is not a member of your organization; it is a safeguard and does not mean support activity is listed. Actions taken by Cykanner support are not included in this log. IP addresses and internal metadata are never returned. An unknown category returns 400.

GET /api/v1/emails

List monitored emails, with optional filters.

Query: limit, offset, domainId, teamId, isActive (true/ false) — all optional.

curl "https://cykanner.io/api/v1/emails?limit=50&offset=0&isActive=true" \
  -H "Authorization: Bearer $CYKANNER_API_KEY"
200 OK
{
  "emails": [
    {
      "id": 91,
      "email": "jane@example.com",
      "ownerName": "Jane Doe",
      "isActive": true,
      "lastScannedAt": "2026-08-20T09:00:14.000Z",
      "createdAt": "2026-07-01T12:00:00.000Z",
      "domain": { "id": 3, "name": "example.com" },
      "team": null,
      "breachCount": 2,
      "unacknowledgedBreachCount": 1
    }
  ],
  "total": 1
}
POST /api/v1/emails

Add a new monitored email.

Request body
{
  "email": "jane@example.com",
  "domainId": 3,
  "teamId": 7,
  "ownerName": "Jane Doe"
}

domainId must already be a verified domain on the org; teamId and ownerName are optional.

201 Created
{
  "success": true,
  "id": 91
}

Errors: 404 domain/team not found, 409 already monitored, 400 invalid input, 429 too many adds/removes (org limit, 60/hour -- shared with DELETE /api/v1/emails below).

DELETE /api/v1/emails

Remove a monitored email.

Query: id (required).

curl -X DELETE "https://cykanner.io/api/v1/emails?id=91" \
  -H "Authorization: Bearer $CYKANNER_API_KEY"

200 { "success": true },404 if the id doesn't belong to this organization, or 429 too many adds/removes (org limit, 60/hour -- shared with POST /api/v1/emails above).

PATCH /api/v1/emails/:id

Enable or disable monitoring for one email, without deleting its history.

Request body
{
  "isActive": false
}

Billing note: your subscription is billed per active monitored email, not per email ever added. Disabling an email here stops it counting toward your bill; a subscribed organization is still billed for a minimum of one seat even with zero active emails.

Errors: 404 not found, 400 the email's domain is not (or no longer) verified, 429 too many toggles (org limit, 60/hour).

POST /api/v1/emails/import/preview

Upload a .csv/.tsv/.txt file (max 1 MiB, multipart/form-data) and get back a plan: which addresses will import, which are already monitored/duplicates/invalid, and the resulting billing quote.

curl -X POST https://cykanner.io/api/v1/emails/import/preview \
  -H "Authorization: Bearer $CYKANNER_API_KEY" \
  -F "file=@team-emails.csv"
200 OK
{
  "emails": [
    { "email": "new.hire@example.com", "domainId": 3, "status": "will_import" },
    { "email": "jane@example.com", "domainId": 3, "status": "already_monitored" }
  ],
  "quote": {
    "interval": "monthly",
    "isEstimate": false,
    "newSeatsCount": 1,
    "newActiveCount": 1,
    "estimatedDeltaCents": 500
  }
}

Errors: 400 missing/unsupported file, 413 file or extracted-address-count too large (max 500 addresses), 429 org import-preview limit (20/hour).

POST /api/v1/emails/import/commit

Commit a plan returned by the preview endpoint — echo its emails[] and quote back exactly.

Request body
{
  "emails": [
    { "email": "new.hire@example.com", "domainId": 3, "status": "will_import" }
  ],
  "quote": {
    "interval": "monthly",
    "isEstimate": false,
    "newSeatsCount": 1,
    "newActiveCount": 1,
    "estimatedDeltaCents": 500
  }
}
200 OK
{
  "success": true,
  "importedCount": 1,
  "disabledCount": 0,
  "skippedCount": 0
}

The quote is re-derived server-side and compared against what you send; if billing state changed since you previewed (e.g. someone else added seats), you get a 409 instead of a silent mismatch:

HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "error": "The cost estimate has changed since you previewed this import -- please re-preview",
  "code": "quote_mismatch"
}

Re-run the preview step and commit the fresh quote. Also: 400 {"code":"nothing_to_import"} if every row was already monitored/duplicate/invalid, 429 org import-commit limit (20/hour).

GET /api/v1/alert-configs

List the organization's alert channel configurations.

200 OK
{
  "configs": [
    {
      "id": 5,
      "organizationId": 1,
      "channel": "webhook",
      "isEnabled": true,
      "config": { "urlPreview": "https://example.com/...", "hasSecret": true },
      "notifyOnNewBreach": true,
      "notifyOnScanComplete": false,
      "notifyOnScanError": true
    }
  ]
}

Secrets are write-only and never read back — a webhook's secret and a Slack config's webhookUrl are replaced with hasSecret/hasWebhookUrl booleans in every response. A generic webhook's url is itself never returned — it's shortened to urlPreview (origin only, plus a literal /... if it had a path), enough to tell configs apart without handing back a capability URL. urlPreview is a display value only: PATCHing it back under the url key does nothing, since url is absent from this response — any webhook edit must resend the real destination.

POST /api/v1/alert-configs

Create an alert channel (email, slack, or webhook — up to 20 per organization).

Request body (webhook channel)
{
  "channel": "webhook",
  "isEnabled": true,
  "config": {
    "url": "https://example.com/hooks/cykanner",
    "secret": "whsec_your_own_random_string"
  },
  "notifyOnNewBreach": true,
  "notifyOnScanComplete": false,
  "notifyOnScanError": true
}

See Webhooks for the webhook channel's delivery format and how secret is used to sign deliveries. channel is one of "email" ( config.recipients: string[]), "slack" (config.webhookUrl: string ), or "webhook" ( config.url: string, config.secret?: string).

201 Created
{ "id": 5 }

400 on an invalid channel shape or an outbound URL that fails the SSRF guard (must be public HTTPS, no embedded credentials).

PATCH /api/v1/alert-configs/:id

Partially update an alert channel.

Request body
{
  "isEnabled": false
}

Any subset of the create fields. Omitting config.secret on a webhook update preserves the existing secret; sending an empty string clears it (future deliveries go out unsigned).

DELETE /api/v1/alert-configs/:id

Remove an alert channel.

200 { "ok": true } or 404.

GET /api/v1/breaches

List breach findings across all monitored emails.

Query: limit, offset, acknowledged (true/ false), scanId — all optional.

curl "https://cykanner.io/api/v1/breaches?limit=50&acknowledged=false" \
  -H "Authorization: Bearer $CYKANNER_API_KEY"
200 OK
{
  "breaches": [
    {
      "id": 210,
      "email": "jane@example.com",
      "emailId": 91,
      "breach": {
        "id": 44,
        "name": "ExampleForum2019",
        "title": "Example Forum",
        "domain": "exampleforum.com",
        "breachDate": "2019-03-12",
        "dataClasses": ["Email addresses", "Passwords"],
        "pwnCount": 12345678,
        "description": "In 2019, Example Forum suffered a data breach..."
      },
      "firstDetectedAt": "2026-08-20T09:00:14.000Z",
      "lastSeenAt": "2026-08-20T09:00:14.000Z",
      "firstDetectedScanId": 4821,
      "acknowledged": false,
      "acknowledgedAt": null,
      "acknowledgedBy": null,
      "acknowledgedByApiKey": null,
      "acknowledgeReason": null,
      "resolution": null
    }
  ],
  "total": 1,
  "unacknowledged": 1
}

id is the finding's permanent identifier: it never changes and is never reused. A finding is deleted when its monitored email is removed or the organization is deleted, after which its id no longer resolves. firstDetectedScanId is the id of the scan that first detected the finding. It is null when no link was recorded, which is the case for findings detected before scan linking was introduced; it would also become null if that scan's record were ever deleted. A scan older than 365 days is no longer returned by GET /api/v1/scans, but its id stays on the finding.

POST /api/v1/breaches/acknowledge

Acknowledge a breach finding, optionally with a reason and resolution.

Request body
{
  "id": 210,
  "resolution": "handled",
  "reason": "Password rotated, hardware key enrolled"
}

resolution is "handled" (default) or "ignored"; reason is optional, up to 1000 characters.

200 OK
{ "success": true }

404 if the id doesn't belong to this organization.