Ingest one or many evidence items from an external source in a single call. Built for webhook senders: PostHog destinations, Zapier, Intercom, or anything that can POST JSON.
Accepts two shapes against the same path: simple { productId, source, content, ... } for one item, and batch { productId, source, items: [...] } for up to 50. Validation is fail-fast and all-or-nothing: the first invalid item rejects the whole request with 400 and nothing is inserted, and the error names the path that failed (e.g. items[2].content). A body that is not a JSON object, a null batch member, or a non-string sourceUrl, stage or externalId is a 400 rather than a 500. productId must be a UUID: a malformed one is a 400, not the 404 it used to surface as. tags stays deliberately lenient — a non-array becomes [] and non-string members are dropped, since refusing them would break senders that work today. The billing check counts the entire batch, so a request that would cross the evidence_items quota is refused whole rather than partially written. Rows land with status='active', triage_status='inbox', origin='integration' (POST /api/v1/evidence leaves origin unset), and created_by=auth.userId. Fire-and-forget side effects: an activity-log row, a Slack 'evidence_created' notification carrying the first 5 items, and the PostHog 'webhook_evidence_ingested' event. Rate-limited per workspace on the evidenceIngest bucket, charged per item rather than per request: a 50-item batch spends 50 of the 1000/hour budget and a single-item post spends 1, so batching is neither penalised nor a way around the ceiling. The budget is spent after the payload is parsed and before anything is written, and the sliding window admits the request that crosses the ceiling, so a workspace can overshoot by at most one batch. Scope: use evidence:ingest, a scope that opens this endpoint and nothing else, so a credential stored in a third-party tool cannot mutate intents, statuses, share tokens, or verification if that tool leaks it. A write-scoped key is still accepted, because senders configured before the narrow scope existed hold one; prefer evidence:ingest for anything new. Only owners, admins, and editors can mint it — ingest is a write, so it is not available to viewers or commenters. Idempotency: items carrying externalId are deduplicated on (workspace, source, externalId), both within a single batch (first occurrence wins) and against everything the workspace already stored for that source. duplicates counts what was skipped. A request in which every item was a redelivery returns 200 with created 0 and writes nothing — not a 4xx, which would make a well-behaved sender retry forever. Skipped items cost no evidence_items quota, though they do spend the rate-limit budget: the limiter protects the service from volume, the quota bills for stored rows. A unique index settles the case where two identical deliveries race past the lookup. If the duplicate lookup itself fails the request is refused with 500 rather than inserting, so a retry gets a clean second chance. POST /api/webhooks/ingest is a legacy alias that delegates to this handler with an identical contract, kept live for senders already configured against it.
Query parameters
Required ONLY for cookie-session auth (no Bearer key); ignored for API-key auth.
Request body
Response
Evidence items created; body carries the created count and the created rows.