evidence

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.

post/api/v1/evidence/ingest

Query parameters

workspace_idstring

Required ONLY for cookie-session auth (no Bearer key); ignored for API-key auth.

Request body

productIdstring required

Product the evidence lands under. Must belong to the workspace (else 404).

sourcestring required

Non-empty label for where the evidence came from, e.g. 'posthog', 'zapier', 'intercom'. Trimmed, then applied to every row in the request. Required here, unlike POST /api/v1/evidence where it is optional.

contentstring

Evidence text for the simple single-item shape. Required unless items is given. Max 10000 characters.

itemsobject[]

Batch shape: up to 50 objects, each accepting content (required) plus the same optional type, severity, sentiment, sourceUrl, tags, and stage fields. Required unless content is given; must be a non-empty array.

typestring

One of friction | quote | observation | metric | request. Defaults to 'observation' when omitted, unlike POST /api/v1/evidence where type is required.

severitystring

low | medium | high | critical. 400 if invalid.

sentimentstring

positive | negative | neutral | mixed. 400 if invalid.

sourceUrlstring

Link back to the originating ticket, survey response, or issue; stored as null if omitted.

tagsstring[]

Tag array; non-string members are dropped. Defaults to [].

externalIdstring

The sending system's own id for this event (PostHog: {event.uuid}). Supplying it makes redelivery a no-op: the same externalId under the same workspace and source is stored once, and a retry returns 200 with created 0 and duplicates 1 instead of a second row. Max 255 chars, trimmed. Omit it and the item inserts unconditionally, as before.

stagestring

Free-text journey stage; stored as null if omitted.

Response

Evidence items created; body carries the created count and the created rows.

object required

Changes