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/ingestAuthorization: Bearer wt15ls_…Content-Type: application/jsonThe 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.
Request
Section titled “Request”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 |
Fields
Section titled “Fields”| 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.
Response
Section titled “Response”{ "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.
Limits and errors
Section titled “Limits and errors”| 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.
AWS Lambda
Section titled “AWS Lambda”No dependencies — Node.js 20+ has fetch built in.
// 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 };};From CloudWatch Logs
Section titled “From CloudWatch Logs”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.
Related
Section titled “Related”- Log sources — creating a source and managing its token.
- Search & explore — finding the lines once they are in.