Penv Cloud
Start free
Docs
Runbooksince cloud@2026-09-10

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

DestinationYou give usWe send
WebhookAn HTTPS endpoint you runSigned NDJSON, below
DatadogYour site and an API keyLogs intake, ddsource:penv-cloud, service:penv-cloud-audit, each event as a JSON message
Splunk HECYour collector's HTTPS address and a HEC tokenHTTP Event Collector /services/collector/event, sourcetype:penv:audit
Amazon S3Bucket, region, prefix and an IAM role ARNOne 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:

trust policy
{
  "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>" } }
  }]
}
permissions
{
  "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

PropertyValue
TransportWebhook: POST, content-type: application/x-ndjson, one event per line
SchemeHTTPS only. We refuse plain HTTP at connect
Batch sizeUp to 200 events
DeliveryAt least once. Deduplicate on event_id
Orderingsequence is per destination and monotonic. A jump means a gap
Timeout10 seconds per attempt
RedirectsWe do not follow them. A 3xx counts as a failure
TimingWe 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.

HeaderContent
webhook-idA fresh id per attempt. Signed
webhook-timestampUnix seconds at send time
webhook-signatureOne or more v1,<base64> signatures, space-delimited
verify.js
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

one event
{
  "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 }
}
FieldContent
timeEpoch milliseconds
actorA person or machine identity id. Never a credential id
targetWorkspace id, project id, environment id, path and key name, joined by /. A root key leaves the path segment empty
metadataAllowlisted keys only. We omit an unreviewed key. We exclude email; join on actor instead

An entry never carries a secret value.

Retries

AttemptWait
2Immediately
35 seconds
45 minutes
530 minutes
62 hours
75 hours
8 and after10 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 stateRetention
Enabled, failingWe never prune past what the destination has delivered. The backlog waits
DisabledWe 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.