> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Generic HTTPS

> Send signed, gzipped batches to a receiver you write: the request contract, ownership verification, a Node receiver, and a curl you can replay

A `http` drain POSTs batches to any HTTPS endpoint you control. It is the only destination that is **signed**, and the only one that proves you own the endpoint by making your receiver echo a token back. Use it for a receiver of your own, for a queue in front of a warehouse, or for a vendor that takes arbitrary JSON.

**Before you begin**

* An HTTPS endpoint that is publicly resolvable. Private, loopback, link-local, and cloud-metadata addresses are refused when the drain is created; plain `http://` is refused too.
* A credential with `logs:manage` on the organization, or an organization admin role.

## What to enter

| Field          | Flag              | Value                                                                           |
| -------------- | ----------------- | ------------------------------------------------------------------------------- |
| Name           | `--name`          | 1–64 characters, unique in the organization                                     |
| Type           | `--type http`     |                                                                                 |
| Endpoint       | `--endpoint`      | The full URL batches are POSTed to                                              |
| Format         | `--format`        | `json` (default) or `ndjson`                                                    |
| Headers        | `--header <name>` | Up to 10. The CLI prompts for each value; nothing is read from the command line |
| Signing secret | —                 | Never an input. The platform mints it and prints it once                        |

```bash theme={null}
lua drains create \
  --name "Log receiver" \
  --type http \
  --endpoint https://logs.example.com/lua \
  --environments production,sandbox \
  --min-severity info
```

```text Output theme={null}
✔ Created drain drn_9f31a7c04b2e615d8a03cc71 (pending_verification)

Signing secret (shown once — store it now):

  whsec_7Qb3xA1mR8pK0sVnT4uZ2yL6cE9dW5gH

Next: make your endpoint echo X-Lua-Verify, then run
  lua drains verify drn_9f31a7c04b2e615d8a03cc71
```

