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
/api/v1/scans Trigger a new breach scan for the organization.
No request body.
{
"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.
/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.
{
"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.
/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.
{
"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.
/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" {
"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
} /api/v1/emails Add a new monitored email.
{
"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.
{
"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).
/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).
/api/v1/emails/:id Enable or disable monitoring for one email, without deleting its history.
{
"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).
/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" {
"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).
/api/v1/emails/import/commit Commit a plan returned by the preview endpoint — echo its emails[] and quote back exactly.
{
"emails": [
{ "email": "new.hire@example.com", "domainId": 3, "status": "will_import" }
],
"quote": {
"interval": "monthly",
"isEstimate": false,
"newSeatsCount": 1,
"newActiveCount": 1,
"estimatedDeltaCents": 500
}
} {
"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).
/api/v1/alert-configs List the organization's alert channel configurations.
{
"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.
/api/v1/alert-configs Create an alert channel (email, slack, or webhook — up to 20 per organization).
{
"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).
{ "id": 5 } 400 on an invalid channel shape or an outbound URL that fails
the SSRF guard (must be public HTTPS, no embedded credentials).
/api/v1/alert-configs/:id Partially update an alert channel.
{
"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).
/api/v1/alert-configs/:id Remove an alert channel.
200 { "ok": true } or 404.
/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" {
"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.
/api/v1/breaches/acknowledge Acknowledge a breach finding, optionally with a reason and resolution.
{
"id": 210,
"resolution": "handled",
"reason": "Password rotated, hardware key enrolled"
} resolution is "handled" (default)
or
"ignored"; reason is optional, up
to 1000 characters.
{ "success": true } 404 if the id doesn't belong to this organization.