Private beta — invite only. Request access

Webhooks

Webhooks let Secronna tell your own HTTPS endpoint when a secret changes — created, rotated, revealed or deleted — instead of you polling the audit log. You register an endpoint, Secronna POSTs a signed JSON event to it, you answer 2xx. A delivery that fails is retried, and every attempt is in the delivery log.

Secret values are never in a webhook. An event names the secret (its id, key and version) and where it lives; the value stays in Secronna.

Register an endpoint

POST /api/v1/webhooks
{
  "url": "https://api.example.com/hooks/secronna",
  "events": ["secronna.secret.rotate", "secronna.secret.reveal"],
  "description": "Production sync worker"
}
  • url must be https:// and must not resolve to a private, loopback, link-local or tailnet address; the check runs again on every delivery, on the address the request actually connects to, so a DNS change cannot redirect deliveries inward.
  • events is one or more of the event types.
  • The response carries the signing secret, whsec_…, once. Later reads show only a hint (whsec_…abcd). To get a new secret, delete the endpoint and register it again.
  • A workspace can have up to 20 endpoints.

Manage endpoints with GET /api/v1/webhooks, PATCH /api/v1/webhooks/{id} (url, events, description, enabled) and DELETE /api/v1/webhooks/{id}. enabled: false pauses an endpoint: deliveries still queued for it fail, and events while it is paused are not queued for it.

Event types

Type Fires when data
secronna.secret.create A secret is created (its first version). secretId, key, version, projectId, environmentId
secronna.secret.rotate A new version of an existing secret is written. the same
secronna.secret.reveal A secret's value is read (revealed) by a person or a machine token. secretId, key, version, projectId, environmentId
secronna.secret.delete A secret is deleted. secretId, key, projectId, environmentId
secronna.webhook_endpoint.disabled Secronna switched off one of your other endpoints because it kept failing (see Delivery). endpointId, url, disabledAt, disabledReason, consecutiveFailures, failingSince

GET /api/v1/webhooks/event-types lists them.

The request

POST <your url>
Content-Type: application/json
Secronna-Signature: t=1790000000,v1=5d2c…
Secronna-Event-Id: evt_01k6…
Secronna-Event-Type: secronna.secret.rotate
Secronna-Delivery-Id: whd_01k6…
Secronna-Delivery-Attempt: 1
{
  "id": "evt_01k6a7c2m3n4p5q6r7s8t9v0w1",
  "type": "secronna.secret.rotate",
  "account": "acc_01k5…",
  "occurredAt": "2026-10-01T09:12:44.120Z",
  "data": {
    "secretId": "sec_01k5…",
    "key": "DATABASE_URL",
    "version": 4,
    "projectId": "proj_01k5…",
    "environmentId": "env_01k5…"
  }
}

id is the event's id, the same on every attempt and in Secronna-Event-Id: dedupe on it.

Verifying the signature

v1 is HMAC-SHA256 over `${t}.${rawBody}` with your endpoint's whsec_… secret. Verify against the raw body, compare in constant time, and refuse a t more than 5 minutes old. Each attempt is signed with a fresh t.

import crypto from 'node:crypto';

function verifySecronna(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(parts.v1 ?? '', 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib, hmac, time

def verify_secronna(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Delivery

Success A 2xx answer within 10 seconds. A redirect is not followed and counts as a failure.
Retries A failed attempt (another status, a timeout, a refused connection, a blocked target) is retried 1 min, 5 min, 25 min, 2 h and 12 h later — 6 attempts in all. After the 6th the delivery is failed until you retry it.
Duplicates At least once: a retry sends the same body with the same id, and an answer that arrived after our 10 seconds is sent again.
Ordering Not guaranteed; use occurredAt and the secret's version.
Auto-disable An endpoint with 20 failed attempts in a row that has been failing for at least 24 hours is switched off (enabled: false, with disabledAt and disabledReason); its queued deliveries fail and secronna.webhook_endpoint.disabled goes to your other endpoints. A short outage never switches anyone off — the retries ride it out. Fix the receiver, PATCH {"enabled": true} (which clears the failure streak), then retry what it missed.
Retention Deliveries and their attempts are kept for 30 days.

The delivery log

Method Path
GET /api/v1/webhook-deliveries All your endpoints' deliveries, newest first. Filter by endpointId, status (pending, success, failed) or eventType; page with limit (1–200) and cursor.
GET /api/v1/webhooks/{id}/deliveries One endpoint's recent deliveries.
GET /api/v1/webhook-deliveries/{id} One delivery.
POST /api/v1/webhook-deliveries/{id}/retry Send it again now. 202; 409 ALREADY_QUEUED while it is pending, 409 ENDPOINT_DISABLED while its endpoint is off.

Each delivery shows status, attempts, statusCode and lastError of the last attempt, nextRetryAt while it is pending, and attemptLog — every attempt with its status, response code, duration and error. The dashboard's Webhooks page shows the same, with a Retry button.

From the SDKs and the CLI

Every route above is in the SDKs' generated surface (client.api in Node and Python, client.API in Go) and the CLI's secronna api webhooks … and secronna api webhook-deliveries … commands — for example secronna api webhook-deliveries list --status failed and secronna api webhook-deliveries retry <id>. See the API reference for every parameter.