> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nephia.cc/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive tweet, post, video, Bluesky post, Hacker News item, article, and listing events from your keywords.

Give a [keyword](/keywords) a `webhookUrl`, on `POST /v1/keywords` or later with `PATCH /v1/keywords/{id}`. When one of its Sources catches a match, Nephia POSTs an event payload to your endpoint.

`webhookUrl` is **optional**. A keyword without one polls, bills and records its
matches exactly the same; it simply pushes nothing. Read those matches with
`GET /v1/keywords/{id}/results` or `GET /v1/mentions?keyword={id}`, both free.

<h2 id="event-types">
  Event types
</h2>

| `type` | Source | Description |
| - | - | - |
| `tweet.created` | X | New tweet matched the keyword's terms |
| `post.created` | Reddit | New post matched the terms or the subreddit |
| `reddit_comment.created` | Reddit | New comment in a watched subreddit, see [Reddit](/sources/reddit#comments) |
| `video.created` | Youtube | New video matched the terms or the channel |
| `bluesky_post.created` | Bluesky | New post matched the terms or the author feed |
| `hn_item.created` | Hacker News | New story or comment matched the terms |
| `article.created` | RSS | New entry appeared in the watched feed or news search |
| `answer.created` | AI answers | First answer from an engine on this prompt |
| `answer.changed` | AI answers | The answer moved — see [AI answers](/sources/ai-answers#changes) |
| `term.cited` | AI answers | A tracked brand entered the answer's citations |
| `term.uncited` | AI answers | A tracked brand left them |
| `listing.created` | Vinted | New listing matched the criteria |
| `listing.price_changed` | Vinted | Price changed on a tracked listing |
| `listing.delisted` | Vinted | Listing removed or no longer matches |

## Payload envelope

Every event uses the same shape. Unused fields are `null`.

`keywordId` is the keyword the match belongs to, and `source` is the Source that
caught it (`x`, `reddit`, `ai_answers`, ...). Route on those two and on `type`.

`ai` is the [agent step](/ai)'s reading of the event. It is `null` unless the
keyword has an `aiStep`, see [Agent step](#agent-step) below.

`analysis` is the only **optional** key in the body: it is present when
something read the item, and absent otherwise — see
[The `analysis` key](#analysis) below.

### Two dates, and which is which

`occurredAt` is when **Nephia collected** the item — the instant the tick that
caught it ran. `publishedAt` is when the **platform** says the item was
published.

They are usually minutes apart and sometimes years. A keyword polling every 15
minutes that meets a two-year-old Reddit thread on today's page delivers it with
today's `occurredAt` and a 2024 `publishedAt`.

Use `occurredAt` to order or de-duplicate a stream you are syncing, and to
filter with `since` and `until` — those read `occurredAt`, so nothing you have
already been delivered can arrive behind you. Use `publishedAt` to answer "when
was this said": grouping mentions by day, charting them over time, deciding
whether something is fresh.

`publishedAt` is `null` when the source gave no real date. Two cases: an AI
answer has no publication date at all (it is generated the moment we ask, so its
`occurredAt` *is* its date), and a YouTube video caught through search reports a
relative age — "4 years ago" — which we will not turn into a timestamp we cannot
stand behind. Fall back to `occurredAt` when you need a date for every item.

```json theme={null}
{
  "id": "event-uuid",
  "type": "tweet.created",
  "keywordId": "keyword-uuid",
  "source": "x",
  "listing": null,
  "tweet": { "...": "canonical tweet object" },
  "post": null,
  "video": null,
  "blueskyPost": null,
  "hnItem": null,
  "article": null,
  "aiAnswer": null,
  "previousPrice": null,
  "occurredAt": "2026-07-07T12:00:00.000Z",
  "publishedAt": "2026-07-07T11:58:12.000Z",
  "ai": null
}
```

<h3 id="analysis">
  The `analysis` key
</h3>

A keyword with analysis turned on (`sentimentEnabled`) delivers one extra key,
after `ai`:

```json theme={null}
{
  "...": "the envelope above",
  "ai": null,
  "analysis": { "sentiment": "negative", "intent": "complaint" }
}
```

| Field | Values |
| - | - |
| `sentiment` | `positive`, `neutral`, `negative`, `question`, `mixed`, or `null` |
| `intent` | `purchase_intent`, `comparison`, `question`, `complaint`, `praise`, `other`, or `null` |

Both are read on **one** pass, so turning analysis on costs one charge and
answers both.

`null` inside the object means *not read* — never "we looked and found
nothing". That verdict has its own value in each vocabulary: `neutral` for an
item that takes no side, `other` for an item with no recognisable intent.

**The key is absent, not null, on an event nothing classified.** Read it as
`event.analysis?.intent` rather than branching on the key's existence for
anything else. Every other key in the body is always present, and this one is
the exception on purpose: an object of two nulls on every delivery would say
nothing.

The same object comes back from
[`GET /v1/keywords/{id}/events`](#replay), so a replay and a delivery agree field
for field.

### X `tweet.created`

```json theme={null}
{
  "id": "evt_01k2def",
  "type": "tweet.created",
  "keywordId": "9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f",
  "source": "x",
  "listing": null,
  "tweet": {
    "id": "1890123456789012345",
    "text": "Just shipped the new release.",
    "authorId": "44196397",
    "createdAt": "2026-07-07T12:00:00.000Z"
  },
  "post": null,
  "video": null,
  "blueskyPost": null,
  "hnItem": null,
  "article": null,
  "aiAnswer": null,
  "previousPrice": null,
  "occurredAt": "2026-07-07T12:00:00.000Z",
  "ai": null
}
```

### Vinted `listing.price_changed`

```json theme={null}
{
  "id": "evt_01k2abc",
  "type": "listing.price_changed",
  "keywordId": "9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f",
  "source": "vinted",
  "listing": {
    "id": "41234567",
    "source": "vinted",
    "market": "fr",
    "title": "Nike Air Max 90",
    "price": 42.0,
    "currency": "EUR"
  },
  "tweet": null,
  "post": null,
  "video": null,
  "blueskyPost": null,
  "hnItem": null,
  "article": null,
  "aiAnswer": null,
  "previousPrice": 45.0,
  "occurredAt": "2026-07-07T12:00:00.000Z",
  "ai": null
}
```

### Reddit `post.created`

```json theme={null}
{
  "id": "evt_01k2ghi",
  "type": "post.created",
  "keywordId": "9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f",
  "source": "reddit",
  "listing": null,
  "tweet": null,
  "post": {
    "id": "abc123",
    "title": "Best webhook patterns for 2026",
    "url": "https://reddit.com/r/webdev/comments/abc123",
    "subreddit": "webdev",
    "author": { "username": "devops_daily" },
    "createdAt": "2026-07-07T12:00:00.000Z"
  },
  "video": null,
  "blueskyPost": null,
  "hnItem": null,
  "article": null,
  "aiAnswer": null,
  "previousPrice": null,
  "occurredAt": "2026-07-07T12:00:00.000Z",
  "ai": null
}
```

### Reddit `reddit_comment.created`

A comment travels in the **same `post` object** a submission does, told apart by `kind`. `title` is the parent submission's title, `selftext` is what the comment said, and `id` carries Reddit's `t1_` prefix.

```json theme={null}
{
  "id": "evt_01k2ghj",
  "type": "reddit_comment.created",
  "keywordId": "9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f",
  "source": "reddit",
  "listing": null,
  "tweet": null,
  "post": {
    "kind": "comment",
    "id": "t1_p7ezoxa",
    "fullname": "t1_p7ezoxa",
    "parentPostId": "abc123",
    "title": "Best webhook patterns for 2026",
    "selftext": "We moved off polling last year and never looked back.",
    "url": "https://www.reddit.com/r/webdev/comments/abc123/best_webhook_patterns_for_2026/p7ezoxa/",
    "permalink": "/r/webdev/comments/abc123/best_webhook_patterns_for_2026/p7ezoxa/",
    "subreddit": "webdev",
    "subredditPrefixed": "r/webdev",
    "score": 12,
    "numComments": 0,
    "author": { "username": "devops_daily" },
    "createdAt": "2026-07-07T12:04:00.000Z"
  },
  "video": null,
  "blueskyPost": null,
  "hnItem": null,
  "article": null,
  "aiAnswer": null,
  "previousPrice": null,
  "occurredAt": "2026-07-07T12:04:00.000Z",
  "ai": null
}
```

A consumer subscribed to `post.created` never receives one of these: a comment has no submission's shape to offer it. See [Reddit](/sources/reddit#comments).

### Youtube `video.created`

```json theme={null}
{
  "id": "evt_01k2jkl",
  "type": "video.created",
  "keywordId": "9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f",
  "source": "youtube",
  "listing": null,
  "tweet": null,
  "post": null,
  "video": {
    "id": "dQw4w9WgXcQ",
    "title": "Build a video monitor in 10 minutes",
    "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
    "channel": { "id": "UCabc", "handle": "TraversyMedia", "title": "Traversy Media" },
    "publishedAt": "2026-07-07T12:00:00.000Z"
  },
  "blueskyPost": null,
  "hnItem": null,
  "article": null,
  "aiAnswer": null,
  "previousPrice": null,
  "occurredAt": "2026-07-07T12:00:00.000Z",
  "ai": null
}
```

### Bluesky `bluesky_post.created`

```json theme={null}
{
  "id": "evt_01k2abc",
  "type": "bluesky_post.created",
  "keywordId": "9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f",
  "source": "bluesky",
  "listing": null,
  "tweet": null,
  "post": null,
  "video": null,
  "blueskyPost": {
    "id": "3lkexample001",
    "uri": "at://did:plc:aliceexample00000000000/app.bsky.feed.post/3lkexample001",
    "cid": "bafyreiexample001",
    "url": "https://bsky.app/profile/alice.bsky.social/post/3lkexample001",
    "text": "Hello with images",
    "createdAt": "2026-08-17T10:00:00.000Z",
    "langs": ["en"],
    "author": {
      "did": "did:plc:aliceexample00000000000",
      "handle": "alice.bsky.social",
      "displayName": "Alice Example",
      "avatarUrl": "https://cdn.bsky.app/img/avatar/plain/did:plc:aliceexample00000000000/example@jpeg"
    },
    "metrics": {
      "likes": 42,
      "reposts": 5,
      "replies": 3,
      "quotes": 1
    },
    "embed": {
      "kind": "images",
      "images": [
        {
          "url": "https://cdn.bsky.app/img/feed_fullsize/plain/did:plc:aliceexample00000000000/example@jpeg",
          "thumbUrl": "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:aliceexample00000000000/example@jpeg",
          "alt": "Example image"
        }
      ],
      "external": null
    }
  },
  "hnItem": null,
  "article": null,
  "aiAnswer": null,
  "previousPrice": null,
  "occurredAt": "2026-08-17T10:01:00.000Z"
}
```

See [Bluesky](/sources/bluesky).

### Hacker News `hn_item.created`

```json theme={null}
{
  "id": "evt_01k2mno",
  "type": "hn_item.created",
  "keywordId": "9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f",
  "source": "hackernews",
  "listing": null,
  "tweet": null,
  "post": null,
  "video": null,
  "blueskyPost": null,
  "hnItem": {
    "id": "49330781",
    "type": "story",
    "title": "A Preview of DuckDB v2.0",
    "url": "https://duckdb.org/2026/08/17/duckdb-20-highlights",
    "text": null,
    "author": "ibotty",
    "points": 428,
    "numComments": 68,
    "createdAt": "2026-08-17T13:46:27.000Z",
    "storyId": "49330781",
    "parentId": null,
    "hnUrl": "https://news.ycombinator.com/item?id=49330781"
  },
  "article": null,
  "aiAnswer": null,
  "previousPrice": null,
  "occurredAt": "2026-08-17T13:47:00.000Z"
}
```

On a comment, `title` is the **parent story's** title and `url` is `null` — `hnUrl` is where the comment is read. See [Hacker News](/sources/hackernews).

### RSS `article.created`

```json theme={null}
{
  "id": "evt_01k2stu",
  "type": "article.created",
  "keywordId": "9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f",
  "source": "rss",
  "listing": null,
  "tweet": null,
  "post": null,
  "video": null,
  "blueskyPost": null,
  "hnItem": null,
  "article": {
    "id": "https://blog.rust-lang.org/2026/08/04/enabling-polonius-alpha-on-nightly/",
    "url": "https://blog.rust-lang.org/2026/08/04/enabling-polonius-alpha-on-nightly/",
    "title": "Enabling the next iteration of the borrow checker on nightly",
    "summary": "TL;DR We are enabling the next iteration of the borrow checker…",
    "publishedAt": "2026-08-04T00:00:00.000Z",
    "updatedAt": "2026-08-04T00:00:00.000Z",
    "author": "The Rust Project",
    "feed": { "url": "https://blog.rust-lang.org/feed.xml", "title": "Rust Blog" },
    "source": "blog.rust-lang.org",
    "imageUrl": null,
    "categories": []
  },
  "aiAnswer": null,
  "previousPrice": null,
  "occurredAt": "2026-08-04T00:05:00.000Z"
}
```

`summary` is text, never HTML. See [RSS & News](/sources/rss).

### AI answers `answer.changed`

```json theme={null}
{
  "id": "evt_01k2ai1",
  "type": "answer.changed",
  "keywordId": "9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f",
  "source": "ai_answers",
  "listing": null,
  "tweet": null,
  "post": null,
  "video": null,
  "blueskyPost": null,
  "hnItem": null,
  "article": null,
  "aiAnswer": {
    "id": "run_01k2ai9",
    "source": "ai_answers",
    "engine": "chatgpt",
    "model": null,
    "prompt": "What are the best social listening tools for a small startup?",
    "country": "US",
    "answerText": "For a small team I would shortlist…",
    "truncated": false,
    "searched": true,
    "citations": [
      { "url": "https://g2.com/categories/social-listening", "domain": "g2.com", "title": "Best Social Listening Software" }
    ],
    "mentions": [
      { "term": "Acme", "mentioned": false, "cited": false, "count": 0 },
      { "term": "Competitor One", "mentioned": true, "cited": true, "count": 3 }
    ],
    "askedAt": "2026-08-29T06:00:00.000Z",
    "latencyMs": 32178,
    "previous": { "runId": "run_01k2ai4", "changed": "citations" }
  },
  "previousPrice": null,
  "occurredAt": "2026-08-29T06:00:00.000Z",
  "ai": null
}
```

`mentioned` and `cited` are different questions and a brand can be either
without the other — see [AI answers](/sources/ai-answers). The same shape
arrives on `answer.created`, `term.cited` and `term.uncited`; only `type`
differs, and on a first run `previous` is `null`.

<h2 id="agent-step">
  Agent step
</h2>

A keyword can carry an `aiStep`: one instruction and one flat schema of
your own fields, applied to every event it emits. The answer arrives on
the event, in the `ai` block. Set it on `POST /v1/keywords` or with a patch:

```json theme={null}
{
  "aiStep": {
    "instruction": "Say whether the author is asking for a recommendation.",
    "schema": {
      "asking": { "type": "boolean" },
      "urgency": { "type": "enum", "values": ["low", "medium", "high"] },
      "why": { "type": "string", "maxLength": 200 }
    }
  }
}
```

Events from that keyword carry the reading:

```json theme={null}
{
  "id": "event-uuid",
  "type": "post.created",
  "keywordId": "9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f",
  "source": "reddit",
  "listing": null,
  "tweet": null,
  "post": { "...": "canonical post object" },
  "video": null,
  "blueskyPost": null,
  "hnItem": null,
  "article": null,
  "aiAnswer": null,
  "previousPrice": null,
  "occurredAt": "2026-07-07T12:00:00.000Z",
  "ai": {
    "output": { "asking": true, "urgency": "medium", "why": "asks for an alternative to their current tool" },
    "model": "deepseek/deepseek-v4-flash-0731",
    "status": "ok",
    "stepId": "9f2c…"
  }
}
```

### Delivery waits for the reading

A keyword with an `aiStep` **holds its delivery** until the step answers, up to
120 seconds. You receive one message per event, with `ai` filled in, never a
first message without the reading and a second one with it.

**The event is never lost.** If the step times out or fails, the event is
delivered anyway with `ai.output: null` and `ai.status` set to `timeout` or
`failed`. Treat a missing reading as "no answer", not as an error to retry: the
event itself is complete.

| `ai.status` | Meaning |
| - | - |
| `ok` | The step answered. `ai.output` holds your fields. |
| `timeout` | The step did not answer within 120 s. `ai.output` is `null`. |
| `failed` | The step could not run. `ai.output` is `null`. |

Keywords without an `aiStep` are unaffected: they deliver immediately, with
`ai: null`.

### Schema and rates

The schema is a flat object of 1–12 fields, each `string`, `number`, `boolean`
or `enum`. Every field can answer `null` — that is a real answer, not an error.
See [AI](/ai) for the full subset and the credit rate.

<h2 id="keyword-events">
  Keyword events
</h2>

A keyword's webhook can also be told about the keyword itself: an alert fired, the
weekly report is ready, the keyword paused or resumed, a delivery channel it feeds
was switched off. An agent or a scenario can then react to "this keyword stopped"
without polling for it.

These are **opt-in, per keyword and per type**. `webhookEvents` is empty by
default, and the webhook then receives item events alone, exactly as before.
Set it when you create or update a keyword:

```bash theme={null}
curl -X PATCH "https://api.nephia.cc/v1/keywords/$KEYWORD_ID" \
  -H "Authorization: Bearer $NEPHIA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "webhookEvents": ["keyword.alert_fired", "keyword.paused", "keyword.resumed"] }'
```

Send `[]` to opt back out of all of them. The keyword needs a `webhookUrl`: the
events go to the same URL as its items.

| `type` | When | `data` |
| - | - | - |
| `keyword.alert_fired` | A volume or sentiment rule tripped | `rule` (`id`, `name`), `kind` (`volume` or `sentiment_shift`), `window` (`1h` or `24h`), `count`, `baseline`, `multiple`, `summary` |
| `keyword.report_ready` | The weekly report was stored | `periodStart`, `periodEnd`, `mentions`, `priorMentions`, `summary` |
| `keyword.paused` | The keyword stopped polling | `reason`: `user_paused`, `schedule`, `plan_entitlement` or `insufficient_credits` |
| `keyword.resumed` | The keyword is polling again | empty |
| `channel.disabled` | A channel this keyword feeds failed too many times in a row | `channel` (`id`, `kind`, `name`), `consecutiveFailures` |

The envelope is smaller than an item event's. Route on
`type`: every item type names an entity (`tweet.created`), every keyword-level
type starts with `keyword.` or `channel.`.

```json theme={null}
{
  "id": "0f4c7c0e-7b0a-4c7e-9a53-2f6d1c8e4b11",
  "type": "keyword.alert_fired",
  "keywordId": "9f8e7d6c-5b4a-4c3d-8e2f-1a0b9c8d7e6f",
  "occurredAt": "2026-09-19T08:00:00.000Z",
  "data": {
    "rule": { "id": "b1c2d3e4-0000-4000-8000-000000000001", "name": "Spike alert" },
    "kind": "volume",
    "window": "1h",
    "count": 42,
    "baseline": 6.2,
    "multiple": 6.8,
    "summary": null
  }
}
```

`summary` is `null` when no model wrote one. The counts stand on their own. A
channel's address or webhook URL is never part of `channel.disabled`.

Deduplicate on `id`: a retry delivers the same `id`. The signature and the
retries are the ones described below, with the same secret as your item events.

## Signature verification

Each delivery includes an `X-Signature` header of the form `t=<unix seconds>,v1=<hex>`, where `v1` is the HMAC-SHA256 of `"<t>.<raw JSON body>"` using your account's webhook signing secret. The timestamp prevents replay attacks: reject deliveries whose `t` is more than 5 minutes from now.

Your signing secret is per-account — find it on the **API Keys** page of the dashboard.

With the SDK (recommended):

```typescript theme={null}
import { Nephia } from 'nephia';

const ok = await Nephia.webhooks.verify(rawBody, req.headers['x-signature'], secret);
// Optional: { toleranceSeconds: 300 } is the default replay window.
```

Manual verification:

```typescript theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifySignature(body: string, header: string, secret: string): boolean {
  const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header);
  if (!match) return false;
  const [, t, v1] = match;
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
  return timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
```

## Retries

Failed deliveries (non-2xx or network error) retry with backoff: immediate, +30s, +120s (3 attempts total).

<Note>
  In n8n, the [official Nephia Trigger](/integrations/n8n) does all of this for
  you: it creates and deletes the keyword, verifies the signature, and drops the
  repeats, with a poll mode for instances that are not reachable from the
  internet.
</Note>

<h2 id="replay">
  Replay events
</h2>

To catch up after your endpoint was down, replay what the keyword emitted with
`GET /v1/keywords/{id}/events` (free):

```bash theme={null}
curl "https://api.nephia.cc/v1/keywords/KEYWORD_ID/events?since=2026-07-01T00:00:00.000Z" \
  -H "x-api-key: YOUR_API_KEY"
```

The answer is `{ "events": [...] }`, newest first, across every search of the keyword,
the ones it has since replaced included. **Each event is the body the webhook delivered
for it**, key for key: `id`, `type`, `keywordId`, `source`, the entity keys, `previousPrice`,
`occurredAt`, `publishedAt`, `ai`, and `analysis` when something read the item. A handler
written for the webhook reads a replay unchanged, and the event `id` is the same in both,
so de-duplicating on it is enough.

| Parameter | What it does |
| - | - |
| `since` | ISO 8601 instant. Only events whose `occurredAt` is later are returned. Defaults to the last 24 hours. |
| `limit` | How many events to return: 200 by default, 500 at most. |
| `source` | Narrows the replay to one Source of the keyword. |

```bash theme={null}
curl "https://api.nephia.cc/v1/keywords/KEYWORD_ID/events?source=reddit&limit=50" \
  -H "x-api-key: YOUR_API_KEY"
```

This is the raw read. `GET /v1/keywords/{id}/results` and `GET /v1/mentions?keyword={id}`
answer the same matches as **mentions** (a title, a body, a bucket, a reading), which is
what you want for a screen or a report; `/events` is what you want when your code already
speaks the webhook.

<h2 id="activity">
  Activity
</h2>

When a keyword goes quiet, or a delivery never arrived, `GET /v1/keywords/{id}/activity`
says what happened underneath (free):

```bash theme={null}
curl "https://api.nephia.cc/v1/keywords/KEYWORD_ID/activity?since=2026-07-01T00:00:00.000Z" \
  -H "x-api-key: YOUR_API_KEY"
```

The answer is `{ "activity": [...] }`, newest first. Each entry carries `id`, `source`,
`type`, `occurredAt`, `eventsEmitted`, `creditsCharged`, `eventId`, `statusCode` and
`message`. It takes the same `since`, `limit` and `source` parameters as `/events`, and
`since` defaults to the last 7 days.

| `type` | Meaning |
| - | - |
| `poll.completed` | A check ran. `eventsEmitted` and `creditsCharged` say what it found and cost. |
| `poll.failed` | A check failed; `message` says why. |
| `webhook.delivered` | Your endpoint answered 2xx for the event named by `eventId`. |
| `webhook.failed` | A delivery failed. `statusCode` is what your endpoint answered, and `eventId` names the event, an `id` of `/events`. |

<Note>
  **Events are kept for 90 days.** A `since` older than that returns what is left,
  not an error: the rest has aged out. The window is the same for every plan and
  every source.

  Ninety days is far longer than a replay needs: a delivery that fails is retried
  for 48 hours, and a `since` you set from your own last-seen event is usually
  minutes or hours behind. If you need events beyond that, store them on receipt:
  the webhook body and the replay body are the same envelope, so nothing is lost
  by writing the one you are already handed.

  Retention is not the only way events end. A replay still reaches the events of a
  **retired** keyword (`DELETE /v1/keywords/{id}` without `purge` stops the polling
  and keeps the record) but not those of a **purged** one, which
  `DELETE /v1/keywords/{id}?purge=true` deletes outright. If you replay against a
  keyword somebody may delete, store what you receive.
</Note>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.