GitHub Issues and Linear are built in: see Tickets in GitHub and Linear. For any other tool, a webhook sends events to an HTTPS address of yours as they happen. With one event per finding, a tool like n8n, Zapier or Make can keep one ticket per finding in your tracker: open it when a scan finds the problem, and close it when a scan confirms the fix.
Add a webhook
On the Webhooks page, paste your endpoint's URL and choose Add and test. A sample event is sent first, and the webhook is saved only if it's delivered. Copy the signing secret shown once.
Then open its Settings to choose:
- Events: which events it gets (below). A new webhook gets every finding event and failed scans.
- Findings of: the least severity it's sent, for example high and up. An update counts when the finding was at that level before or after, so a ticket hears about a finding that dropped below it.
- Targets: every target, or only the ones you pick.
Owners and admins manage webhooks. An organization can have 5.
Events
| Event | When |
|---|---|
finding.opened |
A scan finds it for the first time |
finding.reopened |
A scan finds it again after it was fixed, someone reopens a finding that was set aside, or its review date comes (sent that day at 14:00 UTC) |
finding.updated |
Its severity changed |
finding.resolved |
A scan confirms it's gone |
finding.set_aside |
Someone set it aside as accepted risk or a false positive, with the reason |
finding.overdue |
It's past its SLA due date: on the day, then every 7 days while it stays overdue, 5 more times at most (sent at 14:00 UTC). Only if you choose it |
findings.changed |
A scan alert: what changed in one scan, as in the alert email |
scan.failed |
A scan failed or timed out |
scan.empty |
A scan returned nothing while findings were open (not subdomain takeover, where nothing means the dangling records are gone) |
email_record.changed |
A domain that receives mail lost its SPF, DMARC, TLS-RPT or MTA-STS record, or one started erroring (from the daily expiry watch). Only if you choose it |
target.verified |
A target's proof of control passed |
target.verification_lost |
A verified target's DNS record was found gone (phase: "grace", with scansStopAt 72 hours on), or it stayed gone and scans stopped (phase: "stopped") |
Findings that were set aside, and open ports you listed as expected, don't send events.
What's sent
Each event is a POST with a JSON body:
{
"id": "5b0c1c7e-2f6a-4f4e-9a59-8f0b6a3d1c22",
"type": "finding.opened",
"sentAt": "2026-10-27T14:03:11.000Z",
"org": { "id": "acme-1a2b3c", "name": "Acme" },
"finding": {
"id": "12#kali-testssl#testssl#443#cert_expirationStatus",
"title": "cert_expirationStatus: expires < 30 days (21)",
"severity": "high",
"status": "open",
"scanner": "kali-testssl",
"target": { "id": "12", "name": "Shop", "endpoint": "https://shop.example.com" },
"firstSeenAt": "2026-10-27T14:02:50.000Z",
"observedAt": "2026-10-27T14:02:50.000Z",
"frameworks": {
"basis": "Certificate validity",
"owasp": { "id": "A02", "name": "Cryptographic Failures" },
"nist80053": [{ "id": "SC-8", "name": "Transmission Confidentiality and Integrity" }],
"csf": [{ "id": "PR.DS", "name": "Data Security" }]
},
"remediation": { "summary": "Renew the certificate before it expires.", "steps": ["…"] },
"url": "https://security.akadenia.com/o/acme-1a2b3c/targets/12/findings?finding=…"
}
}
finding.idstays the same for the life of a finding: key your ticket on it.finding.updatedaddspreviousSeverity;finding.set_asideaddsdecisionwith itskind,reason, who decided and when to look again.finding.overdueaddsoverduewith itsdueAt, the SLA'sslaDays, andnotice: 0 the first time, then 1 to 5.finding.resolvedaddsverifiedFixwhen Verify fix confirmed it: its run'sjobId,at, andby, the email of who asked.idis the delivery's ID, the same on every retry, also sent as thex-akadenia-deliveryheader: skip one you've already handled.- A test from the Test button has
"test": true.
Expiring certificates and domains
The daily expiry watch's findings, about a TLS certificate or a domain registration running out, come through the same events as any other finding. There is no event type of their own:
finding.openedwhen one first appears, andfinding.reopenedwhen the same certificate or domain comes up again after a renewal (itsfinding.idstays the same).finding.updated, withpreviousSeverity, each time its severity steps up as expiry gets closer. A certificate opens as info 30 days out and steps to low (14 days), medium (7) and high (1). A registration opens as info 60 days out and steps to low (30), medium (14) and high (7). A hook with a minimum severity gets the first event that passes it, which may be afinding.updatedfor a finding it hasn't seen yet.finding.resolvedonce the certificate is renewed or the registration extended.
Expiring doesn't change the severity, which is already high by then, so it sends no event. Read the date from details rather than waiting for one.
Tell them apart by finding.scanner, which is expiry-watch, and by what finding.id names after the target and scanner:
certificate#<host>:<port>for a certificate.registration#<domain>for a registration.
finding.status is expiring or expired. A registration can instead be in a state that matters whatever its date, and opens high or critical straight away: client hold, redemption period, pending delete, auto renew period, or not registered (critical: anyone can register it).
Their details is JSON:
- A certificate's: its
host,portandaddress, the date (validTo) and days left (daysUntilExpiry), and itssubjectandissuerwhere the certificate gives them. - A registration's: the
domain, itsregistrarand registrystatuscodes, and the date and days left when the registry gives an expiry. - A domain that's no longer registered: only
domainand"registered": false.
{
"id": "0d3e5b8a-6c1f-4a8e-9a7d-2b9f1c4e7a10",
"type": "finding.updated",
"sentAt": "2026-11-04T06:12:40.000Z",
"org": { "id": "acme-1a2b3c", "name": "Acme" },
"finding": {
"id": "12#expiry-watch#certificate#shop.example.com:443",
"title": "TLS certificate for shop.example.com expires on 2026-11-10",
"severity": "medium",
"previousSeverity": "low",
"status": "expiring",
"scanner": "expiry-watch",
"target": { "id": "12", "name": "Shop", "endpoint": "https://shop.example.com" },
"firstSeenAt": "2026-10-12T06:10:02.000Z",
"observedAt": "2026-11-04T06:12:31.000Z",
"details": "{\"host\":\"shop.example.com\",\"port\":443,\"validTo\":\"2026-11-10T23:59:59.000Z\",\"daysUntilExpiry\":6,\"issuer\":\"Let's Encrypt\",\"address\":\"203.0.113.10\"}",
"frameworks": { "…": "…" },
"url": "https://security.akadenia.com/o/acme-1a2b3c/targets/12/findings?finding=…"
}
}
Email records gone or broken
Once a day, the expiry watch reads the SPF, DMARC, TLS-RPT and MTA-STS records of each domain that receives mail (has MX records). When one that was published is gone, or one that was fine now has an error (as the email scan calls an error), an email_record.changed event goes out. It comes from the target that holds the domain. A record that comes back or is fixed sends nothing, and neither does one that couldn't be read that day. These aren't findings: the email scan's findings stay where they are.
A generic webhook gets it only if you choose it. Slack hooks get it with the scan alerts, unless the target's alerts are muted. A record the setup cards verified for you (the hosted MTA-STS records, the DMARC and TLS report records) is left to the setup records check, which opens a finding of its own instead.
It has the scan alert's fields, subject and text as in the email, and records, one per change: the domain, the record's kind (spf, dmarc, tls-rpt or mta-sts), the change (disappeared or erroring), and the record before and after, each with its record text and any error.
{
"type": "email_record.changed",
"orgId": "acme-1a2b3c",
"targetId": "12",
"targetName": "Shop",
"scanKind": "expiry-watch",
"jobId": "4c7d2a1e-9b3f-4e8a-a6d5-0f1e2c3b4a59",
"subject": "[Shop] SPF record disappeared",
"text": "…",
"records": [
{
"domain": "example.com",
"kind": "spf",
"change": "disappeared",
"before": { "record": "v=spf1 include:_spf.google.com -all" },
"after": {}
}
],
"sentAt": "2026-11-04T05:02:13.000Z"
}
Check the signature
Every event carries x-akadenia-signature: t=<unix seconds>,v1=<hex>. v1 is HMAC-SHA256 of <t>.<raw body> with your webhook's secret. Recompute it, compare in constant time, and refuse old timestamps:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const t = Number(parts.t)
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
const given = Buffer.from(parts.v1 ?? '', 'hex')
return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, 'hex'))
}
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", 0))
if not t or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Retries and switching off
Answer with a 2xx within 5 seconds. Anything else, a redirect or no answer is retried 5 times over about an hour (after 30 seconds, 2 minutes, 10 minutes, then 15 minutes twice).
After 20 events in a row fail every try, the webhook is switched off and your owners and admins get an email. Fix the receiving end, then choose Switch back on in its settings. Recent deliveries in its settings shows the last 30 days of tries, with each answer.
Only HTTPS on the standard port, to public addresses, is sent to.
Recipe: n8n to Jira
- Add a Webhook node (POST) and use its production URL for the webhook. Add a Code node that checks the signature (the Node example above), and stop on a mismatch.
- Add a Switch on the expression
$json.body.type. finding.opened: a Jira node, Create issue, with the finding's severity and title as the summary, the target, the remediation steps and the link in the description, and a labelakadenia-followed by$json.body.finding.id(or a custom field) to find it again.finding.reopened: search for the issue by that label and transition it back to To do, or create it if there's none.finding.resolvedandfinding.set_aside: search by the label, add a comment (the scan confirmed the fix, or the reason it was set aside), and transition the issue to Done.- Optionally,
finding.updated: change the issue's priority fromseverity.
The same steps work with Plane, Linear, Asana or GitHub Issues nodes.
Zapier and Make
- Zapier: a Webhooks by Zapier → Catch Raw Hook trigger (raw, so the signature can be checked in a Code step), Paths on
type, then your tracker's Create and Find + Update actions keyed onfinding.id. - Make: a Custom webhook module, a Router on
type, and your tracker's Create and Search + Update modules keyed onfinding.id.