API reference
The CertSentry REST API manages your monitoring programmatically - create monitors from CI, sync them from a YAML file in your repo, or pull check results, certificates, DMARC reports and status pages into your own dashboards. API access is included in every paid plan.
The API accepts and returns JSON and lives under a single base URL:
https://certsentry.dev/api/v1Authentication
Create an API key under Settings → API keys. Keys are org-scoped bearer tokens shown once at creation; store them in your secret manager. A key is either read-only or read + write - writes with a read-only key fail with forbidden.
Authenticate every request with an Authorization header:
curl https://certsentry.dev/api/v1/monitors \
-H "Authorization: Bearer csk_..."const res = await fetch("https://certsentry.dev/api/v1/monitors", {
headers: { Authorization: `Bearer ${process.env.CERTSENTRY_API_KEY}` },
});
const { monitors } = await res.json();All examples assume your key is available as CERTSENTRY_API_KEY.
Errors
Every error response has the same envelope, so clients can switch on a stable code without parsing prose. Validation errors additionally carry a details object with field-level issues.
{
"error": {
"code": "plan_limit",
"message": "The Starter plan allows 10 monitors. Upgrade to add more."
}
}| Code | HTTP status | Meaning |
|---|---|---|
| unauthorized | 401 | Missing, malformed, unknown or revoked API key. |
| forbidden | 403 | Plan without API access, or a write with a read-only key. |
| not_found | 404 | The monitor doesn't exist or belongs to another organization. |
| invalid_input | 400 / 422 | Validation failed - details lists the offending fields. |
| plan_limit | 400 | The request would exceed a plan quota (monitors, regions, features). |
| conflict | 409 | A monitor with this externalId already exists. |
| rate_limited | 429 | Too many requests - retry shortly. |
Rate limits
Limits are per API key: 120 requests/minute for reads and 30 requests/minute for writes. Exceeding them returns 429 rate_limited; back off and retry.
Pagination
List endpoints are cursor-paginated. Pass limit to size the page and the previous response's nextCursor as cursor to fetch the next one. nextCursor is null on the last page.
curl "https://certsentry.dev/api/v1/monitors?limit=50&cursor=cmb1xk2lq0001v8d3" \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"List monitors
GET/api/v1/monitors
Returns the organization's monitors, oldest first.
Query parameters
kindstringoptional- Filter by kind:
standard,tcporheartbeat. enabledbooleanoptional- Filter by enabled state:
trueorfalse. limitintegeroptional- Page size, 1-100. Defaults to 50.
cursorstringoptional- Cursor from a previous response's
nextCursor.
curl "https://certsentry.dev/api/v1/monitors?kind=standard&enabled=true" \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"const res = await fetch("https://certsentry.dev/api/v1/monitors?kind=standard&enabled=true", {
headers: { Authorization: `Bearer ${process.env.CERTSENTRY_API_KEY}` },
});
const { monitors, nextCursor } = await res.json();{
"monitors": [
{
"id": "cmb1xk2lq0001v8d3",
"kind": "standard",
"label": "Client: Acme Corp",
"target": "acme.example.com",
"enabled": true,
"checkSsl": true,
"checkDomain": true,
"checkDns": false,
"checkUptime": true,
"checkCt": false,
"uptimeIntervalSeconds": 300,
"regions": ["virginia", "dublin"],
"alertLeadDays": [30, 14, 7, 3, 1],
"lastStatus": "up",
"sslNotAfter": "2026-08-14T09:21:00.000Z",
"domainExpiry": "2027-02-01T00:00:00.000Z",
"createdAt": "2026-05-02T11:08:41.310Z"
// ...remaining monitor fields
}
],
"nextCursor": null
}Create a monitor
POST/api/v1/monitors
The body matches the dashboard form. kind selects the monitor type; omitting it creates a standard (HTTPS) monitor.
Body parameters
kindstringoptionalstandard(default),tcporheartbeat.labelstringrequired- Display name, up to 100 characters.
targetstringrequired- Hostname (
acme.example.com) or full URL (https://acme.example.com/health). TCP monitors take a bare hostname or public IP; heartbeat monitors have no target. checkSsl / checkDomain / checkDns / checkUptimebooleanrequired- Which checks run for a standard monitor.
checkCtbooleanoptional- Certificate Transparency monitoring: alert when any new certificate is issued for the registrable domain, subdomains included. Pro and up. Defaults to false.
uptimeIntervalSecondsintegerrequired- Check interval:
60,300,900,3600or86400. regionsstring[]required- Probe locations:
virginia,dublin,singapore. Plan limits apply. alertLeadDaysinteger[]required- Expiry alert thresholds in days (1-365), up to 10 values.
enabledbooleanoptional- Defaults to true.
externalIdstringoptional- Stable slug identifying the monitor for declarative sync. Unique per organization.
environmentIdstringoptional- Id of one of the organization's environments (e.g. Production, Staging).
nullclears it; omit to leave unchanged on update. tcpPortintegerrequired- TCP monitors only: the port probed on the target, 1-65535 (a few sensitive ports are blocked).
heartbeatIntervalSecondsintegerrequired- Heartbeat monitors only: how often a ping is expected (same values as uptimeIntervalSeconds).
heartbeatGraceSecondsintegerrequired- Heartbeat monitors only: extra silence allowed before alerting -
60,300,900or3600.
Advanced HTTP options (standard monitors, optional)
httpMethodstringoptionalGET(default),HEAD,POST,PUT,PATCHorDELETE.httpHeaders{name, value}[]optional- Up to 10 request headers. Values are write-only secrets: responses return them masked, and sending value: null keeps the stored value.
httpBodystringoptional- Request body, up to 2048 characters. Requires POST, PUT or PATCH.
httpExpectedStatusstringoptional- Status codes counted as up, e.g.
"200-299"or"200,301". Defaults to 200-399. httpKeyword / httpKeywordModestringoptional- Mark the check failed unless the body contains (
present) or doesn't contain (absent) the keyword. degradedThresholdMsintegeroptional- Latency (50-60000 ms) above which a passing check counts as degraded.
curl -X POST https://certsentry.dev/api/v1/monitors \
-H "Authorization: Bearer $CERTSENTRY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"label": "Client: Acme Corp",
"target": "acme.example.com",
"checkSsl": true,
"checkDomain": true,
"checkDns": false,
"checkUptime": true,
"uptimeIntervalSeconds": 300,
"regions": ["virginia"],
"alertLeadDays": [30, 14, 7, 3, 1]
}'const res = await fetch("https://certsentry.dev/api/v1/monitors", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CERTSENTRY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
label: "Client: Acme Corp",
target: "acme.example.com",
checkSsl: true,
checkDomain: true,
checkDns: false,
checkUptime: true,
uptimeIntervalSeconds: 300,
regions: ["virginia"],
alertLeadDays: [30, 14, 7, 3, 1],
}),
});
const { monitor } = await res.json();TCP and heartbeat monitors use the same endpoint with their own fields:
{
"kind": "tcp",
"label": "Postgres",
"target": "db.acme.example.com",
"tcpPort": 5432,
"uptimeIntervalSeconds": 300,
"regions": ["virginia"]
}{
"kind": "heartbeat",
"label": "Nightly backup",
"heartbeatIntervalSeconds": 86400,
"heartbeatGraceSeconds": 3600
}A heartbeat monitor's response includes its heartbeatToken; have your job hit https://certsentry.dev/api/ping/<token> on success (or .../fail on failure). The monitor stays unarmed until its first ping.
{
"monitor": {
"id": "cmbqi0x9c0003l8dg",
"kind": "standard",
"label": "Client: Acme Corp",
"target": "acme.example.com",
"enabled": true,
"lastStatus": "unknown",
"createdAt": "2026-06-11T15:30:00.000Z"
// ...remaining monitor fields
}
}Retrieve a monitor
GET/api/v1/monitors/:id
Returns a single monitor as { "monitor": { ... } }.
curl https://certsentry.dev/api/v1/monitors/cmb1xk2lq0001v8d3 \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"const res = await fetch("https://certsentry.dev/api/v1/monitors/cmb1xk2lq0001v8d3", {
headers: { Authorization: `Bearer ${process.env.CERTSENTRY_API_KEY}` },
});
const { monitor } = await res.json();Update a monitor
PATCH/api/v1/monitors/:id
Replaces the monitor's definition: send the complete body, same fields as create - omitted optional fields fall back to their defaults. A monitor's kind can't change; create a new monitor instead. Returns the updated monitor as { "monitor": { ... } }.
curl -X PATCH https://certsentry.dev/api/v1/monitors/cmb1xk2lq0001v8d3 \
-H "Authorization: Bearer $CERTSENTRY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"label": "Client: Acme Corp",
"target": "acme.example.com",
"checkSsl": true,
"checkDomain": true,
"checkDns": true,
"checkUptime": true,
"uptimeIntervalSeconds": 60,
"regions": ["virginia", "dublin"],
"alertLeadDays": [30, 14, 7, 3, 1]
}'const res = await fetch("https://certsentry.dev/api/v1/monitors/cmb1xk2lq0001v8d3", {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.CERTSENTRY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
label: "Client: Acme Corp",
target: "acme.example.com",
checkSsl: true,
checkDomain: true,
checkDns: true,
checkUptime: true,
uptimeIntervalSeconds: 60,
regions: ["virginia", "dublin"],
alertLeadDays: [30, 14, 7, 3, 1],
}),
});
const { monitor } = await res.json();Delete a monitor
DELETE/api/v1/monitors/:id
Deletes the monitor and its check history. Returns { "ok": true }.
curl -X DELETE https://certsentry.dev/api/v1/monitors/cmb1xk2lq0001v8d3 \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"await fetch("https://certsentry.dev/api/v1/monitors/cmb1xk2lq0001v8d3", {
method: "DELETE",
headers: { Authorization: `Bearer ${process.env.CERTSENTRY_API_KEY}` },
});Check results
GET/api/v1/monitors/:id/results
Raw check results, newest first. Results are retained for 90 days.
Query parameters
typestringoptionalssl,domain,dns,uptime,heartbeat,tcporct.statusstringoptionalok,failorunknown.sincestringoptional- ISO 8601 datetime; only results created at or after it, e.g.
2026-06-01T00:00:00Z. limitintegeroptional- Page size, 1-500. Defaults to 100.
cursorstringoptional- Cursor from a previous response's
nextCursor.
curl "https://certsentry.dev/api/v1/monitors/cmb1xk2lq0001v8d3/results?type=uptime&status=fail" \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"const res = await fetch(
"https://certsentry.dev/api/v1/monitors/cmb1xk2lq0001v8d3/results?type=uptime&status=fail",
{ headers: { Authorization: `Bearer ${process.env.CERTSENTRY_API_KEY}` } },
);
const { results, nextCursor } = await res.json();{
"results": [
{
"id": "cmbqj4r2e0009l8dg",
"type": "uptime",
"status": "fail",
"degraded": false,
"region": "virginia",
"latencyMs": 1043,
"httpStatus": 503,
"error": "HTTP 503",
"createdAt": "2026-06-11T15:28:12.000Z"
}
],
"nextCursor": null
}Daily stats
GET/api/v1/monitors/:id/stats
Per-UTC-day uptime and latency rollups. The window spans at most 90 days and defaults to the last 30. Percentiles (p50LatencyMs, p95LatencyMs) are computed when a day is finalized overnight.
Query parameters
fromstringoptional- Start day,
YYYY-MM-DD(UTC). tostringoptional- End day,
YYYY-MM-DD(UTC). Defaults to today.
curl "https://certsentry.dev/api/v1/monitors/cmb1xk2lq0001v8d3/stats?from=2026-05-01&to=2026-06-01" \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"const res = await fetch(
"https://certsentry.dev/api/v1/monitors/cmb1xk2lq0001v8d3/stats?from=2026-05-01&to=2026-06-01",
{ headers: { Authorization: `Bearer ${process.env.CERTSENTRY_API_KEY}` } },
);
const { stats } = await res.json();{
"stats": [
{
"date": "2026-05-01",
"totalChecks": 288,
"failedChecks": 1,
"degradedChecks": 4,
"avgLatencyMs": 212,
"p50LatencyMs": 184,
"p95LatencyMs": 412,
"maxLatencyMs": 1043,
"uptimePercent": 99.65,
"finalized": true
}
]
}Sync monitors
PUT/api/v1/monitors/sync
Declaratively reconciles a set of monitors: the payload is the desired state for every monitor carrying an externalId; monitors without one are never touched. Validation is all-or-nothing - any invalid entry rejects the whole sync with 422 and a per-item error list, and nothing is applied.
Body parameters
monitorsobject[]required- Up to 250 monitor definitions (same fields as create); each needs an
externalId. Heartbeat monitors can't be synced (their ping token is stateful), and a monitor'skindcan't change. prunebooleanoptional- Delete managed monitors missing from the payload. Defaults to
false. dryRunbooleanoptional- Validate and return the plan without applying anything. Defaults to
false.
curl -X PUT https://certsentry.dev/api/v1/monitors/sync \
-H "Authorization: Bearer $CERTSENTRY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"monitors": [
{
"externalId": "acme-web",
"label": "Acme web",
"target": "acme.example.com",
"checkSsl": true,
"checkDomain": true,
"checkDns": false,
"checkUptime": true,
"uptimeIntervalSeconds": 300,
"regions": ["virginia"],
"alertLeadDays": [30, 14, 7]
},
{
"externalId": "acme-db",
"kind": "tcp",
"label": "Acme DB",
"target": "db.acme.example.com",
"tcpPort": 5432,
"uptimeIntervalSeconds": 300,
"regions": ["virginia"]
}
],
"prune": true,
"dryRun": false
}'{
"created": ["acme-web"],
"updated": [],
"unchanged": ["acme-db"],
"deleted": [],
"dryRun": false
}List environments
GET/api/v1/environments
The organization's environment labels. Use an environment's id as a monitor's environmentId when creating or syncing monitors.
curl https://certsentry.dev/api/v1/environments \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"const res = await fetch("https://certsentry.dev/api/v1/environments", {
headers: { Authorization: `Bearer ${process.env.CERTSENTRY_API_KEY}` },
});
const { environments } = await res.json();{
"environments": [
{ "id": "cmb1env00010", "name": "Production" },
{ "id": "cmb1env00020", "name": "Staging" }
]
}List certificates
GET/api/v1/certificates
The SSL certificate inventory across every standard monitor with SSL checks, soonest expiry first - a single "what's about to expire" view.
curl https://certsentry.dev/api/v1/certificates \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"{
"certificates": [
{
"monitorId": "cmb1xk2lq0001v8d3",
"label": "Client: Acme Corp",
"target": "acme.example.com",
"issuer": "Let's Encrypt",
"notAfter": "2026-08-14T09:21:00.000Z",
"status": "valid",
"error": null,
"grade": "A",
"lastCheckedAt": "2026-06-11T06:00:00.000Z"
}
]
}status is valid, invalid (see error) or pending (no successful check yet). grade is the TLS configuration grade (A…F) or null.
List DMARC domains
GET/api/v1/dmarc/domains
DMARC domains monitored by the organization. The rua token is a secret and is never returned - rotate it from the dashboard.
curl https://certsentry.dev/api/v1/dmarc/domains \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"{
"domains": [
{
"id": "cmb1dmrc00010",
"domain": "example.com",
"failureRateThresholdPct": 5,
"lastFailureRateAlertAt": null,
"createdAt": "2026-05-02T11:08:41.310Z"
}
]
}Retrieve a DMARC domain
GET/api/v1/dmarc/domains/:id
Returns a single domain as { "domain": { ... } }.
curl https://certsentry.dev/api/v1/dmarc/domains/cmb1dmrc00010 \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"Aggregate reports
GET/api/v1/dmarc/domains/:id/reports
RFC 7489 aggregate reports for the domain, newest first. Cursor-paginated.
Query parameters
limitintegeroptional- Page size, 1-200. Defaults to 50.
cursorstringoptional- Cursor from a previous response's
nextCursor.
curl "https://certsentry.dev/api/v1/dmarc/domains/cmb1dmrc00010/reports?limit=50" \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"{
"reports": [
{
"id": "cmb1rep000010",
"reporterOrg": "google.com",
"externalId": "8a1b2c3d",
"beginAt": "2026-06-10T00:00:00.000Z",
"endAt": "2026-06-11T00:00:00.000Z",
"policyPublished": { "domain": "example.com", "p": "reject", "pct": 100 },
"messageCount": 1284,
"failCount": 7
}
],
"nextCursor": null
}DMARC sources
GET/api/v1/dmarc/domains/:id/sources
Every source IP ever seen sending as the domain - the "known sender" memory behind new-source alerts - most recently active first.
Query parameters
limitintegeroptional- Max rows, 1-1000. Defaults to 200.
curl https://certsentry.dev/api/v1/dmarc/domains/cmb1dmrc00010/sources \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"{
"sources": [
{
"ip": "209.85.220.41",
"firstSeenAt": "2026-05-02T00:00:00.000Z",
"lastSeenAt": "2026-06-11T00:00:00.000Z",
"passCount": 4210,
"failCount": 3
}
]
}List status pages
GET/api/v1/status-pages
The organization's public status pages, with their monitors in display order.
curl https://certsentry.dev/api/v1/status-pages \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"{
"statusPages": [
{
"id": "cmb1sp0000010",
"slug": "acme",
"title": "Acme status",
"description": "Live service status",
"enabled": true,
"createdAt": "2026-05-02T11:08:41.310Z",
"updatedAt": "2026-06-01T09:00:00.000Z",
"monitors": [
{
"monitorId": "cmb1xk2lq0001v8d3",
"publicLabel": "Website",
"showTarget": false,
"showCertHealth": true,
"sortOrder": 0
}
]
}
]
}Create a status page
POST/api/v1/status-pages
Publishes a status page at https://certsentry.dev/status/<slug>. Slugs are a global namespace, and the plan's status-page quota applies.
Body parameters
slugstringrequired- 3-40 lowercase letters, digits and inner hyphens; globally unique.
titlestringrequired- Page heading, up to 80 characters.
descriptionstringoptional- Optional sub-heading, up to 300 characters.
enabledbooleanoptional- Whether the page is live. Defaults to true.
monitorsobject[]required- 1-20 entries, in display order. Each:
monitorId,publicLabel, and optionalshowTarget(default false) andshowCertHealth(default true).
curl -X POST https://certsentry.dev/api/v1/status-pages \
-H "Authorization: Bearer $CERTSENTRY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "acme",
"title": "Acme status",
"monitors": [
{ "monitorId": "cmb1xk2lq0001v8d3", "publicLabel": "Website" }
]
}'Retrieve a status page
GET/api/v1/status-pages/:id
Returns a single status page as { "statusPage": { ... } }.
Update a status page
PATCH/api/v1/status-pages/:id
Replaces the page definition: send the complete body (same fields as create). The monitor list is replaced wholesale, and array order becomes the public display order.
Delete a status page
DELETE/api/v1/status-pages/:id
Unpublishes and deletes the page. Returns { "ok": true }.
List audit events
GET/api/v1/audit-logs
The organization's audit trail of member actions (who changed what, when), newest first. Cursor-paginated. Requires the Agency plan.
Query parameters
actionstringoptional- Filter by action verb, e.g.
monitor.update. targetTypestringoptional- Filter by target kind, e.g.
monitor,member,apiKey. sincestringoptional- ISO 8601 timestamp; only events at or after it.
limitintegeroptional- Page size, 1-500. Defaults to 100.
cursorstringoptional- Cursor from a previous response's
nextCursor.
curl "https://certsentry.dev/api/v1/audit-logs?targetType=monitor&limit=100" \
-H "Authorization: Bearer $CERTSENTRY_API_KEY"{
"auditLogs": [
{
"id": "cmb1aud000010",
"action": "monitor.update",
"actorId": "cmb1usr000010",
"actorEmail": "ops@acme.example.com",
"targetType": "monitor",
"targetId": "cmb1xk2lq0001v8d3",
"metadata": { "label": "Acme web" },
"createdAt": "2026-06-11T15:30:00.000Z"
}
],
"nextCursor": null
}Monitors as code
Commit a monitors.yaml to your repo and sync it on every push with yq + curl - no extra tooling needed. Run with dryRun: true first to preview what would change.
monitors:
- externalId: acme-web
label: Acme web
target: acme.example.com
checkSsl: true
checkDomain: true
checkDns: false
checkUptime: true
uptimeIntervalSeconds: 300
regions: [virginia]
alertLeadDays: [30, 14, 7]
- externalId: acme-db
kind: tcp
label: Acme DB
target: db.acme.example.com
tcpPort: 5432
uptimeIntervalSeconds: 300
regions: [virginia]name: Sync monitors
on:
push:
branches: [main]
paths: [monitors.yaml]
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
yq -o=json '. + {"prune": true}' monitors.yaml | \
curl --fail-with-body -X PUT https://certsentry.dev/api/v1/monitors/sync \
-H "Authorization: Bearer ${{ secrets.CERTSENTRY_API_KEY }}" \
-H "Content-Type: application/json" \
--data @-Webhook alerts
Outgoing alert webhooks (configured under Settings → Notification channels) POST this JSON to your endpoint:
{
"event": "uptime.down",
"severity": "critical",
"status": "down",
"monitor": {
"id": "cmb1xk2lq0001v8d3",
"label": "Client: Acme Corp",
"target": "acme.example.com"
},
"details": { "Last error": "connect timeout" },
"timestamp": "2026-06-11T15:30:00.000Z"
}severity is critical, warning or resolved. Possible events:
| Event | Fired when |
|---|---|
| uptime.down | Outage confirmed: at least 2 regions failed 2 consecutive checks. |
| uptime.up | The monitor recovered from an outage. |
| uptime.degraded | Checks pass but latency exceeds the degraded threshold. |
| ssl.expiring | Certificate expiry crossed one of the monitor's lead-day thresholds. |
| ssl.invalid | Handshake, hostname or chain problem with the served certificate. |
| ssl.valid | A previous certificate problem was resolved (e.g. renewal). |
| domain.expiring | Domain registration expiry crossed a lead-day threshold. |
| dns.changed | A DNS record change was confirmed on two consecutive checks. |
| heartbeat.missed | No ping arrived within the interval plus grace period. |
| heartbeat.failed | The job explicitly reported failure via the /fail ping URL. |
| heartbeat.recovered | Pings resumed after a miss or failure. |
| ct.new_certificate | A new certificate was issued for the domain (incl. subdomains). |
| dmarc.new_source | DMARC failures from a source IP never seen for the domain (monitor = the DMARC domain). |
| dmarc.failure_rate | A report's DMARC failure rate crossed the domain's threshold (monitor = the DMARC domain). |
Verifying signatures
When a signing secret is set, requests include X-CertSentry-Timestamp (unix seconds) and X-CertSentry-Signature headers, where signature = hex(HMAC-SHA256(secret, "<timestamp>.<rawBody>")). Compute the HMAC over the raw request body, compare in constant time, and reject timestamps older than 5 minutes:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, timestamp, rawBody, signature) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex"), b = Buffer.from(signature, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}import hashlib, hmac, time
def verify(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Audit webhook
On the Agency plan you can stream every audit event to your SIEM in real time (configured under Settings → Audit log webhook). Each event is POSTed as it happens:
{
"type": "audit.event",
"id": "cmb1aud000010",
"event": "member.role.change",
"actor": { "id": "cmb1usr000010", "email": "owner@acme.example.com" },
"target": { "type": "member", "id": "cmb1usr000020" },
"metadata": { "role": { "before": "member", "after": "admin" } },
"timestamp": "2026-06-11T15:30:00.000Z"
}Requests are signed exactly like alert webhooks: the X-CertSentry-Timestamp and X-CertSentry-Signature headers carry hex(HMAC-SHA256(secret, "<timestamp>.<rawBody>")), so the same verification code above applies. Delivery is best-effort - the in-app log, the CSV export and the /api/v1/audit-logs endpoint remain the source of truth.