logs:manage; the read routes need logs:read. The base URL, bearer authentication, and the error envelope are on the REST API overview; lua drains is the same surface from a terminal.
Log drains are switched on for a deployment as a whole, not per organization. While they are off, every drain route on this page answers
404 — the resource does not exist rather than being forbidden, which is why a 404 here never distinguishes a disabled feature from an unknown drain.Scopes
logs:manage is sensitive: a wildcard such as logs:* does not satisfy it, and a scoped key holds it only when it was granted by exact name. logs:read is an ordinary read scope — a key that already holds *:read has it and needs nothing re-minted.
A credential without the scope answers
403 with code: "INSUFFICIENT_SCOPE" and the requiredScope.
Every drain route resolves the drain by organization and id together, so a drain id from another organization answers 404 DRAIN_NOT_FOUND rather than 403. The two cases are deliberately indistinguishable.
The log export routes live on this same controller and share this error envelope, but take logs:read rather than logs:manage. The usage report is on the usage dashboard and takes usage:read.
Destination types
type accepts ten values, but a deployment opens them individually. A create naming one that is not open answers 422 DRAIN_TYPE_UNAVAILABLE, and the message names the live list:
type cannot be changed after creation: PATCH refuses it. Delete the drain and create a new one.
A test post proves the host answers and the credential works. Whether it also proves the destination is the vendor’s own intake depends on whether you could name the host at all — which is what
verification.ownership records, and what Protecting your destination reasons through.
Sources
Six
sources values are available from lua-cli 3.39.0: delivery, trigger-execution, job-execution, workflow-run, voice-call, and workflow-audit. On a deployment that predates them, a create or patch naming one answers 400 DRAIN_VALIDATION_FAILED with field: "sources".delivery, trigger-execution, job-execution, and workflow-run; voice-call and workflow-audit are selectable but not defaulted, and must be named in sources.
POST spreads the default set only when the body names no sources at all. PATCH writes selectors.sources only when the body names it — and an empty array is refused — so a drain that already exists is never re-derived from the defaults, and a source added to the default set is never granted to a drain a customer already configured. The one exception is execution, which was already a default: a drain that selects it receives four new lua.execution.* outcome names with no opt-in. That is additive under the schema policy.
includeContent is unrelated to the voice-call and workflow-audit opt-in. includeContentAck is required only for includeContent itself, and the content sources remain user_message and agent_response alone — a drain selecting only the new sources never needs an acknowledgement. includeContent still governs a handful of the new attributes (the full delivery recipient, trigger payload and response, a workflow event’s data, a recording URL, and the audit evidence group), which are withheld without it while the record itself still arrives.
Rate limits
The drain routes are limited per organization, not per credential — a drain is an organization resource, and every route carries the organization in its path:
Over either, the request answers
429 with code: "DRAIN_RATE_LIMITED" and a bare Retry-After header in seconds. Back off for what it says and retry.
Every response, throttled or not, also carries the bucket’s own budget headers, suffixed with the bucket that was evaluated — log-drains-read on a GET, log-drains-mutate on everything else:
Read the bare
Retry-After to back off and the suffixed X-RateLimit-Remaining-* to pace yourself; a client that looks for an unsuffixed X-RateLimit-Remaining will not find one.
The per-drain budgets
Two routes send bytes to a destination on demand, so each carries a second, narrower budget on top of the organization bucket — counted per drain, over a rolling hour:
Both refusals are a
429 carrying Retry-After in seconds and the same number as retryAfterS in the body, so a client can back off on either without parsing a header it may not be able to see:
The three
429s are three different codes, and they are worth telling apart. DRAIN_RATE_LIMITED is the organization bucket above — every caller in your organization shares it, and it refills within the minute. DRAIN_TEST_RATE_LIMITED and DRAIN_VERIFY_RATE_LIMITED are one drain’s own budget over an hour, and no other drain is affected. A client that maps every 429 onto “slow down and retry in a second” will spin for the better part of an hour on the last two. Read retryAfterS.retryAfterS is the real wait until the oldest attempt falls out of the window.
The log read routes are limited separately, and per credential.
Drains
GET /admin/orgs/:orgId/log-drains
Lists every drain in the organization, including health and quota. Soft-deleted drains are not returned. Response200 with { "drains": LogDrainRead[], "limits": { "maxDrains": 5 } }. limits.maxDrains is the organization’s ceiling — 5, or 10 on Enterprise — so a UI can say “3 of 5” without knowing the plan. Each drain carries its health and quota blocks.
headers[] carries each header’s name and last4 and never a value; last4 is an empty string when the stored value is shorter than 8 characters, rather than a shortened value. secret is never present.
A drain whose credential is embedded in its endpoint — Sumo Logic — carries endpointSecretLast4 beside secretLast4, and its endpoint is the prefix with the credential split off. An s3 drain carries its bucket block, with credentials.secretLast4 and no secretAccessKey, and a challenge whose tokenHash reads as an empty string.
POST /admin/orgs/:orgId/log-drains
Creates a drain. It starts inpending_verification and buffers rather than delivers until ownership is proved.
string
required
1–64 characters, unique in the organization.
string
required
http, otlp, datadog, betterstack, splunk, loki, axiom, newrelic, sumologic, or s3. Availability is per deployment — see Destination types.string
The destination URL. Required for
http, otlp, betterstack, splunk, loki, axiom, and sumologic; refused for datadog and newrelic, whose URLs are derived from site, and for s3, which derives its own from bucket. HTTPS only, and never a private, loopback, link-local, unspecified, or cloud-metadata address.string
datadog: one of datadoghq.com, datadoghq.eu, us3.datadoghq.com, us5.datadoghq.com, ap1.datadoghq.com, ddog-gov.com. newrelic: one of log-api.newrelic.com, log-api.eu.newrelic.com, log-api.jp.newrelic.com.object
Required for
s3, refused for every other type. See the bucket block.string
default:"json"
json or ndjson. Honoured for http; the other types fix their own encoding, and s3 is always ndjson.object[]
Up to 10
{ name, value } pairs. Write-only: values are stored encrypted and never returned. name matches ^[A-Za-z0-9-]{1,64}$.string
Write-only HMAC signing secret for an
http drain. Generated when omitted, and returned exactly once in this response.string[]
The sources to copy. Defaults to every source except the four that are off on a new drain:
user_message, agent_response, voice-call, and workflow-audit.string
default:"info"
debug, info, warn, or error.string | string[]
default:"*"
"*" for every agent in the organization, now and later, or a list of agent ids that must belong to this organization.string[]
default:"[\"production\"]"
Any of
production, sandbox.object
{ "debug": 0.1 }. Deterministic head sampling by execution, 0 < n <= 1. Only debug is sampleable.boolean
default:"false"
Turns on the
user_message and agent_response bodies.boolean
Required
true on the request that turns includeContent on — a POST creating a content-carrying drain, or a PATCH moving it from off to on. A PATCH that leaves the flag on as it was, or turns it off, needs no acknowledgement; turning it back on asks again. The acknowledgement is recorded on the drain as contentAck and is never returned as a bare boolean.string[]
Up to 32 attribute keys to drop before encoding, for example
["user.id"].object[]
Up to 20 organization masking rules,
{ id, pattern, flags? }. Each pattern is at most 256 characters and is compiled with RE2, so there are no backreferences, no lookahead, no lookbehind, no nested quantifiers, and no unbounded repetition of an alternation.400 DRAIN_VALIDATION_FAILED whose field and message name it, and no drain is created. A mis-nested field — selectors.redact instead of the top-level redact — fails the same way rather than being dropped.
The bucket block
Required fortype: "s3" and refused for every other type. See Object storage for the key layout, the policy, and the ownership challenge.
string
required
aws, r2, or gcs.string
required
3–63 characters, lower-case, no dots — a dotted name in a virtual-hosted URL breaks TLS verification.
string
required
1–32 characters. The vendor’s region code;
auto for R2.string
Up to 256 characters. No leading or trailing
/.string
The S3-compatible endpoint. Required for
r2 and gcs; refused for aws, which derives its own from the bucket and the region. The host must match that provider’s own shape, checked before any DNS lookup.object
Exactly one of the two shapes: a key pair, or a role to assume.
string
The SigV4 access key id — an identifier, not a secret, and returned on a read.
string
8–512 characters. Write-only: stored encrypted and returned by nothing. A read carries
bucket.credentials.secretLast4 instead.string
AWS only: assume this role instead of holding a key pair. Mutually exclusive with the key pair — sending both answers
422 DRAIN_VALIDATION_FAILED with field: "bucket.credentials".string
The
sts:ExternalId your role’s trust policy requires.PATCH, the whole bucket block is replaced, never merged. A half-patched destination — a new bucket with the old credentials — is a drain writing somewhere you did not ask for. Omit bucket to keep the current one.
Response
201 with the created drain. This is the only response that ever carries secret. Store it now: it cannot be read back, only rotated.
Errors
GET /admin/orgs/:orgId/log-drains/:drainId
Reads one drain, withhealth, quota, and the last verification outcome.
Response
200 with a LogDrainRead:
health.hourly is the same 24 hours as the *24h counters, broken into points to draw. There are always 24 of them, oldest first and zero-filled across quiet hours, so nothing has to handle a gap: h is the absolute UTC hour (floor(epochMilliseconds / 3600000)), and delivered, dropped, and rejected are that hour’s attempt counts. The window is the current, partial hour and the 23 before it, which is why the newest point’s numbers keep rising while you watch them.
verification.ownership
verifiedAt says when a drain last verified. verification.ownership says what that verification established, and it is the field to read before describing a drain as proven:
verified on a pinned preset means the bytes reached that vendor, not that the account at that vendor is yours. Protecting your destination has the reasoning and what to audit instead.
The key is absent on a drain verified before it existed, and an absent ownership reads as reachability-only — the weaker claim, which is the only safe reading of a check that ran before the distinction did. It fills in on the drain’s next successful verification. Treat ownership !== "verified" as the test, not ownership === "reachability-only".
The content acknowledgement
A drain withselectors.includeContent: true carries contentAck: who agreed to send end-user message content to this destination, and when.
The block is absent on a drain that carries no content, and absent on a content-carrying drain created before the record existed. It is never a bare
true: “somebody agreed” is the answer this field exists to improve on.
Errors
PATCH /admin/orgs/:orgId/log-drains/:drainId
Updates a drain. Accepts everyPOST field except type, which cannot be changed. Sending null for sampling, redact, or scrubRules clears it.
The body is closed. A key this page does not list is a 400 DRAIN_VALIDATION_FAILED whose message names it, so a typo or a mis-nested field fails instead of being dropped. In particular redact is a top-level field — {"redact": ["app.error_message"]} — not selectors.redact.
A PATCH that changes nothing is not a write. If every key you send already holds that value, the drain is returned with the same version and updatedAt, and no audit entry is recorded. version moves by one only when the stored document actually changed.
Changing endpoint moves the drain back to pending_verification: delivery stops until it is re-verified, and records keep buffering meanwhile.
Response
200 with the updated LogDrainRead.
Errors — as for POST, plus 404 DRAIN_NOT_FOUND.
DELETE /admin/orgs/:orgId/log-drains/:drainId
Soft-deletes the drain, purges its stored header values and signing secret, and drops whatever it had queued. The name becomes free for reuse. Response204, no body.
Errors
Operations
POST /admin/orgs/:orgId/log-drains/:drainId/test
Enqueues onelua.drain.test record and sends it through the real delivery path. The outcome lands on the drain’s deliveries.
Response
202 with { "batchId", "enqueuedAt" }.
Errors
POST /admin/orgs/:orgId/log-drains/:drainId/verify
Runs ownership verification. For anhttp drain a single-use token valid for 10 minutes is sent as X-Lua-Verify and must be echoed back; for the vendor types any 2xx from the intake is accepted.
On success the drain moves to healthy, verifiedAt is set, and the buffered backlog starts draining.
Response
202 with { "verificationId", "expiresAt" }. The outcome appears on the drain’s verification object and in its deliveries.
Errors
POST /admin/orgs/:orgId/log-drains/:drainId/challenge/confirm
Settles an object-storage drain’s ownership challenge, and applies totype: "s3" alone.
POST …/verify writes {prefix}/_lua-verify-<token> into the bucket. If the drain’s credentials can read it back, verification finishes on its own and this route is never needed. With the write-only grant we recommend they cannot, so the caller lists their own prefix, opens the object, and echoes the token here. Finding it in the bucket is the proof: Lua stores only a hash and returns the token from no route, because a control plane that could hand it back would let anyone holding logs:manage confirm a bucket they cannot even read.
string
required
8–256 characters. The token from the
_lua-verify-<token> object in your bucket.200 with { "verifiedAt": "2026-09-22T14:11:07.244Z" }. The drain moves to healthy and its buffered backlog starts draining.
Idempotent: confirming an already-settled challenge answers 200 with the existing verifiedAt rather than an error.
Errors
A challenge expires 10 minutes after
verify mints it. Run verify again for a new one. The token is compared in constant time, and the three refusals are deliberately indistinguishable in everything but their message.
POST /admin/orgs/:orgId/log-drains/:drainId/pause
Stops delivery. Records keep buffering, within the six-hour horizon and the buffer size.string
Recorded with the pause.
200 with the updated LogDrainRead, state: "paused" and health.pauseReason: "manual".
POST /admin/orgs/:orgId/log-drains/:drainId/resume
Resumes delivery. The drain returns todegraded, never straight to healthy — it has to prove itself again.
Response
200 with the updated LogDrainRead.
POST /admin/orgs/:orgId/log-drains/:drainId/rotate-secret
Mints a new HMAC signing secret and opens a 24-hour rotation window in which every delivery carries twov1 signatures. Pass ?finalize=true to close an open window instead.
Only a signed drain has a secret to rotate, and http is the only signed type. Every other destination — otlp, s3, and the seven vendor presets — delivers unsigned and authenticates with the credential you configured, so this route answers 400 on one of them rather than minting a secret that nothing would sign with.
boolean
default:"false"
Closes the current rotation window early. No new secret is minted.
finalize: 200 with { "secret", "rotation": { "startedAt", "expiresAt" } }. The secret is shown once.
With finalize=true: 200 with the updated LogDrainRead.
Errors
GET /admin/orgs/:orgId/log-drains/:drainId/deliveries
Recent delivery attempts, newest first. Kept for 7 days.number
default:"20"
At most 100.
string
The
nextCursor from a previous page.string
One of
batch, test, verify, heartbeat, dropped, or truncated. Anything else is a 400.200 with { "deliveries": LogDrainDeliveryRead[], "nextCursor": string | null }.
200 with partialSuccess.rejectedLogRecords, so the batch counts as delivered (ok: true) and is marked errorClass: "partial". It is never retried — the OTLP specification asks for that, and re-sending would duplicate the records the collector did accept. A partialSuccess that names zero refused records is a warning, not a rejection, and produces a plain ok row with no errorClass at all.
Errors
The
404 is not an existence oracle. An id that never existed, an id belonging to another organization, a soft-deleted drain, and a name typed where an id goes all produce the same response, so a drain id still cannot be probed across tenants.
rejectedRecords
rejectedRecords is how many records the destination refused out of the recordCount this attempt carried. A count, never the records — the refused records are yours to chase at your own destination.
Three rules decide whether the key is there at all:
- It is present only when it is greater than zero. A
partial_successthat names zero refused records is a warning, not a rejection, and produces a plainokrow with noerrorClassand norejectedRecords. - It is written on a partial accept, and only there — a
2xx(ok: true) that still refused N records, carryingerrorClass: "partial"beside the count, never retried. That is the OTLPpartialSuccesscase and, once enabled on your deployment, a generic HTTPS receiver’srejectedreply — which also fills inrejectedIdsbelow. - A terminal refusal deliberately does not set it. That row already says
ok: falsewith its ownerrorClass, and itsrecordCountis the whole batch; writing the batch size here too would make one column mean two things. The drain’s own counters still count a terminal batch underrejectedCount24h— that side is unchanged.
rejectedCount24h on GET …/log-drains/:drainId, and the rejected figure in the hourly series, remain the figures to total for a period. rejectedRecords is what attributes a partial accept to the attempt that caused it.
A row with
errorClass: "partial" and no rejectedRecords is ambiguous on older rows. Rows written before this field started being populated carry the class without the count — that is age, not a bug, and it resolves itself: delivery rows are kept for 7 days, so no such row survives beyond that.billableRecords is deliveredRecords − rejectedRecords per batch.
rejectedIds
Which records the destination refused, when it named them. A generic HTTPS receiver can answer a 2xx with {"rejected":[{"id","reason"}]}, and that list — intersected with the batch actually sent, so an id the receiver invented never reaches the row — becomes rejectedIds:
reason is the receiver’s own text, scrubbed and capped at 200 characters, and is absent when it sent none. At most 50 entries are kept — rejectedRecords stays the authoritative count, and this is the evidence. The key is present only beside errorClass: "partial", and never on a plain 2xx. An OTLP partial_success reports a count and no ids, so an OTLP row carries rejectedRecords alone.
replayOf
The batch id this attempt is a replay of. Present only when the whole batch is one replay’s records, and it is how you find the new attempt that carried them:
responseExcerpt is at most 1 KB of the destination’s response, scrubbed. errorClass is one of the delivery error classes.
POST /admin/orgs/:orgId/log-drains/:drainId/deliveries/:batchId/replay
Puts one batch’s records back in the queue after a terminal refusal. They are delivered again in a new batch with a new id, and the attempt that carries them is markedreplayOf: "<batchId>".
A 4xx that is not 408 or 429 is terminal — the batch is settled as dropped and never re-sent — so when the cause has been fixed this is the way to close the gap. Needs logs:manage.
Response
202 with { "replayOf", "records", "enqueuedAt" }. records is how many rows went back to pending. Delivery is asynchronous; the response says the records are queued, not that they arrived.
Every replay writes a
logs.drain.replayed entry to the organization’s audit log, naming the batch and the record count.
Audit trail
Every mutation on this page writes one entry to the organization’s audit log:logs.drain.created, logs.drain.updated, logs.drain.deleted, logs.drain.paused, logs.drain.resumed, logs.drain.verified, logs.drain.secret_rotated, logs.drain.test_sent, logs.drain.replayed, and logs.drain.quota_paused when the quota ladder pauses a drain by itself.
The entry is deliberately narrower than the drain it describes, because an audit log is read by more people than a drain configuration is:
Reducing the endpoint matters more than it looks: several vendors’ intakes carry a token in the query string, so the whole query is dropped rather than inspected.
name, type, site, format, state, version, and the selectors are recorded as they are.
Reading logs
These routes read the platform’s own copy of the same records. They needlogs:read.
GET /developer/agents/:agentId/logs
One agent’s execution logs, newest first.string
A log source, or
all. This is the parameter lua logs --type sets.string
Inclusive lower bound. ISO 8601, or a relative form such as
15m, 2h, 7d.string
Inclusive upper bound. Same formats.
string
production or sandbox.string
The
nextCursor from a previous page. Cannot be combined with page.number
default:"20"
Clamped to 100.
number
default:"1"
Deprecated. Use
cursor.200 with { "logs", "nextCursor", "pagination" }. pagination is kept for one release. pagination.totalCountExact is a boolean: above 10,000 matches totalCount is capped at 10000 and totalCountExact is false, so page through with cursor rather than trusting totalPages. hasNextPage is reliable either way.
A request that passes page explicitly also carries a Deprecation header and a Link: <…?cursor=…>; rel="next" pointing at the cursor form; the implicit first page does not.
GET /admin/orgs/:orgId/logs
Every agent in the organization, with the samelogSource, since, until, environment, cursor, and limit parameters. GET /admin/orgs/:orgId/logs/download exports the same selection.
These routes are rate limited to 120 requests per minute per credential.
Scope deprecation
The log read routes moved tologs:read. For two releases the scope each route used before is still accepted, so nothing breaks while keys are re-minted:
A request authorized by a route’s previous scope still succeeds and carries three response headers.
Warning names the scope that authorized that request, so a per-skill route answers automations:read and an organization route analytics:read:
Deprecation: true in your clients and re-mint those keys with logs:read before the date in the Sunset header. After it, only logs:read is accepted.
The
Sunset value above is the one in force as this is written, and is subject to change until the release is announced. Read the date from the header your own calls receive rather than pinning this literal.See also
- Ship logs to your stack — what a drain is and what it selects
- Log export — the same records for a window that has already passed
- Object storage — the
bucketblock and the challengechallenge/confirmsettles - Protecting your destination — the endpoint refusal rules, the scrubber, and what verification proves
- Usage and plan quotas — the usage route, and what
rejectedRecordsdoes to a billable total - Event schema — the shape of what a drain delivers
- Delivery records — the
deliverysource, its statuses and its masked recipient - Workflow audit records — the
workflow-auditsource, and the SIEM webhook migration - Delivery guarantees — retry, drops, and the health states behind
state lua drains— the same operations from a terminal- REST API overview — base URL, authentication, and the error envelope

