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

# Consuming events

> Consume events from Streaming Hub: verify a webhook HMAC signature, handle two signatures during rotation, read the X-Lerian correlation headers, deduplicate deliveries, and pull events with cursor-as-ack.

Streaming Hub delivers events two ways. **Push** sinks (`webhook`, `sqs`, `rabbitmq`, `eventbridge`) send each matched event to a destination you own. A **pull** sink holds events on a server-side cursor that your consumer reads on its own schedule. This page covers what your consumer must do in each case.

## Verifying a webhook signature

***

Every webhook delivery is signed with an HMAC over the request timestamp and the exact request body, so you can prove the request came from Streaming Hub and was not tampered with or replayed. Two headers carry the signature:

| Header                | Value                                                 |
| --------------------- | ----------------------------------------------------- |
| `X-Webhook-Signature` | `v1,sha256=<hex>` — the signature.                    |
| `X-Webhook-Timestamp` | The Unix-seconds timestamp folded into the signature. |

The signature is computed as:

```
signature_input = "v1:" + X-Webhook-Timestamp + "." + <raw request body>
X-Webhook-Signature = "v1,sha256=" + hex( HMAC-SHA256(key = signing_secret, message = signature_input) )
```

To verify a delivery:

1. **Read the raw body and the timestamp** — verify **before** parsing the body, over the exact bytes received. Any re-serialization changes the bytes and breaks the signature.
2. **Check freshness** — reject the request if `X-Webhook-Timestamp` is more than **5 minutes** from now. This is the replay-protection window.
3. **Recompute the signature** — build `signature_input` as above with your signing secret and hex-encode the HMAC-SHA256.
4. **Compare in constant time** — compare your `v1,sha256=<hex>` against the received `X-Webhook-Signature` with a constant-time (timing-safe) comparison. Reject on mismatch.
5. **Respond** — return `2xx` only after the signature and freshness both pass.

The signing secret is the one you received when you created the subscription (or last rotated it). It is never sent in a header — only the derived signature is. See [creating a webhook subscription](/en/streaming-hub/managing-subscriptions) for where the secret comes from.

## Handling two signatures during rotation

***

When you [rotate a signing secret](/en/streaming-hub/managing-subscriptions), the hub runs a **24-hour dual-sign overlap**. During the overlap each delivery carries **two `X-Webhook-Signature` headers** — one signed with the new secret and one with the previous secret — sent as **repeated headers**, so you can migrate to the new secret without dropping any delivery.

Verify against both: recompute the expected signature with each secret you currently hold, and **accept the request if either received signature matches**. Once you have deployed and confirmed the new secret, retire the old one.

<Warning>
  Read `X-Webhook-Signature` as a **list of repeated header values** using your framework's multi-value header accessor (or its raw-headers view). Do **not** split a single joined string on commas: the signature value itself contains a comma (`v1,sha256=…`), so a naive comma-split corrupts it. Some HTTP stacks join repeated headers with `", "` by default — use the list accessor to get each value intact.
</Warning>

## Correlation headers

***

Every webhook delivery also carries a set of `X-Lerian-*` context headers:

| Header                    | Carries                                                                                                                    |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `X-Lerian-Event-Id`       | The stable event id (the CloudEvents `ce-id`). **The deduplication key** — constant across redeliveries of the same event. |
| `X-Lerian-Event-Type`     | The event type, as the bare `<resource>.<event>` tail (for example, `transaction.created`).                                |
| `X-Lerian-Tenant-Id`      | The owning tenant id (attribution only).                                                                                   |
| `X-Lerian-Delivery-Id`    | The per-attempt id — **changes on every retry** of the same event.                                                         |
| `X-Lerian-Schema-Version` | The payload schema version.                                                                                                |

## Deduplicating deliveries

***

Delivery is **at-least-once**: the hub may deliver the same event more than once, through retries or a redelivery after a worker restart. Deduplicate on **`X-Lerian-Event-Id`** — it is stable across every redelivery of the same event, while `X-Lerian-Delivery-Id` differs per attempt. Treat a `X-Lerian-Event-Id` you have already processed as a duplicate: acknowledge it with a `2xx` and do not reprocess. Keep your handler idempotent.

## Responding quickly

***

Return a `2xx` as soon as you have verified and durably accepted the event — then do the real work asynchronously. A slow or failing response is treated as a failed delivery, which triggers the [retry curve](/en/streaming-hub/how-streaming-hub-works) and, if the destination stays broken long enough, eventually [auto-disables](/en/streaming-hub/how-streaming-hub-works) the subscription. Acknowledge fast, process out of band.

## Pulling events

***

A `pull` subscription receives no push. Instead, your consumer reads a page of its events with:

```
GET /v1/events?subscription_id=<uuid>
```

The events come back in ascending arrival order, each with its `seq`, its `ceId` (for dedup), its type, and its payload. The response includes a `next_cursor`.

**The read is the acknowledgment (cursor-as-ack).** Fetching a page advances the subscription's durable cursor to the highest `seq` returned. There is no separate ack call — reading a page acknowledges it. This is monotonic, so it never moves backward.

To page through normally, resume from the server's `next_cursor` and stop when a short page (fewer than your `limit`) returns `null`. Two behaviors to keep in mind:

* **A forward `?after=` seek forfeits the gap.** Passing `?after=N` greater than your current cursor advances the cursor past everything up to `N` — the skipped events are **never redelivered**. Asking for events after `N` declares everything up to `N` consumed. This can only happen with a hand-crafted forward seek, never through normal `next_cursor` pagination.
* **A backward `?after=` seek never rewinds.** A stale or lower `?after=` that reads an older page does not move the cursor back, because the cursor only ever advances.

The pull read is rate-limited per tenant. A denied read returns **`429 rate_limited`** — back off and retry. Deduplicate pulled events on `ceId`, just as a webhook consumer deduplicates on `X-Lerian-Event-Id`.

## Next steps

***

<CardGroup cols={2}>
  <Card title="Managing subscriptions" icon="gear" href="/en/streaming-hub/managing-subscriptions">
    Create subscriptions, rotate secrets, and recover disabled destinations.
  </Card>

  <Card title="Operating Streaming Hub" icon="server" href="/en/streaming-hub/operating-streaming-hub">
    Deploy, configure, and observe the hub.
  </Card>
</CardGroup>
