Tripwire posts signed JSON events to your server when interesting things happen on a session or a Gate signup. Use webhooks to react asynchronously: persist a verdict to your warehouse, fan out to a workflow, or provision an account when Gate approves a developer.
Event types
Three event types are available today. One endpoint can subscribe to any combination of them.
| Event | Sent when |
|---|
session.fingerprint.calculated | The browser SDK has frozen a visitor fingerprint for a session. Fires once, on the first freeze. |
session.result.persisted | A non-provisional verdict has been written for a session. Fires once per session, after the final scoring pass — provisional updates do not deliver. |
gate.session.approved | A developer approved a CLI signup through Gate. Your handler must respond with an encrypted credentials envelope — see the Gate webhook quickstart. |
For the full payload of each event, see the Webhooks group in the API reference.
Envelope
Every event is wrapped in the same envelope:
{
"id": "wevt_0123456789abcdef0123456789abcdef",
"object": "webhook_event",
"type": "session.result.persisted",
"created": "2026-03-24T20:00:05.000Z",
"data": { ... }
}
| Field | Description |
|---|
id | Unique event identifier. Use it to dedupe retries — see Idempotency. |
object | Always "webhook_event". |
type | The event type. Switch on this to dispatch to the right handler. |
created | ISO-8601 UTC timestamp of when the event was recorded. |
data | Event-specific payload. See the API reference for the full schema. |
Endpoints
A webhook endpoint is a URL on your server, an event-type subscription list, and a signing secret. Each endpoint is scoped to a single organization. You can have multiple endpoints — for example, separate URLs for staging and production, or per-team fan-out.
Create an endpoint
From the dashboard, open Webhooks → Endpoints, click New endpoint, paste your HTTPS URL, name it, and check the events you want to receive. Copy the signing_secret from the success screen — it is shown once. Store it as TRIPWIRE_WEBHOOK_SECRET (or similar) on the receiving service.
To create an endpoint via the API, POST /v1/organizations/{organizationId}/webhooks/endpoints with an sk_* key that has the webhooks:manage scope:
curl https://api.tripwirejs.com/v1/organizations/org_.../webhooks/endpoints \
-X POST \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Production receiver",
"url": "https://app.example.com/webhooks/tripwire",
"event_types": [
"session.fingerprint.calculated",
"session.result.persisted"
]
}'
The 201 response includes the signing_secret exactly once. Persist it before discarding the response.
URL requirements
Tripwire validates every endpoint URL on create, on update, and again before each delivery attempt:
- HTTPS only in production. Plain HTTP is allowed only against
localhost / 127.0.0.1 in non-production environments.
- No credentials in the URL —
https://user:pass@host/path is rejected.
- Public IPs only. Tripwire resolves the hostname and refuses to deliver to private, reserved, or link-local ranges. This protects your infrastructure from SSRF-style misuse if a webhook secret leaks.
- Reachable. DNS must resolve and the request must complete within the per-attempt timeout (10 seconds).
If your receiver sits behind a private network, terminate TLS on a public ingress and proxy internally.
Event subscriptions
A subscription is the (endpoint, event_type) pair. When you PATCH .../endpoints/{endpointId} with a new event_types array, Tripwire replaces the endpoint’s subscriptions — pass the full desired set, not just additions.
curl https://api.tripwirejs.com/v1/organizations/org_.../webhooks/endpoints/we_... \
-X PATCH \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "event_types": ["session.result.persisted"] }'
Disable, re-enable, or delete
- Disable —
PATCH .../endpoints/{endpointId} with {"status": "disabled"}. New events are not queued for the endpoint; in-flight deliveries finish in skipped state.
- Re-enable —
PATCH .../endpoints/{endpointId} with {"status": "active"}. Past skipped deliveries are not replayed; only new events are delivered.
- Delete —
DELETE .../endpoints/{endpointId} soft-disables the endpoint. The endpoint and its event history remain visible for auditing.
Send a test event
POST .../endpoints/{endpointId}/test (or Send test event in the dashboard) enqueues a delivery with type: "webhook.test" and a tiny payload. The signature flow is identical to production events, so a passing test verifies your verification code as well as connectivity.
Authentication
Every Tripwire webhook is signed with HMAC-SHA256. Verify the signature before reading the body — without it, anyone who knows your URL can post arbitrary payloads.
What gets signed
For every request, Tripwire computes:
HMAC-SHA256(signing_secret, `${X-Tripwire-Timestamp}.${rawBody}`)
and sends the lowercase hex digest in X-Tripwire-Signature. The signing secret has the format whsec_<base64url> and is shown once when you create or rotate the endpoint.
The timestamp is the Unix epoch in seconds, as a string. Both headers are required; missing or malformed values must be rejected as 401.
Compute the HMAC over the raw request bytes, not over JSON.stringify(req.body). Most JSON parsers reorder keys and strip whitespace, which produces a different digest and a guaranteed signature mismatch. Capture the raw body before any middleware parses it.
Verify the signature
const crypto = require('crypto');
app.post('/webhooks/tripwire', (req, res) => {
const timestamp = req.headers['x-tripwire-timestamp'];
const signature = req.headers['x-tripwire-signature'];
const expected = crypto
.createHmac('sha256', process.env.TRIPWIRE_WEBHOOK_SECRET)
.update(`${timestamp}.${req.rawBody}`)
.digest('hex');
if (
typeof signature !== 'string'
|| signature.length !== expected.length
|| !crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process the event...
res.status(200).end();
});
Always compare with a constant-time helper (crypto.timingSafeEqual, hmac.compare_digest, hmac.Equal, Rack::Utils.secure_compare, hash_equals) — naive == leaks information about partial matches.
Replay protection
The timestamp is signed, so an attacker cannot reuse a captured request with a forged body. To also reject replays of the original request, reject anything whose timestamp is more than a few minutes old:
const skewSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(skewSeconds) || skewSeconds > 5 * 60) {
return res.status(401).json({ error: 'Stale webhook' });
}
Five minutes is a comfortable bound; tighten it if you have strict clock sync. Also dedupe by the envelope id to drop legitimate retries — see Idempotency.
Rotating secrets
POST .../endpoints/{endpointId}/rotations (or Rotate secret in the dashboard) returns a new signing_secret and immediately uses it for outgoing deliveries. Rotate when:
- a teammate with access to the secret leaves
- the secret may have been logged or committed
- on a regular schedule (annually is a reasonable default)
To rotate without dropping in-flight deliveries, do an overlap window:
- Add the new secret as a second verifier in your code, alongside the current one.
- Deploy. Both signatures now verify.
- Rotate via the API. Tripwire signs new requests with the new secret.
- After the longest possible retry window (≥ 10 minutes covers all delivery attempts), drop the old secret from your code and redeploy.
const secrets = [
process.env.TRIPWIRE_WEBHOOK_SECRET_NEW,
process.env.TRIPWIRE_WEBHOOK_SECRET_OLD,
].filter(Boolean);
const valid = secrets.some((secret) => {
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${req.rawBody}`)
.digest('hex');
return signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
});
Delivery
The request
Every webhook is an HTTP POST with a JSON body and these headers:
| Header | Description |
|---|
Content-Type | Always application/json. |
X-Tripwire-Event | The event ID — same as id in the envelope. Use this for idempotency. |
X-Tripwire-Event-Type | The event type, e.g. session.result.persisted. Lets you route without parsing the body. |
X-Tripwire-Timestamp | Unix timestamp in seconds, as a string. Part of the signature base string. |
X-Tripwire-Signature | HMAC-SHA256 hex digest. See Authentication. |
There is no fixed source IP range and no IP allowlist. Authenticate via the signature, not the network.
What counts as success
- 2xx — delivery succeeds. The endpoint will not be retried for this event.
- anything else, plus connection errors and timeouts — delivery fails and is retried (up to the limit below).
The per-attempt timeout is 10 seconds. If your handler takes longer, do the heavy work asynchronously: enqueue a job, then return 200 immediately.
The first 256 KB of the response body is captured for the delivery log. Larger responses are truncated; the log stores up to 4000 characters.
Retries
If a delivery is not 2xx, Tripwire retries up to 5 attempts total. The first retry waits at least one minute; subsequent retries use exponential backoff and run on a worker queue, so the actual delay can extend during high load. Treat all timing as a lower bound — design for “delivered eventually” rather than “delivered every minute on the dot.”
After the 5th failed attempt the delivery moves to terminal failed status and stops retrying. Re-deliver manually from the dashboard or by sending a fresh test event.
Delivery status values you’ll see in the dashboard and the API:
| Status | Meaning |
|---|
pending | Queued, not yet attempted (or scheduled retry). |
delivering | A worker is currently sending the request. |
succeeded | The endpoint returned 2xx. |
failed | All retries exhausted with non-2xx / errors. |
skipped | The endpoint was disabled or deleted before delivery. |
Idempotency
You may receive the same event ID more than once — retries after a transient 5xx, network blips, or worker reschedules. Make your handler idempotent on X-Tripwire-Event:
async function handleEvent(envelope) {
const eventId = envelope.id;
const inserted = await db.query(
`INSERT INTO webhook_events (id) VALUES ($1) ON CONFLICT DO NOTHING`,
[eventId],
);
if (inserted.rowCount === 0) return; // already processed
// ... do the work
}
Tripwire also dedupes internally per (event_id, endpoint_id), so the same event never produces parallel deliveries to one endpoint. But your handler must still tolerate retries of the same event ID over time.
For Gate’s gate.session.approved event, also key on data.gate_session_id — the Gate webhook quickstart shows the pattern.
Event log
Every event is recorded with its delivery attempts. View it in two places:
- Dashboard — Webhooks → Events lists recent events with delivery status, attempt count, response code, and the captured response body. Filter by endpoint or event type to debug a specific receiver.
- API —
GET /v1/organizations/{organizationId}/events returns event resources with nested delivery attempts. Retrieve one event with GET /v1/organizations/{organizationId}/events/{eventId}. Both require webhooks:read.
curl 'https://api.tripwirejs.com/v1/organizations/org_.../events?endpoint_id=we_...&type=session.result.persisted&limit=50' \
-H "Authorization: Bearer sk_live_..."
Supported list filters:
| Query param | Description |
|---|
endpoint_id | Only return events that produced a delivery for this endpoint. |
type | Only return events of this type. Includes webhook.test for test sends. |
limit | 1–200, default 50. |
Each event resource looks like:
{
"object": "event",
"id": "wevt_0123456789abcdef0123456789abcdef",
"type": "session.result.persisted",
"subject": { "type": "session", "id": "sid_..." },
"data": { ... },
"webhook_deliveries": [
{
"object": "webhook_delivery",
"id": "wdlv_0123456789abcdef0123456789abcdef",
"event_id": "wevt_...",
"endpoint_id": "we_...",
"event_type": "session.result.persisted",
"status": "succeeded",
"attempts": 1,
"response_status": 200,
"response_body": "ok",
"error": null,
"created_at": "2026-03-24T20:00:05.000Z",
"updated_at": "2026-03-24T20:00:06.000Z"
}
],
"created_at": "2026-03-24T20:00:05.000Z"
}
| Field | Description |
|---|
id | Event ID — also sent as X-Tripwire-Event and as the envelope id. |
type | The event type. |
subject.type / subject.id | The resource the event is about (e.g. session / sid_...). |
data | The same data object delivered in the webhook envelope. |
webhook_deliveries[] | One entry per endpoint subscribed at the time of fan-out. Each entry tracks attempts, the latest response_status and response_body (truncated to 4000 chars), the most recent error, and a status of pending, delivering, succeeded, failed, or skipped. |
created_at | When the event was recorded. |
Local development
Tripwire refuses to deliver to private IPs in production. To develop against your laptop, use a tunnel (ngrok, cloudflared, etc.) and register the tunnel’s public URL as the endpoint. The signature flow and payloads are identical to production.