Native ingest — POST /messages
The native endpoint is the simplest way to push activity into ActivityLog. One JSON object per message, one HTTPS POST. No SDK required.
This page covers single-message ingest, batch, idempotency, tags, metadata, and the duration-message pattern for tracking long-running operations.
Which ingest path should I use?
Are you instrumenting your own application code?
├── Yes ──> Native ingest (this doc)
└── No
├── You have OpenTelemetry instrumented already?
│ └── Yes ──> OTLP — see 51-ingest-otlp.md
│
├── You're using the Langfuse SDK?
│ └── Yes ──> Langfuse-compat — see 52-ingest-langfuse.md
│
└── You want a third-party system to push to you
(ADO Service Hooks, Azure Event Grid, GitHub)?
└── Yes ──> Webhook receiver — see 53-ingest-webhooks.md
Endpoint
POST https://api.activitylog.com/api/v1/messages
Authorization: Bearer al_REPLACE_WITH_YOUR_TOKEN
Content-Type: application/json
Minimal message
curl -X POST https://api.activitylog.com/api/v1/messages \
-H "Authorization: Bearer al_REPLACE_WITH_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"stream": "events",
"type": "user.signed_in"
}'
Response (HTTP 201):
{
"id": "01JT8KMBN4VK0000000000000",
"ingestedAt": "2026-05-20T14:21:03.123Z"
}
Only stream is required. id is server-assigned (ULID, sortable by ingest time).
Full message shape
{
"stream": "events",
"level": "info",
"type": "order.placed",
"body": { "orderId": "ord_99", "total": 49.99 },
"tags": ["prod", "checkout"],
"metadata": { "region": "us-east", "partner": "acme" },
"timestamp": "2026-05-20T14:21:03Z",
"sourceEventId": "evt_99_v1",
"isDuration": false
}
| Field | Type | Required | Notes |
|---|---|---|---|
stream |
string | yes | Logical channel; 1–80 chars. |
level |
string | no | debug / info / warn / error / fatal. Default info. |
type |
string | no | Dot-separated event type. Max 200 chars. |
body |
object | string | no | Object stored as JSON. Max 256 KB Pro / 16 KB Free. |
tags |
string[] | no | Free-form labels. Tier-capped (Free 8, Pro 64). |
metadata |
object | no | Key-value (both strings). Tier-capped. Keys: ^[a-z][a-z0-9._-]{0,79}$. |
timestamp |
RFC3339 datetime | no | Event time. Default = server time. |
sourceEventId |
string | no | Your own idempotency key. Max 200 chars. |
isDuration |
bool | no | Open a duration event (see Duration messages). |
Tags vs metadata
Both are filterable. The difference is shape and intent.
| Concept | Tags | Metadata |
|---|---|---|
| Shape | string array | key-value object |
| Filter syntax | ?tag=prod |
?meta.partner=acme |
| Use for | Free-form labels, environment markers, feature flags | Structured attributes you'll group/filter on |
| Hierarchical queries | No | Yes — ?meta.partner=acme&meta.region=us-east |
| Cap on Free | 8 per message | 8 pairs per message |
| Cap on Pro | 64 | 64 |
If you're not sure: use metadata for things you'll filter by, tags for things you'll eyeball.
Batch — POST /messages/batch
Send up to 100 messages in a single request. Each is processed independently — one failure does not abort the others.
curl -X POST https://api.activitylog.com/api/v1/messages/batch \
-H "Authorization: Bearer al_REPLACE_WITH_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{ "stream": "events", "type": "page.view", "metadata": { "url": "/" } },
{ "stream": "events", "type": "page.view", "metadata": { "url": "/pricing" } },
{ "stream": "events", "type": "form.submit", "body": { "form": "contact" } }
]
}'
Response (HTTP 207 Multi-Status):
{
"items": [
{ "status": 201, "id": "01JT...", "ingestedAt": "2026-05-20T14:21:03.123Z" },
{ "status": 201, "id": "01JT...", "ingestedAt": "2026-05-20T14:21:03.124Z" },
{ "status": 400, "error": "stream is required" }
]
}
items[i] corresponds to messages[i] of the request.
Use batch when you can — it reduces HTTP overhead substantially. Batch sizes:
| Tier | Max items per batch |
|---|---|
| Free | 10 |
| Pro | 500 |
| Enterprise | per contract |
Idempotency — sourceEventId
Set sourceEventId to your own unique key per logical event. If the same (systemId, sourceEventId) arrives twice, the second one is silently collapsed to the first (no duplicate Message row).
This makes retry loops safe by construction. A typical key:
"sourceEventId": "order.placed.ord_99.v1"
Pattern: {source}.{event_type}.{external_id} — matches the convention used across our pre-built integrations (see ../../../ActivityLog-Integrations/Common-Architecture.md § 8).
Collision returns 409 Conflict with the existing message id in the body — your retry logic should treat this as success.
Metadata
Metadata is the structured-attribute bag. Use it for anything you'll filter by later.
{
"stream": "events",
"type": "order.placed",
"metadata": {
"partner": "acme",
"region": "us-east",
"fulfilment.tier": "standard",
"user.id": "usr_42"
}
}
Rules:
- Keys match
^[a-z][a-z0-9._-]{0,79}$(lowercase, dot/dash/underscore separated). Bad keys → 400. - Values are strings, up to 500 chars. Non-string values (numbers, booleans) are coerced to string on ingest.
- Dot-separated keys are query-friendly:
?meta.fulfilment.tier=standard. - Cap per message: 8 (Free) / 64 (Pro) / configurable (Enterprise).
Querying metadata back
curl "https://api.activitylog.com/api/v1/messages?meta.partner=acme&meta.region=us-east" \
-H "Authorization: Bearer YOUR_JWT_HERE"
Multiple meta.* parameters are ANDed. Full query reference in 60-query-api.md.
Duration messages
For operations with a clear start and end (a background job, an HTTP request you're tracing yourself, a multi-step workflow), open a duration message at the start and close it at the end. ActivityLog records the elapsed time and surfaces it on the message detail.
Open
RESULT=$(curl -sX POST https://api.activitylog.com/api/v1/messages \
-H "Authorization: Bearer al_REPLACE_WITH_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"stream": "jobs",
"type": "report.generate",
"isDuration": true,
"body": { "report": "monthly-sales" }
}')
JOB_ID=$(echo "$RESULT" | jq -r .id)
Close (success)
curl -X POST "https://api.activitylog.com/api/v1/messages/$JOB_ID/complete" \
-H "Authorization: Bearer al_REPLACE_WITH_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"status": "completed",
"body": { "rows": 12400, "outputUrl": "s3://reports/..." }
}'
Close (failure)
curl -X POST "https://api.activitylog.com/api/v1/messages/$JOB_ID/complete" \
-H "Authorization: Bearer al_REPLACE_WITH_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"status": "failed",
"body": { "error": "DB connection refused", "stage": "fetch" }
}'
The closed message has durationStatus: "completed" (or "failed") and durationMs: 4821. Querying-by-duration is a 60-query-api.md topic.
Duration tips
- Open as soon as the work starts. Latency between open and close is what we record — don't pre-build the body if it'll delay the open.
- One close per open. Re-closing a duration message is a no-op (the first close wins).
- If your service dies before closing, the message stays
durationStatus: "open"indefinitely. Periodically reap orphaned durations with your own cleanup logic — we don't auto-close them. (Auto-reap is on the roadmap as a per-token setting.)
Headers worth knowing
| Header | Direction | Notes |
|---|---|---|
Authorization: Bearer al_… |
Request | Required on every call. |
Content-Type: application/json |
Request | Required. |
X-AL-Source-Event-Id: … |
Request | Alternative to body's sourceEventId — useful if you're using a thin HTTP wrapper. |
X-RateLimit-Remaining: N |
Response | How much burst you have left. Log this. |
X-AL-Sampled: true |
Response | (Free tier only) Above burst the API drops 1-in-N; this header signals which messages were kept under sampling. |
Retry-After: N |
Response (429) | Seconds to wait before retrying. Honor it. |
Error reference
| HTTP | Slug | Meaning |
|---|---|---|
| 400 | (varies) | Validation failed — read the error field. |
| 401 | invalid-token |
Token wrong, revoked, or for a different environment. |
| 403 | forbidden |
Token can't write here (rare — usually means the tenant is suspended). |
| 409 | source-event-id-exists |
This idempotency key already lives on another message. Likely a successful retry. |
| 413 | payload-too-large |
Body or batch exceeds the tier cap. |
| 429 | rate-limit |
Slow down. Honor Retry-After. |
| 500 | internal |
Our problem. The X-Correlation-Id header is what to quote at support. |
| 503 | unavailable |
Backend store is down. Retry with exponential backoff. |
Best practices
- Always set
sourceEventId. Retries become safe and replay-able. - Batch where you can. A 50-item batch is one HTTPS round trip; the equivalent 50 single POSTs is 50. Network beats CPU here every time.
- Don't blow the body cap. If your event payload is regularly > 256 KB, you probably want OTLP traces with body-store overflow (Enterprise feature). Native ingest hard-caps at the tier inline cap.
timestampis for event time, not ingest time. Set it to when the event happened on your side; we'll separately recordingestedAt. Useful for replaying historical data without time-warping.- Pick a tagging convention early.
prod/staging/devfor environment;v1/v2for version;red-team/blue-teamfor ownership. Document it in your team's README.
What's next
| Goal | Doc |
|---|---|
| Query the messages back | 60-query-api.md |
| Switch to OTLP from your existing OTel collector | 51-ingest-otlp.md |
| Mint a second token with different retention | 20-setup-system-tokens.md |
| See the precise endpoint contract | ../API-Reference.md |