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.
| Property | Value |
|---|---|
| Transport | POST with content-type: application/x-ndjson, one event per line |
| 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 | Not 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.
| Header | What it holds |
|---|---|
webhook-id | A fresh id per attempt. Part of what is signed |
webhook-timestamp | Unix seconds at send time |
webhook-signature | One 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.
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
{
"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.
| Attempt | Waits |
|---|---|
| 2 | Immediately |
| 3 | 5 seconds |
| 4 | 5 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 5 hours |
| 8 and after | 10 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
- Stand up an HTTPS endpoint that answers
2xxfast and does the work afterward. - Open Audit Log as an owner and connect the destination. Confirm it is you when asked.
- Copy the
whsec_secret. It is shown once. - Verify the first batch against the raw body before you parse it.
- Store
event_idand drop a repeat. Tracksequenceand alarm on a jump. - Alarm on your own side when no batch has arrived for longer than your quietest hour.
- 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.
- If the destination is disabled, fix the endpoint and restart it before the five day mark passes on the next outage.