The secret is not stored anywhere you can read it again. Lose it and you rotate it: see [Verify signatures](/drains/verify-signatures#rotate-a-secret).

## The request contract

Every delivery is a single `POST`.

| Header             | Value                                                                                                                                                                  |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`     | `application/json`, or `application/x-ndjson` when `--format ndjson`                                                                                                   |
| `Content-Encoding` | `gzip` — bodies are always compressed                                                                                                                                  |
| `X-Lua-Signature`  | `t=<unix seconds>,v1=<hex>` — HMAC-SHA256 over the **uncompressed** body. A second `v1=` appears during a [rotation window](/drains/verify-signatures#rotate-a-secret) |
| `X-Lua-Batch-Id`   | The batch ULID. A request identifier for support and tracing — **not** a deduplication key                                                                             |
| `X-Lua-Schema`     | `https://docs.heylua.ai/schemas/logs/1.0`                                                                                                                              |
| `X-Lua-Drain-Id`   | The drain id, so a shared receiver can route without parsing the body                                                                                                  |
| `X-Lua-Verify`     | Present **only** on an ownership-verification batch. The plaintext token to echo back                                                                                  |
| `User-Agent`       | `LuaDrain/1.0 (+https://docs.heylua.ai/drains)`                                                                                                                        |
| *(yours)*          | Any headers you configured, with their values resolved at send time                                                                                                    |

Your receiver must:

* **Answer `2xx` within 10 seconds.** The request is abandoned at 10 seconds and the batch is retried.
* **Deduplicate on `records[].id`.** Delivery is at-least-once; the same record can arrive twice, in different batches, under different batch ids.
* **Not redirect.** A `3xx` is treated as a failure and is never followed. Publish the final URL.
* Return `429` or `503` with a `Retry-After` header to ask for a pause. The value is honoured, clamped at one hour.

`408`, `429`, `500`, `502`, `503`, and `504` are retried. Every other `4xx` is terminal: the batch is dropped and counted, because a `400` means your receiver will reject it again on the next attempt too.

### Caps

| Cap                           | Value   |
| ----------------------------- | ------- |
| Records per batch             | 500     |
| Uncompressed bytes per batch  | 1 MiB   |
| Request timeout               | 10 s    |
| Concurrent requests per drain | 4       |
| Retry horizon                 | 6 hours |

## How ownership verification works

A new drain starts in `pending_verification` and buffers rather than delivers. Verification proves you control the endpoint before any real log data goes to it.

<Steps>
  <Step title="Ask for a verification batch">
    ```bash theme={null}
    lua drains verify drn_9f31a7c04b2e615d8a03cc71
    ```

    The platform mints a single-use token, valid for 10 minutes, and sends one batch containing one `lua.drain.test` record with the token in `X-Lua-Verify`.
  </Step>

  <Step title="Echo the token">
    Your receiver returns `2xx` **and** copies the value straight back as its own `X-Lua-Verify` response header.

    ```js theme={null}
    const token = req.get('X-Lua-Verify');
    if (token) res.set('X-Lua-Verify', token);
    ```

    If the response has no such header, one follow-up `GET {origin}/.well-known/lua-drain-verify` is made and a body whose trimmed content equals the token is accepted instead. Use that when a proxy strips unknown response headers.
  </Step>

  <Step title="Confirm">
    ```bash theme={null}
    lua drains status drn_9f31a7c04b2e615d8a03cc71
    ```

    On success the drain moves to `healthy`, records `verifiedAt`, and the buffered backlog starts draining — anything queued in the last six hours still gets delivered. On failure the drain stays in `pending_verification` and `verification.outcome` says which check failed: `no_2xx`, `token_not_echoed`, `ssrf_refused`, `timeout`, or `error`.
  </Step>
</Steps>

Verification is limited to 5 attempts per drain per hour. Changing the endpoint or the type later sends the drain back to `pending_verification`, and you verify the new endpoint the same way.

## A receiver

A complete receiver: it verifies the signature over the uncompressed body, tolerates a rotation window, echoes the verification token, deduplicates on record id, and answers well inside 10 seconds.

```js receiver.mjs theme={null}
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { gunzipSync } from 'node:zlib';

const SECRETS = [process.env.LUA_DRAIN_SECRET, process.env.LUA_DRAIN_SECRET_PREVIOUS].filter(Boolean);
const TOLERANCE_SECONDS = 300;
const seen = new Map(); // record id -> expiry. Use Redis with a TTL in production.

function verifySignature(header, rawBody) {
  if (!header) return false;
  let t = null;
  const candidates = [];
  for (const piece of header.split(',')) {
    const eq = piece.indexOf('=');
    if (eq < 0) return false;
    const key = piece.slice(0, eq).trim();
    const value = piece.slice(eq + 1).trim();
    if (key === 'v1') candidates.push(value);
    else if (key === 't') t = Number(value);
  }
  if (!Number.isFinite(t) || candidates.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) return false;

  // The signed string is `${t}.${body}` over the UNCOMPRESSED body.
  const base = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]);
  let ok = false;
  for (const secret of SECRETS) {
    const expected = createHmac('sha256', secret).update(base).digest();
    for (const candidate of candidates) {
      const given = Buffer.from(candidate, 'hex');
      // Compare every candidate: no early return, so timing says nothing.
      if (given.length === expected.length && timingSafeEqual(given, expected)) ok = true;
    }
  }
  return ok;
}

const app = express();

// inflate:false keeps the compressed bytes so we can gunzip them ourselves and
// sign over exactly what the platform signed.
app.post('/lua', express.raw({ type: '*/*', limit: '4mb', inflate: false }), (req, res) => {
  const rawBody =
    req.get('content-encoding') === 'gzip' ? gunzipSync(req.body) : req.body;

  if (!verifySignature(req.get('X-Lua-Signature'), rawBody)) {
    return res.status(401).json({ error: 'bad signature' });
  }

  // Ownership verification: echo the token straight back.
  const verifyToken = req.get('X-Lua-Verify');
  if (verifyToken) res.set('X-Lua-Verify', verifyToken);

  const batch = JSON.parse(rawBody.toString('utf8'));
  const now = Date.now();
  for (const [id, expiry] of seen) if (expiry < now) seen.delete(id);

  for (const record of batch.records) {
    if (seen.has(record.id)) continue;          // at-least-once: this is the dedup key
    seen.set(record.id, now + 6 * 60 * 60 * 1000); // one retry horizon
    store(batch.resource, record);
  }

  res.status(204).end();                         // answer fast; do the work after
});

function store(resource, record) {
  console.log(
    resource['lua.agent.id'],
    record.eventName,
    record.severityText,
    record.body ?? '',
  );
}

app.listen(8080);
```

<Warning>
  Do the slow part — writing to a warehouse, fanning out to a queue — **after** you answer, or behind a bounded buffer. A receiver that takes longer than 10 seconds is retried, which makes it slower still.
</Warning>

### NDJSON

With `--format ndjson` the body is one JSON object per line and `Content-Type` is `application/x-ndjson`. Each line repeats `schemaUrl`, `batchId`, and `resource` so a line is self-contained wherever it ends up:

```text theme={null}
{"schemaUrl":"https://docs.heylua.ai/schemas/logs/1.0","batchId":"01JBW…","resource":{…},"record":{…}}
{"schemaUrl":"https://docs.heylua.ai/schemas/logs/1.0","batchId":"01JBW…","resource":{…},"record":{…}}
```

The signature still covers the whole uncompressed body, all lines together — not line by line.

## Test it with curl

Replay a realistic batch at your receiver without waiting for traffic. Substitute your endpoint and the secret you saved.

```bash theme={null}
ENDPOINT='https://logs.example.com/lua'
SECRET='whsec_7Qb3xA1mR8pK0sVnT4uZ2yL6cE9dW5gH'

BODY='{"schemaUrl":"https://docs.heylua.ai/schemas/logs/1.0","batchId":"01JBW8N2Q4Z6H8Y0KX3R9T5V7M","resource":{"service.name":"support-agent","service.namespace":"org_4f2c9a1b","service.instance.id":"agent_1789214224176_2vta8rnyn","deployment.environment.name":"production","gen_ai.agent.id":"agent_1789214224176_2vta8rnyn","lua.org.id":"org_4f2c9a1b","lua.agent.id":"agent_1789214224176_2vta8rnyn"},"records":[{"id":"1789217997644-jtoulxxxq","timestamp":"2026-09-12T12:59:57.644Z","observedTimestamp":"2026-09-12T12:59:59.102Z","eventName":"lua.skill.error","severityNumber":17,"severityText":"ERROR","body":"Ticket lookup failed: upstream timeout","attributes":{"lua.log.type":"log","lua.source":"skill","lua.primitive.name":"tickets","gen_ai.tool.name":"lookup_tickets","gen_ai.operation.name":"execute_tool","exception.type":"Error","exception.message":"upstream timeout"}}]}'

T=$(date -u +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')

printf '%s' "$BODY" | gzip | curl -sS -i -X POST "$ENDPOINT" \
  -H 'Content-Type: application/json' \
  -H 'Content-Encoding: gzip' \
  -H "X-Lua-Signature: t=$T,v1=$SIG" \
  -H 'X-Lua-Batch-Id: 01JBW8N2Q4Z6H8Y0KX3R9T5V7M' \
  -H 'X-Lua-Schema: https://docs.heylua.ai/schemas/logs/1.0' \
  -H 'X-Lua-Drain-Id: drn_9f31a7c04b2e615d8a03cc71' \
  -H 'User-Agent: LuaDrain/1.0 (+https://docs.heylua.ai/drains)' \
  --data-binary @-
```

A receiver that is ready answers `204` and logs one line. Add `-H "X-Lua-Verify: probe-token"` and the response should carry `X-Lua-Verify: probe-token` back.

Once the drain exists, the same round trip through the real delivery path is one command:

```bash theme={null}
lua drains test drn_9f31a7c04b2e615d8a03cc71
```

```text Output theme={null}
✔ 200 in 142 ms — "ok"
  batch 01JBWA6R1KD3P8N0SY4V2XQZ7T · 1 record · lua.drain.test
```

It exits `1` on anything other than a `2xx`, so it works as a smoke test in CI.

## If it isn't working

<AccordionGroup>
  <Accordion title="The drain stays in pending_verification">
    `lua drains status <id> --json` carries `verification.outcome`. `token_not_echoed` means the request reached you and returned `2xx` but neither the response header nor `/.well-known/lua-drain-verify` carried the token — most often a proxy stripping unknown response headers. `ssrf_refused` means the endpoint resolved to a private or link-local address. `no_2xx` carries the status code your endpoint actually returned.
  </Accordion>

  <Accordion title="Every delivery fails with a 401 from my own receiver">
    You are almost certainly signing over the compressed bytes. The signature covers the body **after** gunzip. If you use a framework that transparently inflates request bodies, turn that off and gunzip explicitly — otherwise you cannot know which bytes you have.
  </Accordion>

  <Accordion title="I get duplicates">
    Expected, and not a bug: delivery is at-least-once. Deduplicate on `records[].id` and keep the key for at least six hours, the retry horizon. Deduplicating on `X-Lua-Batch-Id` does not work — a retried record is re-sent under a new batch id with whatever else is due at that moment.
  </Accordion>

  <Accordion title="The backlog is growing">
    `lua drains status` prints `BACKLOG`. A drain that is up but slow builds a queue; a drain that is down buffers for six hours and then starts dropping the oldest records, and tells you it did with a `lua.drain.dropped` record on recovery. See [Delivery guarantees](/drains/delivery-guarantees#backpressure-and-drops).
  </Accordion>
</AccordionGroup>

## Next steps

<Columns cols={2}>
  <Card title="Verify signatures" href="/drains/verify-signatures">Verifiers in Node, Python, and Go, and how rotation works.</Card>
  <Card title="Event schema" href="/drains/event-schema">Every field your receiver will see.</Card>
  <Card title="Delivery guarantees" href="/drains/delivery-guarantees">Retry, drops, heartbeat, and health states.</Card>
  <Card title="lua drains" href="/reference/cli/drains">Every verb and flag.</Card>
</Columns>
