Webhooks
Webhooks deliver outbound notifications from Novantra to your integration when important state changes happen in your workspace. They let you avoid polling list endpoints just to discover new findings, completed assessments, or submission events.
Event catalogue
The v1 webhook surface covers the events external integrations most often need:
| Event | When it fires |
|---|---|
finding.created | A new finding was recorded in your workspace (via the UI or POST /api/v1/governance/findings). |
finding.updated | A finding’s severity, status, owner, or due date changed. |
finding.closed | A finding was closed (resolved, accepted, or invalidated). |
evidence.claim.created | A new evidence claim was created. |
evidence.claim.approved | A previously-pending evidence claim was approved by a reviewer. |
evidence.claim.rejected | A previously-pending evidence claim was rejected by a reviewer. |
submission.package.status_changed | A submission package moved through its lifecycle (e.g., from draft to submitted, or from submitted to accepted). |
submission.package.event_recorded | A submission event was recorded against a package (receipt, rejection, withdrawal, resubmission). |
assessment.completed | An assessment instance finished and its results are available. |
The catalogue above is the stable event set for v1.
Payload shape
Every webhook delivery is a POST to your configured URL with this JSON body:
{
"id": "evt_01HXY...",
"type": "finding.created",
"createdAt": "2026-05-21T12:34:56.789Z",
"organization": {
"id": "org_01H...",
"slug": "acme"
},
"data": {
"finding": {
"id": "find_01H...",
"title": "Annual control review overdue",
"severity": "medium",
"status": "open",
"subjectModuleKey": "controls",
"subjectResourceType": "control",
"subjectResourceId": "ctrl_01H...",
"createdAt": "2026-05-21T12:34:56.123Z"
}
}
}Fields you can rely on across every event type:
id- unique delivery ID. Use for deduplication.type- the event type. Branch on this.createdAt- when the event happened in the workspace.organization- which workspace it came from (always set).data- the event-specific payload. The shape insidedatamatches the public resource shape for that event type.
Signature verification
Every webhook delivery must be verified. Without verification, an attacker who learns your webhook URL could impersonate Novantra and forge events.
Each delivery includes:
X-Novantra-Signature: t=1716321296,v1=<signature>
X-Novantra-Timestamp: 2026-05-21T12:34:56.789ZTo verify:
- Concatenate the timestamp and the raw request body with a
.separator:<timestamp>.<body>. - Compute
HMAC-SHA256of that string, using your webhook’s signing secret as the key. - Compare the hex digest to the
v1=value in theX-Novantra-Signatureheader. Use a constant-time comparison. - Reject the delivery if the digest doesn’t match.
- Reject the delivery if the timestamp is older than 5 minutes (replay defense).
Pseudocode:
def verify(headers, raw_body, signing_secret):
signature = parse_v1(headers["X-Novantra-Signature"])
timestamp = headers["X-Novantra-Timestamp"]
if not within_5_minutes(timestamp):
return False
expected = hmac.new(
signing_secret.encode(),
f"{timestamp}.{raw_body}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)Compute the HMAC over the raw request body, exactly as sent. Re-serializing the JSON before hashing breaks verification because key order and whitespace matter.
Subscribing
Create a webhook subscription for a service account from Developers -> Webhooks -> Endpoints, or programmatically with a token that has the webhooks:manage scope:
POST /api/v1/webhooks
Authorization: Bearer <access-token>
Content-Type: application/json
{
"url": "https://example.com/novantra/webhook",
"events": ["finding.created", "finding.closed", "submission.package.status_changed"],
"description": "ServiceNow incident sync",
"reason": "Wire findings into ServiceNow incident queue."
}Response:
{
"webhook": {
"id": "whk_01H...",
"url": "https://example.com/novantra/webhook",
"events": ["finding.created", "finding.closed", "submission.package.status_changed"],
"status": "active",
"createdAt": "2026-05-21T12:34:56.789Z",
"signingSecret": "<webhook-signing-secret>"
}
}The signingSecret is returned only once, at creation time. Store it in your secrets store. You can rotate it later, but you cannot retrieve it.
You may subscribe to specific events (as above) or to * to receive every event type your token has read scope for.
Delivery semantics
- At-least-once. A given event may be delivered more than once if your endpoint is briefly unavailable or returns a non-2xx status. Use the delivery
idto deduplicate. - Ordering is best-effort within an event type. Cross-type ordering is not guaranteed.
- Timeouts. Your endpoint must respond within 10 seconds with a
2xxstatus. Otherwise the delivery is considered failed and queued for retry. - Synchronous vs asynchronous. Acknowledge with
2xxfirst, then process. Long-running processing should happen in a background queue, not in the request handler.
Retries
Failed deliveries are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 (initial) | immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
| 7 | 24 hours |
After 7 failed attempts, the delivery is marked dead-lettered. The webhook itself is not disabled, but the workspace surface shows a delivery failure that an admin can review and trigger manual replay.
Replay
Workspace admins can replay any past delivery from the webhook detail page.
A replayed delivery uses the same payload as the original; only the delivery id changes. Your deduplication should key on event id (which is stable across replays) rather than delivery id.
Disabling and rotating
- Pause a webhook to temporarily stop deliveries without losing the subscription. New events accumulate and are delivered when the webhook is unpaused.
- Rotate signing secret to invalidate the current secret. A grace window of 60 seconds keeps the old secret valid so you can update your integration’s stored secret without a window of failed verifications.
- Delete a webhook to permanently end the subscription. Any pending deliveries are dropped.
What is and isn’t a webhook event
| Webhook event? | |
|---|---|
| Customer-impacting state changes (find created, evidence approved, etc.) | yes |
| Background system events (job runs, posture sweeps) | no |
| Admin-only events (license renewals, mailer config changes) | no |
| Per-field updates inside a resource | no - one *.updated event covers any field change |
| Reads | no - webhooks are write/state-change-driven only |
Compliance and audit
Every webhook delivery is recorded in the workspace audit log with its delivery ID, the event ID, and the destination URL. Webhook subscription changes (create, update, delete, rotate secret, pause) are also audited.
Next
- Governance reference - per-resource endpoints for what fires which event.