Akadenia SecurityAkadenia Security

Webhooks and ticket automation

Send findings to Jira, Plane, Asana or any tool through n8n, Zapier or Make, one ticket per finding, closed when it's fixed.

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.id stays the same for the life of a finding: key your ticket on it.
  • finding.updated adds previousSeverity; finding.set_aside adds decision with its kind, reason, who decided and when to look again.
  • finding.overdue adds overdue with its dueAt, the SLA's slaDays, and notice: 0 the first time, then 1 to 5.
  • finding.resolved adds verifiedFix when Verify fix confirmed it: its run's jobId, at, and by, the email of who asked.
  • id is the delivery's ID, the same on every retry, also sent as the x-akadenia-delivery header: 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.opened when one first appears, and finding.reopened when the same certificate or domain comes up again after a renewal (its finding.id stays the same).
  • finding.updated, with previousSeverity, 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 a finding.updated for a finding it hasn't seen yet.
  • finding.resolved once 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, port and address, the date (validTo) and days left (daysUntilExpiry), and its subject and issuer where the certificate gives them.
  • A registration's: the domain, its registrar and registry status codes, and the date and days left when the registry gives an expiry.
  • A domain that's no longer registered: only domain and "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

  1. 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.
  2. Add a Switch on the expression $json.body.type.
  3. 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 label akadenia- followed by $json.body.finding.id (or a custom field) to find it again.
  4. finding.reopened: search for the issue by that label and transition it back to To do, or create it if there's none.
  5. finding.resolved and finding.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.
  6. Optionally, finding.updated: change the issue's priority from severity.

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 on finding.id.
  • Make: a Custom webhook module, a Router on type, and your tracker's Create and Search + Update modules keyed on finding.id.