Stream the audit log
On Enterprise, or with the streaming add-on, we deliver every audit entry to your HTTPS endpoint, Datadog, Splunk or S3. Setup, verification and retries.
On Enterprise, or on Free and Pro with the audit log streaming add-on, we copy your audit log to one destination per workspace.
Destinations
| Destination | You give us | We send |
|---|---|---|
| Webhook | An HTTPS endpoint you run | Signed NDJSON, below |
| Datadog | Your site and an API key | Logs intake, ddsource:penv-cloud, service:penv-cloud-audit, each event as a JSON message |
| Splunk HEC | Your collector's HTTPS address and a HEC token | HTTP Event Collector /services/collector/event, sourcetype:penv:audit |
| Amazon S3 | Bucket, region, prefix and an IAM role ARN | One NDJSON object per batch at prefix/YYYY/MM/DD/<org>-<first sequence>-<last sequence>.ndjson |
We seal a Datadog key or HEC token with your workspace key. Rotate it in your provider, then replace it on Audit Log; we use the new one from the next batch.
For S3 we keep no AWS key of yours: we assume your role under your workspace's External ID, shown with both policies when you pick S3 on Audit Log. The role needs:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::<penv-account-id>:root" },
"Action": "sts:AssumeRole",
"Condition": { "StringEquals": { "sts:ExternalId": "<your External ID>" } }
}]
}{
"Version": "2012-10-17",
"Statement": [{ "Effect": "Allow", "Action": "s3:PutObject", "Resource": "arn:aws:s3:::<bucket>/*" }]
}Before we save the role we check its trust policy: we try to assume it without the External ID and refuse the role if that works, then assume it with the External ID and refuse the role if that fails. Keep the sts:ExternalId condition; without it, anyone who learns the role's ARN could have us write to your bucket.
We refuse a bucket name with a dot. If we later cannot assume the role, the stream fails with role_not_assumable and retries like any failure.
A webhook or Splunk endpoint must use port 443, or one of 8443, 9443, 8200, 8088, 8501 or 4646.
The rest of this page applies to every destination, except Verify a batch and Rotate the signing secret, which cover the webhook.
Delivery
| Property | Value |
|---|---|
| Transport | Webhook: POST, content-type: application/x-ndjson, one event per line |
| Scheme | HTTPS only. We refuse plain HTTP at connect |
| Batch size | Up to 200 events |
| Delivery | At least once. Deduplicate on event_id |
| Ordering | sequence is per destination and monotonic. A jump means a gap |
| Timeout | 10 seconds per attempt |
| Redirects | We do not follow them. A 3xx counts as a failure |
| Timing | We send on activity, not on a clock. A batch trails its events by minutes; a daily job is the fallback |
Connect
Audit Log in the console. Connecting requires all three: streaming on your plan (Enterprise, or the add-on), your owner role, and step-up. No permission slug grants it.
The same gate covers rotating the secret, disconnecting, and restarting a disabled destination. See audit.
For a webhook, we show the whsec_ signing secret once and keep only a sealed copy, so you rotate a lost secret; we cannot recover it.
Verify a batch
We sign each delivery per the Standard Webhooks specification, so a library for it verifies your batches.
| Header | Content |
|---|---|
webhook-id | A fresh id per attempt. Signed |
webhook-timestamp | Unix seconds at send time |
webhook-signature | One or more v1,<base64> signatures, space-delimited |
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verify(headers, rawBody, secret) {
const seconds = Number(headers["webhook-timestamp"]);
if (!Number.isInteger(seconds)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - seconds) > TOLERANCE_SECONDS) return false;
// The secret is base64 after the prefix, and the spec signs with those bytes.
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key)
.update(`${headers["webhook-id"]}.${seconds}.${rawBody}`)
.digest();
return headers["webhook-signature"]
.split(" ")
.filter((part) => part.startsWith("v1,"))
.some((part) => {
const given = Buffer.from(part.slice(3), "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}Signed string: id.timestamp.rawBody, HMAC-SHA256 keyed on the secret's base64-decoded bytes. We sign at send time, so the five-minute window runs from the send, not the enqueue.
Verify against the raw body, before JSON parsing. A re-serialized body has a different MAC, and every batch looks forged.
Rotate the signing secret
We keep one secret and send one signature, so rotation is a hard cutover. After rotation every batch carries only the new signature, and the next batch follows your next workspace activity by minutes.
A receiver holding the old secret rejects every batch; we retry each on the curve below until the five-day disable.
Event shape
{
"event_id": "01937f...",
"sequence": 4821,
"time": 1789041600000,
"org_id": "6b1c...",
"action": "secret.read",
"actor": "9f2e...",
"target": "6b1c9d2e/8d3a44f1/2f77b0ac//DATABASE_URL",
"source_ip": "203.0.113.7",
"status": "ok",
"metadata": { "credentialId": "3b1f...", "ms": 41 }
}| Field | Content |
|---|---|
time | Epoch milliseconds |
actor | A person or machine identity id. Never a credential id |
target | Workspace id, project id, environment id, path and key name, joined by /. A root key leaves the path segment empty |
metadata | Allowlisted keys only. We omit an unreviewed key. We exclude email; join on actor instead |
An entry never carries a secret value.
Retries
| Attempt | Wait |
|---|---|
| 2 | Immediately |
| 3 | 5 seconds |
| 4 | 5 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 5 hours |
| 8 and after | 10 hours |
Eight attempts span about 17.6 hours. We retry a 4xx like a 5xx, and hold the cursor until your endpoint accepts the batch.
After five days of continuous failure, counted from the first failure in the streak, we disable the destination and stop delivery.
| Destination state | Retention |
|---|---|
| Enabled, failing | We never prune past what the destination has delivered. The backlog waits |
| Disabled | We prune on the ordinary schedule. You lose undelivered entries past retention |
Restart the destination on Audit Log once your endpoint is healthy. We resume the backlog from where sequence stopped.
Procedure
Run an HTTPS endpoint that answers 2xx within the timeout and processes afterward, or have your Datadog API key or Splunk HEC token ready.
Open Audit Log as an owner, connect the destination, and complete step-up.
For a webhook, store the whsec_ secret (we show it once) and verify each batch against the raw body before parsing.
Drop repeated event_id values. Alert on a sequence jump.
Alert when no batch arrives for longer than the quietest expected hour.
Before rotating, load your receiver's secret from configuration that changes without a deploy. Rotate in Audit Log, then update it before the next batch.
If the destination is disabled, fix the endpoint and restart it.
A credential leaked
Runbook for your leaked machine or person credential: revoke, read the audit log, rotate what it could read, close the hole.
Import from another tool
Three ways to bring your values to us: paste a .env file in the console, sync from another service, or run penv init on a local file.