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.
  • timestamp is for event time, not ingest time. Set it to when the event happened on your side; we'll separately record ingestedAt. Useful for replaying historical data without time-warping.
  • Pick a tagging convention early. prod / staging / dev for environment; v1 / v2 for version; red-team / blue-team for 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