Docs
Runbooksince cloud@2026-09-10

Stream the audit log

Enterprise workspaces ship every entry to an endpoint you own, as signed NDJSON batches you verify with three headers.

Every reveal is written down with who, what, and when. An Enterprise workspace can also ship that record to a destination you own, so your own tooling holds a copy nobody here can reach.

What you get

One kind of destination exists: an HTTPS endpoint you run. There is no bucket and no vendor connector.

PropertyValue
TransportPOST with content-type: application/x-ndjson, one event per line
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
RedirectsNot followed. A 3xx answer counts as a failure

Plain HTTP is refused when you connect, because an audit stream in the clear is not something Penv Cloud will carry.

Connect it

Open Audit Log. Three things have to hold at once:

  • The workspace is on the Enterprise plan.
  • You are an owner of it.
  • You confirm it is you.

No permission slug covers this. Deciding where a complete record of everything the workspace does gets sent is not something a stolen session should reach.

The console returns a signing secret starting with whsec_ once. Only a sealed copy is kept, so losing it means rotating rather than recovering. The same gate covers rotating the secret and disconnecting. Restarting a disabled destination needs it too.

Verify a batch

Three headers travel with every batch, following the Standard Webhooks specification, so an off-the-shelf library verifies them.

HeaderWhat it holds
webhook-idA fresh id per attempt. Part of what is signed
webhook-timestampUnix seconds at send time
webhook-signatureOne or more v1,<base64> signatures, space delimited

The signed string is the id, a dot, the timestamp, a dot, then the raw body. The MAC is HMAC with SHA-256, keyed on the secret's decoded bytes rather than its text, and the result is base64.

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);
    });
}

Verify against the raw body, before any JSON parsing. A re-serialized body has a different MAC and every batch will look forged.

Batches are signed at send time, so the five minute window is measured from the send rather than from when the batch was queued.

One secret is stored and one signature travels with each batch, so a rotation is a hard cutover. The console mints the new secret and shows it once, and every batch after that carries only the new signature. Update your receiver with the new secret before the next batch lands, which is minutes after the next thing anyone does in the workspace. A receiver still holding the old secret rejects every batch, and each one is retried on the backoff curve below until the destination is disabled at the five day mark.

What one line looks like

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 }
}

time is epoch milliseconds. actor is a person or a machine identity, and a credential id never appears there. target names where the value lives. It joins the workspace id, the project id, the environment id, the path and the key name with slashes, and a key at the root leaves the path segment empty. A value never appears in an entry.

Metadata is allowlisted and closed by default. A key nobody has reviewed does not leave the platform, so a new key is silently absent downstream until somebody adds it. email is left out on purpose, because it is personal data rather than a workspace identifier and a consumer who wants it can join on the actor id.

When your endpoint is down

Delivery follows activity rather than a clock, so a batch trails the events that produced it by minutes. The daily job is only the safety net.

AttemptWaits
2Immediately
35 seconds
45 minutes
530 minutes
62 hours
75 hours
8 and after10 hours

Eight attempts fall inside about 17.6 hours, and every attempt after that waits ten hours.

Every failed delivery is retried on that curve. A 4xx that says the request is wrong is retried the same way a 5xx is, and the cursor holds where it is until a batch is accepted.

After five days of continuous failure the destination is disabled and Penv Cloud stops trying. The clock runs from the first failure in the streak, so a destination that fails slowly is disabled on the same rule as one that fails fast.

Nothing is lost while it is failing. The retention job refuses to delete past what an enabled destination has delivered, so a backlog waits for you. Once the auto-disable stops the destination that workspace prunes on its ordinary schedule again, which is the moment a long outage starts costing you entries.

Restart it on Audit Log once your endpoint is healthy. The backlog goes out from where the sequence stopped.

Do it in order

  1. Stand up an HTTPS endpoint that answers 2xx fast and does the work afterward.
  2. Open Audit Log as an owner and connect the destination. Confirm it is you when asked.
  3. Copy the whsec_ secret. It is shown once.
  4. Verify the first batch against the raw body before you parse it.
  5. Store event_id and drop a repeat. Track sequence and alarm on a jump.
  6. Alarm on your own side when no batch has arrived for longer than your quietest hour.
  7. Before you rotate, make the receiver read its secret from somewhere you can change in seconds. Rotate in Audit Log, then put the new secret there before the next batch lands.
  8. If the destination is disabled, fix the endpoint and restart it before the five day mark passes on the next outage.

The record of who did what