# Stream logs to an HTTPS endpoint

An HTTPS endpoint log stream posts the requests of a gateway environment to a URL you own, in batches, about 30 seconds after each request ends. Each batch is signed so your receiver can check that it came from Faustine.

## Before you begin

- An endpoint that accepts `POST` over `https://` on a public address. Faustine does not send to private addresses and does not follow redirects.
- The endpoint answers with a `2xx` status within 15 seconds. Any other answer counts as a failure and the batch is retried.

## Add the stream

1. In the portal, go to [**Agent Gateway**](https://auth.faustine.ai/go?to=%2Fintegrations%2Fagent-gateway) and select your gateway.
2. Select the **Logs** tab, then **Log settings**.
3. Under **Log streams**, select **Add a stream**.
4. Under **Destination**, select **HTTPS endpoint**.
5. Fill in the fields in the table.
6. Select **Send test event**. Faustine posts one sample request marked `test: true` and shows the status your endpoint answered.
7. Select **Continue**.
8. Under **What to send**, choose **All requests** or **Errors and denials**, and turn on **Discovery calls** or **Payloads** if you want them. Select **Continue**.
9. Under **Review and create**, enter a **Name** and select **Create stream**.
10. On the stream's page, copy the **Signing secret** and store it where your receiver can read it, then select **I saved it**.

| Field | Value |
| --- | --- |
| **URL** | The address of your endpoint. Put credentials in a header, not in the URL. |
| **Body** | **NDJSON**, one request per line, or **JSON array**, the batch as an array. |
| **Compress with gzip** | Sends the body with `Content-Encoding: gzip`. Turn it on only if your receiver decompresses. Off by default. |
| **Headers** | Headers sent with every batch, such as `Authorization`. Optional, up to 10. The values are stored encrypted. |

> **Warning:** The signing secret is shown once. If you lose it, select **Roll signing secret** under **More actions** on the stream's page. Deliveries are signed with the new secret immediately, and your receiver rejects them until it has it.

## What your endpoint receives

Each batch is one `POST` of up to 500 requests and 4 MB.

| Header | Value |
| --- | --- |
| `Content-Type` | `application/x-ndjson` or `application/json`, by the **Body** field. |
| `X-Faustine-Delivery` | An ID for the batch. |
| `X-Faustine-Signature` | `t=<unix seconds>,v1=<hex>`. |
| `User-Agent` | `Faustine-Log-Streams/1` |

Each request is one JSON document:

```json
{
  "schema": "faustine.gateway.request/v1",
  "id": "req_…",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "startedAt": "2026-11-11T14:03:07.120Z",
  "endedAt": "2026-11-11T14:03:07.532Z",
  "durationMs": 412,
  "gateway": { "id": "…", "slug": "crm", "name": "CRM" },
  "env": { "id": "…", "slug": "production", "name": "Production" },
  "status": "ok",
  "channel": "mcp",
  "kind": "operation",
  "tool": "crm.deals.get",
  "opKey": "crm.deals.get",
  "actor": { "kind": "key", "id": "…", "name": "Nightly sync" },
  "client": { "hint": "claude-code", "name": "claude-code", "version": "2.1.0" },
  "calls": [{ "id": "…", "opKey": "crm.deals.get", "status": "ok", "upstreamStatus": 200, "latencyMs": 405 }],
  "attempts": [{ "id": "…", "method": "GET", "url": "https://api.example.com/deals/42", "status": 200, "durationMs": 388 }]
}
```

The example leaves out the fields that are `null`. For every field, see [Log streams](https://docs.faustine.ai/agent-gateway/log-streams/).

Delivery is at least once. A batch can arrive twice after a failure, so deduplicate by the `id` of each request.

## Verify the signature

`v1` is the HMAC-SHA256 of `<t>.<body>` with the stream's signing secret, in hexadecimal. `<body>` is the body before gzip, so decompress first if **Compress with gzip** is on. Reject a signature whose `t` is more than a few minutes old.

```js filename="verify.mjs"
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(',').map((part) => part.split('=')));
  if (!t || !v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  if (expected.length !== v1.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
```

Pass the body exactly as received, before any JSON parsing, and the value of the `X-Faustine-Signature` header.

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| The form shows "Use an https:// URL" | The URL starts with `http://`. | Serve the endpoint over TLS. |
| The test shows "The URL redirects" | The endpoint answers with a `3xx` status. | Enter the final URL. |
| The form shows that a header "is set by the gateway" | The header is one Faustine sets itself, such as `Content-Type` or `X-Faustine-Signature`. | Remove it from **Headers**. |
| The signature never matches | The receiver hashes a parsed or re-serialized body, or the compressed bytes. | Hash the raw body, after decompressing it. |

## Next steps

- [Log streams](https://docs.faustine.ai/agent-gateway/log-streams/): retries, the failing state and what happens after 24 hours.
- [Redact sensitive fields](https://docs.faustine.ai/agent-gateway/redact-sensitive-fields/): decide what a stream can carry.
- [Stream logs to an OpenTelemetry collector](https://docs.faustine.ai/technologies/opentelemetry/): receive traces in place of JSON documents.
