# Heartbeat Queue

The heartbeat queue is a Redis List-based pipeline for delivering page engagement signals from Allegro to external consumers. When enabled, the SDK tracks how long each page is actively open and periodically POSTs that running total to the platform, which pushes it onto a per-tenant Redis List. External consumers poll the queue to read heartbeats, then issue a separate delete call to remove them.

It mirrors the [Event Queue](/developer/platform/event-queue.md) — same per-tenant FIFO List, same read-then-delete consumer contract — but carries engagement heartbeats rather than tracked events.

## How It Works[​](#how-it-works "Direct link to How It Works")

### Feature Flag[​](#feature-flag "Direct link to Feature Flag")

The queue is opt-in per tenant via the `SdkHeartbeat` Pennant feature flag. The same flag also surfaces the heartbeat configuration to the SDK, so enabling it both starts the client loop and opens the queue.

### Active Time Tracking[​](#active-time-tracking "Direct link to Active Time Tracking")

When the SDK loads, it fires a `page_view` event and starts a timer. While the page is visible it measures the **cumulative active time** — the time the page is in the foreground, excluding any stretch it spends backgrounded — and POSTs the running total (whole seconds) to `/api/heartbeat` every few seconds.

* Each beat carries the page\_view `event_id` and the running `active_seconds` total, so the **latest beat for an event id supersedes earlier ones**.
* Beats are sent with `keepalive` so the final beat survives the page being backgrounded or unloaded.
* When the page is hidden (backgrounded or unloading), the SDK flushes one final beat with the accumulated time and pauses. Time spent hidden is **not** counted.
* When the page becomes visible again, the timer resumes and active time keeps accruing from where it left off.
* On SPA navigation a new `page_view` fires and the active-time counter resets for the new page.
* Beats that round to **0 active seconds** are not sent — a sub-second visit (a quick reload, a prefetch/bot, or a tab opened and immediately hidden) carries no engagement signal, and the `page_view` event already records the load. So every queued heartbeat has `active_seconds` ≥ 1.

### Heartbeat Ingestion[​](#heartbeat-ingestion "Direct link to Heartbeat Ingestion")

Each accepted heartbeat is pushed onto the tenant's heartbeat queue with a server-stamped receipt timestamp. Each tenant has its own isolated queue.

### Reading Heartbeats (`get`)[​](#reading-heartbeats-get "Direct link to reading-heartbeats-get")

Consumers call `GET /api/v1/platform/heartbeats` to read up to 10,000 heartbeats from the front of the queue. This is a **non-destructive** read — heartbeats remain in the queue until you explicitly delete them.

### Deleting Heartbeats (`delete`)[​](#deleting-heartbeats-delete "Direct link to deleting-heartbeats-delete")

After processing the heartbeats, call `DELETE /api/v1/platform/heartbeats?count=N` where `N` is the `count` value returned by the preceding `GET`. Passing the exact count prevents a race condition: any heartbeats that arrived between the `GET` and the `DELETE` are left untouched and will be returned on the next `GET`.

***

## API Endpoints[​](#api-endpoints "Direct link to API Endpoints")

All endpoints are under the `/api/v1/platform` prefix and require Sanctum authentication (`auth:sanctum`).

***

### `GET /api/v1/platform/heartbeats`[​](#get-apiv1platformheartbeats "Direct link to get-apiv1platformheartbeats")

Read up to 10,000 heartbeats from the front of the queue. Non-destructive — heartbeats are not removed until `DELETE` is called.

If the `SdkHeartbeat` feature flag is not active for the current tenant, returns an empty result.

**Response `200 OK`:**

```json
{
    "data": {
        "count": 2,
        "heartbeats": [
            {
                "event_id": "9b5c1f2e-9c3a-4f1b-8a2d-1e2f3a4b5c6d",
                "active_seconds": 5,
                "received_at": "2026-02-25T18:00:05+00:00"
            },
            {
                "event_id": "9b5c1f2e-9c3a-4f1b-8a2d-1e2f3a4b5c6d",
                "active_seconds": 10,
                "received_at": "2026-02-25T18:00:10+00:00"
            }
        ]
    }
}

```

**Empty queue response:**

```json
{
    "data": {
        "count": 0,
        "heartbeats": []
    }
}

```

#### Heartbeat Payload[​](#heartbeat-payload "Direct link to Heartbeat Payload")

Each entry in the queue is the exact object stored at ingestion time — there is no enrichment or lookup.

| Field            | Type    | Description                                                                                      |
| ---------------- | ------- | ------------------------------------------------------------------------------------------------ |
| `event_id`       | string  | UUID of the `page_view` event this heartbeat belongs to. Repeated across beats; the latest wins. |
| `active_seconds` | integer | Cumulative active time on the page in whole seconds. Excludes time the page was backgrounded.    |
| `received_at`    | string  | ISO 8601 timestamp stamped server-side when the beat was received.                               |

Latest beat wins

Multiple beats share the same `event_id` as active time grows. To compute final engagement per page view, keep the **largest `active_seconds`** (equivalently, the latest `received_at`) for each `event_id`.

***

### `DELETE /api/v1/platform/heartbeats`[​](#delete-apiv1platformheartbeats "Direct link to delete-apiv1platformheartbeats")

Remove exactly `count` heartbeats from the front of the queue (oldest first). The `count` parameter is **required** and must match the `count` returned by the preceding `GET` call — this prevents accidentally deleting heartbeats that arrived after the read.

**Query parameters:**

| Parameter | Type        | Required | Description                                                                    |
| --------- | ----------- | -------- | ------------------------------------------------------------------------------ |
| `count`   | integer ≥ 1 | Yes      | Number of heartbeats to remove. Use the `count` value from the `GET` response. |

**Response `204 No Content`** — heartbeats removed.

**Response `422 Unprocessable Content`** — `count` is missing or invalid.

***

## Recommended Consumer Pattern[​](#recommended-consumer-pattern "Direct link to Recommended Consumer Pattern")

```text
loop:
    GET /api/v1/platform/heartbeats
    if count == 0 → sleep, continue

    process each heartbeat (keep the largest active_seconds per event_id)

    DELETE /api/v1/platform/heartbeats?count={count}

```

Read all heartbeats first, process them, then delete — passing the exact `count` from the `GET` response. Because `GET` is non-destructive, heartbeats are safe to re-read if your consumer needs to retry before issuing the `DELETE`. Using the explicit count ensures heartbeats that arrived between your `GET` and `DELETE` are never accidentally removed.

***

## Configuration[​](#configuration "Direct link to Configuration")

| Config key                    | Default   | Description                                              |
| ----------------------------- | --------- | -------------------------------------------------------- |
| `eventqueue.redis_connection` | `default` | Laravel Redis connection to use for the heartbeat queue. |
