---
title: "Emit an app-lane business event into the project feed"
method: POST
path: "/projects/v1/{project_id}/events"
tags: ["Projects"]
---

# Emit an app-lane business event into the project feed

`POST /projects/v1/{project_id}/events`

## Request

- Base URL: `https://api.run402.com`
- URL: `https://api.run402.com/projects/v1/{project_id}/events`
- Auth: HTTP bearer

The write twin of the feed read above (app-events-emit-lane): deployed app code records ITS OWN business facts — "signature completed", "booking created" — as ordinary feed rows with `source: "app"`, `class: "app"`, read back by every existing and future feed consumer with zero extra work. service_key ONLY, own-project (a cross-project key gets 403, never a 404 existence oracle); principals, grant keys, and admin keys are rejected in v1 — nothing platform-side should fabricate app facts. NOT lifecycle-gated (data-plane classification): a frozen/dormant organization's app keeps serving, so its facts keep recording — gating the emit would punch silent holes in the feed exactly when the owner is debugging. `event_type` must be flat snake_case matching `^[a-z][a-z0-9_]{2,63}$` and must not name platform-registered vocabulary (400 `RESERVED_EVENT_TYPE` — e.g. `mailbox_suspended` is off-limits), so an app can never impersonate a platform event; every consumer discriminates app rows from platform rows structurally on `(source, event_type)`, not by naming convention. The response `next_actions[]` is ALWAYS the platform-synthesized `poll` entry, never app-supplied — an app payload that happens to contain a `next_actions`-shaped field is rendered as inert data, never as an instruction (agent-instruction-injection containment). Bounded by a per-tier `eventsPerDay` quota pooled across the organization's projects for the UTC day (prototype 1,000 / hobby 10,000 / team 100,000): once spent, 403 `QUOTA_EXCEEDED` with `details: {resource: "events_per_day", scope, used, limit}`. `idempotency_key` is durable resource identity (backs the `(source, source_event_id)` UNIQUE, namespaced per-project server-side), not transient header-based retry dedup — the same key always replays the ORIGINAL stored event with `deduplicated: true` instead of writing twice.

## Path parameters

- `project_id` string, required: Run402 project ID. Public IDs are not UUIDs.

## Request body

- object
  - `event_type` string, required: Flat snake_case, 3-64 chars. 400 `INVALID_EVENT_TYPE` if it doesn't match the grammar; 400 `RESERVED_EVENT_TYPE` if it names platform-registered vocabulary.
  - `payload` object: Compact fact: ids + verdict fields, never bodies or secret material. Bounded to 8 KiB; an oversize payload is truncated (`payload_truncated: true`, `dropped_keys[]`) rather than rejected.
  - `severity` 'info' | 'notice' | 'warn' | 'critical': event-attention-rules: the app's own judgement of how significant this is. `info` routine, `notice` unusual but not known to be wrong, `warn` probably wrong, `critical` wrong now. Omitted stores `info`; the platform never infers or raises it. Routing rules filter on it (`min_severity`). Any other value is 400 `VALIDATION_FAILED` before the quota is consulted.
  - `idempotency_key` string: Durable resource identity within this project (namespaced server-side as `<project_id>:<idempotency_key>` so two projects can both use e.g. `order_1` without colliding on the global UNIQUE). Omit to always write a fresh row; replaying the same key returns the ORIGINAL stored event with `deduplicated: true`.

## Response `200`

Idempotency-key replay — `deduplicated: true` and the ORIGINAL stored event (Faithful: no pretend re-insert).

- AppEventEmitResponse: app-events-emit-lane: the app-emitted event exactly as the feed will render it. A fresh emit returns this with HTTP 201; an idempotency_key replay returns the same shape with HTTP 200 and `deduplicated: true`, carrying the ORIGINAL stored event (Faithful — never a pretend re-insert).
  - `cursor` string, required: This event's own opaque cursor (`evc_...`). Pass it as `?cursor=` to resume the feed strictly after it.
  - `event_type` string, required: Echoes the request's event_type.
  - `class` 'app', required: Always `app` for app-lane events — never a member of the platform's mandatory-delivery classes.
  - `severity` 'info' | 'notice' | 'warn' | 'critical', required: The STORED severity: the request's `severity`, or `info` when it named none. On an idempotency_key replay, the original row's.
  - `source` 'app', required: Always `app`. Consumers discriminate app facts from platform facts on `(source, event_type)`.
  - `payload` object, required: The BOUNDED stored payload (8 KiB contract) — echoes the request's payload, truncated when oversize.
  - `payload_truncated` boolean: Present and `true` only when the stored payload was bounded to the 8 KiB cap; omitted otherwise.
  - `occurred_at` string, date-time, required: Server-stamped. A client-supplied timestamp is never accepted.
  - `deduplicated` boolean, required: True when `idempotency_key` already existed for this project — `payload`/`occurred_at` are the ORIGINAL stored row's, not a fresh write.
  - `next_actions` object[], required: Always the platform-synthesized `poll` entry for this project's feed, positioned just before this event — never app-supplied. An app payload cannot inject its own next_actions (agent-instruction-injection containment).
    - `type` string
    - `method` string
    - `path` string
    - `why` string

## Other responses

- `201`: Event recorded. The stored event exactly as the feed will render it, with a `poll` next_action positioned just before this row so the emitter's next read includes it.
- `400`: VALIDATION_FAILED (missing/malformed `event_type`, `payload`, or `idempotency_key`), INVALID_EVENT_TYPE (grammar), or RESERVED_EVENT_TYPE (names platform vocabulary)
- `401`: Missing or invalid authentication. Run402-originated JSON bodies use the canonical ErrorEnvelope.
- `403`: Not authorized for this project (FORBIDDEN — including a service_key presented for a different project), or QUOTA_EXCEEDED (`details: {resource: "events_per_day", scope, used, limit}`) once the organization's daily app-event allowance is spent

---

[API](https://skmtc.dev/run402/apis/run402-api.md) · [All operations](https://skmtc.dev/run402/apis/run402-api/llms.txt) · [OpenAPI document](https://skmtc.dev/run402/apis/run402-api/revisions/893607557f50?raw)
