# 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

```bash
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):

```json
{
  "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

```json
{
  "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](#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.

```bash
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):

```json
{
  "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](../../../ActivityLog-Integrations/Common-Architecture.md)).

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.

```json
{
  "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

```bash
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`](./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

```bash
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)

```bash
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)

```bash
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`](./60-query-api.md) |
| Switch to OTLP from your existing OTel collector | [`51-ingest-otlp.md`](./51-ingest-otlp.md) |
| Mint a second token with different retention | [`20-setup-system-tokens.md`](./20-setup-system-tokens.md) |
| See the precise endpoint contract | [`../API-Reference.md`](../API-Reference.md) |
