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 many messages in a single request (the per-tier maximum is in the table below). 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": [
{ "index": 0, "status": 201, "id": "01JT...", "ingestedAt": "2026-05-20T14:21:03.123Z" },
{ "index": 1, "status": 200, "duplicate": true, "id": "01JS..." },
{ "index": 2, "status": 400, "error": "validation-failed", "message": "stream is required" }
],
"successCount": 2,
"failureCount": 1
}
items[i] corresponds to messages[i] of the request (index says which). A message whose sourceEventId was already stored comes back as status: 200, duplicate: true with the existing id and counts as a success. Messages past your rate-limit budget come back as status: 429; a body over the cap as status: 413.
Use batch when you can — it reduces HTTP overhead substantially. Batch sizes:
| Tier | Max items per batch |
|---|---|
| Free | 10 |
| Pro | 500 |
| Enterprise | per contract (up to 5,000) |
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).
On POST /messages a collision returns 409 Conflict (duplicate-source-event); your retry logic should treat this as success. In a batch the duplicate is reported per item as status: 200, duplicate: true with the existing id.
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.)
What the API adds to your activity
When you send a JSON-object body, the API adds a few derived fields to it before storing it, so the report pages and your own activity/search queries can group on them. It only adds a field when the block it reads is present, and it never overwrites a value you set yourself: send the field and yours is kept.
| Field added | Added when the body has | Value |
|---|---|---|
change.state |
change.kind = pr.merged |
approved when pull_request.approved is true or any pull_request.reviewers[].vote is 5 or more; otherwise unapproved |
change.state |
change.kind = push with main or master in push.branches (with or without refs/heads/) |
direct_push, unless push.pr_completion is true (the push is the merge commit behind a completed pull request), in which case nothing is added |
sharing.exposure |
a sharing object |
anonymous when sharing.link_type is anonymous; else external when sharing.shared_with_type is Guest or External, or actor.is_external is true; else internal |
sharing.shared_with_domain |
sharing.shared_with containing @ |
the lower-cased domain; for a SharePoint guest address (name_partner.com#EXT#@yourtenant.onmicrosoft.com) the guest's own domain (partner.com) |
actor.origin |
actor.is_external (true / false) |
external or internal. This is separate from actor.kind (user / system / app), which the API never changes |
sensitive |
a top-level string prompt |
{ "category": "secret:aws-key", "categories": [...], "count": n } when the prompt contains a secret or personal data; nothing when it is clean |
The prompt scanner looks for Anthropic, OpenAI, AWS and GitHub keys, ActivityLog tokens, private-key blocks, passwords and connection strings, JWTs, email addresses, card numbers and phone numbers. It records categories only, never the matched text. Categories are secret:anthropic-key, secret:aws-key, secret:github-token, secret:activitylog-token, secret:private-key, secret:connection-string, secret:openai-key, secret:jwt, pii:email, pii:card and pii:phone; category is the most serious one found.
Where this runs:
| Ingest path | Report fields (change.state, sharing.*, actor.origin) |
Prompt scan (sensitive) |
|---|---|---|
POST /messages, POST /messages/batch |
yes | yes |
| Webhooks, single JSON object (Azure DevOps, GitHub, raw) | yes, after the Azure DevOps change fields are added (71-integrations-azure-devops.md) |
no |
| Webhooks, Event Grid arrays | no | no |
| OTLP | no | yes, for Claude Code user_prompt activity (51-ingest-otlp.md) |
| Langfuse-compatible | no | no |
The added fields count toward the body size cap, and are encrypted at rest with the rest of the body where your tenant has encryption on.
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 | duplicate-source-event |
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 |