Skip to content

Log ingest API

The endpoint a log source posts to. It is deliberately undemanding: one header, a JSON array, done.

POST https://api.watchtower15.com/api/log-sources/ingest
Authorization: Bearer wt15ls_…
Content-Type: application/json

The source’s page in the dashboard shows this URL on app.watchtower15.com. Behind a firewall that allows outbound traffic by IP address, use api.watchtower15.com and allow 3.11.48.155, 18.171.1.44 and 18.135.154.177 on port 443: those addresses serve api.watchtower15.com only. See the Restricted egress tab under Install for the list and when it changes.

The body may be any of the following — the format is detected from the content, not from Content-Type:

Form Example
JSON array [{"message":"a"},{"message":"b"}]
Envelope {"lines":[{"message":"a"}]}
Single object {"message":"a"}
NDJSON one JSON object per line
gzip any of the above, gzipped
Field Required Notes
message yes The log line. A line without one is skipped.
logged_at no RFC 3339 (2026-10-06T14:03:22.481+01:00) or a Unix epoch in seconds or milliseconds, as a number or a string. A time without a timezone is read as UTC, and fractions of a second are kept. A missing or unreadable time is the time the line was received. Timestamps more than 5 minutes in the future are clamped to now.
log_level no One of debug, info, notice, warning, error, critical, alert, emergency. Anything else is dropped rather than stored.
stream_name no The stream this line belongs to. Defaults to the source’s slug.
raw_line no The original unparsed line, if you have one.

log_source is accepted as an alias for stream_name, so an agent-shaped payload works unchanged.

{ "status": "success", "data": { "accepted": true, "batch_id": "…", "lines": 2 } }

accepted means the batch is durably stored. Lines usually become searchable within a second, but the two are not the same guarantee.

Code Meaning
200 Accepted. lines reports how many were usable.
400 The body could not be parsed, or contained no readable lines.
403 The token is missing, unknown, revoked, or the source is paused or deleted. All of these return the same response deliberately.
413 Too many lines (max 1,000 per request), or the body is too large (5 MB on the wire, 20 MB decompressed).
429 Rate limited. Retry with back-off. Also returned while your organisation’s log ingestion limit is reached for the billing period: data.code is log_volume_limit_reached, data.resumes_at says when ingestion restarts, in epoch milliseconds, and Retry-After is at most an hour. Lines sent until then are discarded.
503 We could not store the batch. Retry later.

Rate limiting is keyed to the token, not your IP, so a fleet of workers behind one NAT address shares one generous budget rather than competing for a per-IP one.

Retries are safe from our side but are not deduplicated: a retried batch is stored and billed twice. Only retry when you did not get a 2xx.

No dependencies — Node.js 20+ has fetch built in.

index.mjs
// Set WT15_INGEST_URL and WT15_INGEST_TOKEN as environment variables. Keep the
// token in Secrets Manager or an encrypted env var, never in source control.
const ENDPOINT = process.env.WT15_INGEST_URL;
const TOKEN = process.env.WT15_INGEST_TOKEN;
export const handler = async (event) => {
const lines = (event.Records ?? [event]).map((record) => ({
logged_at: new Date().toISOString(),
log_level: 'info',
stream_name: 'orders-lambda',
message: typeof record === 'string' ? record : JSON.stringify(record)
}));
const response = await fetch(ENDPOINT, {
method: 'POST',
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ lines })
});
if (!response.ok) {
// 403 → the token was rotated or revoked. 429 → back off and retry.
throw new Error(`WatchTower15 ingest failed: ${response.status} ${await response.text()}`);
}
return { ingested: lines.length };
};

To mirror an existing log group, put a subscription filter on it pointing at a Lambda like the one above. The event arrives base64-encoded and gzipped, so decode it first:

import { gunzipSync } from 'node:zlib';
const payload = JSON.parse(gunzipSync(Buffer.from(event.awslogs.data, 'base64')).toString());
const lines = payload.logEvents.map((e) => ({
logged_at: new Date(e.timestamp).toISOString(),
stream_name: payload.logGroup.replace(/^\//, '').replace(/\//g, '.'),
message: e.message
}));

Batches over 1,000 lines need splitting across requests.