> ## 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 webhook HMAC signatures, handle rotation, read X-Lerian headers, deduplicate deliveries, and pull with cursor 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

***

Streaming Hub signs every webhook delivery with an HMAC over the request timestamp and the exact request body. The signature proves that the request came from Streaming Hub, and that it 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 formula is:

```
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.

You receive the signing secret when you create the subscription, or when you last rotate it. Streaming Hub never sends the secret in a header. The header carries only the derived signature. See [creating a webhook subscription](/en/platform/streaming-hub/managing-subscriptions) for where the secret comes from.

## Handling two signatures during rotation

***

When you [rotate a signing secret](/en/platform/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. The hub sends them 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.                                                                                                |

<Warning>
  Webhook delivery carries no producer-source header. `X-Lerian-Event-Type` is deliberately source-free, so a consumer cannot distinguish two producers that emit the same key unless the subscription pins `origin`. Create one origin-pinned subscription per producer when source identity matters.
</Warning>

## 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. The hub treats a slow or failing response as a failed delivery. A failed delivery triggers the [retry curve](/en/platform/streaming-hub/how-streaming-hub-works). If the destination stays broken long enough, the hub eventually [auto-disables](/en/platform/streaming-hub/how-streaming-hub-works) the subscription.

## 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/platform/streaming-hub/managing-subscriptions">
    Create subscriptions, rotate secrets, and recover disabled destinations.
  </Card>

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