# VoteTracker Webhook Forwarding Guide

This guide documents webhook forwarding behavior beyond the OpenAPI contract.

**Official SDK:** [`@votetracker/sdk`](https://www.npmjs.com/package/@votetracker/sdk) verifies deliveries in both auth modes, ships typed payloads for every event and has handlers for Express, Fastify and Hono. Source at [votetracker-bot/sdks](https://github.com/votetracker-bot/sdks).

## Overview

Forwarding sends selected integration events to your configured destination URL.

## Get Started

Configure forwarding and create/manage your integration API keys in the **[Vote Tracker Dashboard](https://votetracker.bot/dashboard/api)**.

- Delivery is handled by VoteTracker's forwarding service.
- Payloads are JSON.

## Event Types

Current forwarding event types:

- `VOTE_CREATED`
- `EVENT_CREATED`
- `EVENT_ACTIVATED`
- `EVENT_CANCELLED`
- `EVENT_COMPLETED`
- `EVENT_WINNERS_SELECTED`
- `EVENT_WINNER_REROLLED`

Notes:

- Vote webhooks from providers with type `WEBHOOK_TEST` are forwarded as:
  - `event_type = VOTE_CREATED`
  - `data.vote_type = WEBHOOK_TEST`
- [Dashboard](https://votetracker.bot/dashboard/api) forwarding test endpoint sends:
  - `event_type = WEBHOOK_TEST`

## Payload Examples

All payloads are JSON with a top-level `event_type`, `occurred_at`, and `data`.

### VOTE_CREATED

```json
{
  "event_type": "VOTE_CREATED",
  "occurred_at": "2026-02-25T18:30:00Z",
  "data": {
    "id": "vt_vote_2k5k5f2nY1Rk08Jxq8Nqf2Q2f6b2iYwzH0n7qkW9c3Q",
    "vote_type": "VOTE_CREATE",
    "user_platform_id": "223456683337318402",
    "project_platform_id": "479688142908162059",
    "project_type": "BOT",
    "project_name": "Vote Tracker",
    "weight": 1,
    "vote_expires_at": "2026-02-26T18:30:00Z",
    "streak_current": 4,
    "is_test": false,
    "query": {
      "guild_id": "811261515891474442",
      "source": "vote-command"
    }
  }
}
```

### VOTE_CREATED with `WEBHOOK_TEST`

```json
{
  "event_type": "VOTE_CREATED",
  "occurred_at": "2026-02-25T18:31:00Z",
  "data": {
    "id": "vt_vote_4q9V5f1sV9uJ3b2d1pQ5y3A7X3bA6W9mR2oK1eP0f8M",
    "vote_type": "WEBHOOK_TEST",
    "user_platform_id": "223456683337318402",
    "project_platform_id": "479688142908162059",
    "project_type": "BOT",
    "project_name": "Vote Tracker",
    "weight": 1,
    "vote_expires_at": "2026-02-26T18:31:00Z",
    "streak_current": 0,
    "is_test": true
  }
}
```

### EVENT_CREATED

```json
{
  "event_type": "EVENT_CREATED",
  "occurred_at": "2026-02-25T19:00:00Z",
  "data": {
    "event_id": "evt_pub_1234567890",
    "guild_id": "706877503645679676",
    "type": "GIVEAWAY",
    "starts_at": "2026-02-25T19:00:00Z",
    "ends_at": "2026-02-26T19:00:00Z",
    "winner_count": 1,
    "prize_text": "Your configured prize",
    "base_entries_per_vote": 1,
    "project_platform_id": "479688142908162059",
    "project_type": "BOT",
    "project_name": "Vote Tracker",
    "external_reward": {
      "kind": "currency",
      "amount": 500
    },
    "prize_image_url": "https://cdn.example.com/prizes/holo-pikachu.png",
    "is_test": false
  }
}
```

### EVENT_ACTIVATED

```json
{
  "event_type": "EVENT_ACTIVATED",
  "occurred_at": "2026-02-25T19:00:00Z",
  "data": {
    "event_id": "evt_pub_1234567890",
    "guild_id": "706877503645679676",
    "type": "GIVEAWAY",
    "starts_at": "2026-02-25T19:00:00Z",
    "ends_at": "2026-02-26T19:00:00Z",
    "winner_count": 1,
    "prize_text": "Your configured prize",
    "base_entries_per_vote": 1,
    "project_platform_id": "479688142908162059",
    "project_type": "BOT",
    "project_name": "Vote Tracker",
    "is_test": false
  }
}
```

### EVENT_CANCELLED

```json
{
  "event_type": "EVENT_CANCELLED",
  "occurred_at": "2026-02-25T20:00:00Z",
  "data": {
    "event_id": "evt_pub_1234567890",
    "guild_id": "706877503645679676",
    "type": "GIVEAWAY",
    "prize_text": "Your configured prize",
    "project_platform_id": "479688142908162059",
    "project_type": "BOT",
    "project_name": "Vote Tracker",
    "cancel_reason": "MANUAL_CANCEL",
    "cancelled_at": "2026-02-25T20:00:00Z",
    "is_test": false
  }
}
```

### EVENT_COMPLETED

```json
{
  "event_type": "EVENT_COMPLETED",
  "occurred_at": "2026-02-26T19:00:00Z",
  "data": {
    "event_id": "evt_pub_1234567890",
    "guild_id": "706877503645679676",
    "type": "GIVEAWAY",
    "prize_text": "Your configured prize",
    "project_platform_id": "479688142908162059",
    "project_type": "BOT",
    "project_name": "Vote Tracker",
    "ended_at": "2026-02-26T19:00:00Z",
    "is_test": false,
    "total_entries": 87,
    "winners": [
      {
        "user_platform_id": "223456683337318402",
        "rank": 1,
        "entries_at_draw": 12,
        "discord_id": "223456683337318402",
        "user_name": "xavin"
      }
    ]
  }
}
```

### EVENT_WINNERS_SELECTED

```json
{
  "event_type": "EVENT_WINNERS_SELECTED",
  "occurred_at": "2026-02-26T19:00:10Z",
  "data": {
    "event_id": "evt_pub_1234567890",
    "guild_id": "706877503645679676",
    "type": "GIVEAWAY",
    "prize_text": "Your configured prize",
    "project_platform_id": "479688142908162059",
    "project_type": "BOT",
    "project_name": "Vote Tracker",
    "total_entries": 87,
    "winners": [
      {
        "user_platform_id": "223456683337318402",
        "rank": 1,
        "entries_at_draw": 12,
        "discord_id": "223456683337318402",
        "user_name": "xavin"
      }
    ],
    "external_reward": {
      "kind": "currency",
      "amount": 500
    },
    "selected_at": "2026-02-26T19:00:10Z",
    "is_test": false
  }
}
```

### EVENT_WINNER_REROLLED

Sent when the owner rerolls one or all winners, from the dashboard or the API. Revoke the reward
of each `previous_winner` and grant it to the matching `new_winner`.

```json
{
  "event_type": "EVENT_WINNER_REROLLED",
  "occurred_at": "2026-02-26T20:15:00Z",
  "data": {
    "event_id": "evt_pub_1234567890",
    "guild_id": "706877503645679676",
    "type": "GIVEAWAY",
    "prize_text": "Your configured prize",
    "project_platform_id": "479688142908162059",
    "project_type": "BOT",
    "project_name": "Vote Tracker",
    "total_entries": 87,
    "rerolls": [
      {
        "rank": 1,
        "previous_winner": {
          "user_platform_id": "223456683337318402",
          "rank": 1,
          "entries_at_draw": 12,
          "discord_id": "223456683337318402",
          "user_name": "xavin"
        },
        "new_winner": {
          "user_platform_id": "199456683337318401",
          "rank": 1,
          "entries_at_draw": 9,
          "discord_id": "199456683337318401",
          "user_name": "newwinner"
        }
      }
    ],
    "winners": [
      {
        "user_platform_id": "199456683337318401",
        "rank": 1,
        "entries_at_draw": 9,
        "discord_id": "199456683337318401",
        "user_name": "newwinner"
      }
    ],
    "reason": "Previous winner left the server",
    "external_reward": {
      "kind": "currency",
      "amount": 500
    },
    "rerolled_at": "2026-02-26T20:15:00Z"
  }
}
```

### WEBHOOK_TEST (dashboard forwarding test endpoint)

```json
{
  "event_type": "WEBHOOK_TEST",
  "occurred_at": "2026-02-25T18:45:00Z",
  "data": {
    "delivery_id": "7d7a5f2e-1f6b-4a83-9512-d57f8e9f3e7c"
  }
}
```

Notes:

- `data.delivery_id` in the test payload is generated for test payload identification.
- `X-VT-Delivery-Id` request header is generated separately.
- For test requests, these two IDs are currently not guaranteed to be equal.

## Payload Schema (Types & Nullability)

All forwarding payloads use this envelope:

| Field | Type | Nullable | Notes |
|---|---|---|---|
| `event_type` | `string` | No | Event discriminator (`VOTE_CREATED`, `EVENT_CREATED`, `EVENT_ACTIVATED`, `EVENT_CANCELLED`, `EVENT_COMPLETED`, `EVENT_WINNERS_SELECTED`, `EVENT_WINNER_REROLLED`, `WEBHOOK_TEST` for dashboard test payloads). |
| `occurred_at` | `string` (ISO-8601 UTC timestamp) | No | Example: `2026-02-25T18:30:00Z`. |
| `data` | `object` | No | Event-specific payload. |

### `VOTE_CREATED` -> `data`

| Field | Type | Nullable | Notes |
|---|---|----------|---|
| `id` | `string` | No       | Canonical vote identifier for durable dedupe across forwarding and API backfill. |
| `vote_type` | `string` enum | No       | `VOTE_CREATE` or `WEBHOOK_TEST`. |
| `user_platform_id` | `string` | No       | User ID as string (snowflake-like ID). |
| `project_platform_id` | `string` | No       | Project ID on the provider platform. |
| `project_type` | `string` enum | No       | `BOT`, `GAME`, `SERVER`. |
| `project_name` | `string` | Yes      | Project display name if available. |
| `weight` | `integer` | No       | Vote weight. |
| `vote_expires_at` | `string` (ISO-8601 UTC timestamp) | Yes      | Vote expiry timestamp if available. |
| `streak_current` | `integer` | Yes      | Current streak after processing this vote. |
| `is_test` | `boolean` | No       | `true` for test votes. |
| `query` | `object` | Yes      | Query parameters captured from the vote link, as a string to string map (for example `guild_id` or `source`). Present only when the vote carried query parameters. |

### `EVENT_CREATED` / `EVENT_ACTIVATED` -> `data`

| Field | Type | Nullable | Notes |
|---|---|---|---|
| `event_id` | `string` | No | Public event ID. |
| `guild_id` | `string` | No | Guild/server ID. |
| `type` | `string` | No | Currently always `GIVEAWAY`. |
| `starts_at` | `string` (ISO-8601 UTC timestamp) | No | Event start time. |
| `ends_at` | `string` (ISO-8601 UTC timestamp) | No | Event end time. |
| `winner_count` | `integer` | No | Number of winners. |
| `prize_text` | `string` | No | Prize description. |
| `base_entries_per_vote` | `integer` | No | Base entries granted per vote. |
| `project_platform_id` | `string` | No | Project ID on provider platform. |
| `project_type` | `string` enum | No | `BOT`, `GAME`, `SERVER`. |
| `project_name` | `string` | Yes | Project display name if available. |
| `external_reward` | `object` or `array` | Yes | `EVENT_CREATED` only. Owner-defined reward payload, echoed verbatim, max 2048 bytes. Absent when not configured. |
| `prize_image_url` | `string` | Yes | `EVENT_CREATED` only. Prize image URL when configured. |
| `is_test` | `boolean` | No | Indicates test flow. |

### `EVENT_CANCELLED` -> `data`

| Field | Type | Nullable | Notes |
|---|---|---|---|
| `event_id` | `string` | No | Public event ID. |
| `guild_id` | `string` | No | Guild/server ID. |
| `type` | `string` | No | Currently always `GIVEAWAY`. |
| `prize_text` | `string` | No | Prize description. |
| `project_platform_id` | `string` | No | Project ID on provider platform. |
| `project_type` | `string` enum | No | `BOT`, `GAME`, `SERVER`. |
| `project_name` | `string` | Yes | Project display name if available. |
| `cancel_reason` | `string` | Yes | Cancel reason if provided. |
| `cancelled_at` | `string` (ISO-8601 UTC timestamp) | No | Cancellation timestamp. |
| `is_test` | `boolean` | No | Indicates test flow. |

### `EVENT_COMPLETED` -> `data`

| Field | Type | Nullable | Notes |
|---|---|---|---|
| `event_id` | `string` | No | Public event ID. |
| `guild_id` | `string` | No | Guild/server ID. |
| `type` | `string` | No | Currently always `GIVEAWAY`. |
| `prize_text` | `string` | No | Prize description. |
| `project_platform_id` | `string` | No | Project ID on provider platform. |
| `project_type` | `string` enum | No | `BOT`, `GAME`, `SERVER`. |
| `project_name` | `string` | Yes | Project display name if available. |
| `ended_at` | `string` (ISO-8601 UTC timestamp) | No | Event end timestamp. |
| `is_test` | `boolean` | No | Indicates test flow. |
| `total_entries` | `integer` | No | Total entries at completion. |
| `winners` | `array<object>` | No | Winner list. May be empty. |

Winner item (`winners[]`):

| Field | Type | Nullable | Notes |
|---|---|---|---|
| `user_platform_id` | `string` | No | Winner user ID on the voting platform (Discord snowflake for `BOT`/`SERVER`, Roblox ID for `GAME`). |
| `rank` | `integer` | No | Winner rank (`1` = top). |
| `entries_at_draw` | `integer` | No | Entries the user had at draw time. |
| `discord_id` | `string` | Yes | Optional Discord snowflake for actions on Discord (ping, DM, role). For `BOT`/`SERVER` it equals `user_platform_id`. For `GAME` it DIFFERS from `user_platform_id`: it is the winner's linked Discord account, and null when the voter never linked one. Always act on Discord via this field, never via `user_platform_id`. |
| `user_name` | `string` | Yes | Display name if known. |

### `EVENT_WINNERS_SELECTED` -> `data`

Same shape as `EVENT_COMPLETED` plus:

| Field | Type | Nullable | Notes |
|---|---|---|---|
| `external_reward` | `object` or `array` | Yes | Owner-defined reward payload, echoed verbatim. Absent when not configured. |
| `selected_at` | `string` (ISO-8601 UTC timestamp) | No | Winner selection timestamp. |

### `EVENT_WINNER_REROLLED` -> `data`

| Field | Type | Nullable | Notes |
|---|---|---|---|
| `event_id` | `string` | No | Public event ID. |
| `guild_id` | `string` | No | Guild/server ID. |
| `type` | `string` | No | Currently always `GIVEAWAY`. |
| `prize_text` | `string` | No | Prize description. |
| `project_platform_id` | `string` | No | Project ID on provider platform. |
| `project_type` | `string` enum | No | `BOT`, `GAME`, `SERVER`. |
| `project_name` | `string` | Yes | Project display name if available. |
| `total_entries` | `integer` | No | Total entries of the giveaway. |
| `rerolls` | `array<object>` | No | One entry per replaced rank, each with `rank`, `previous_winner` and `new_winner` (winner item shape). |
| `winners` | `array<object>` | No | The full current winner list after the reroll. |
| `reason` | `string` | No | Owner-provided reroll reason. Not shown in the Discord channel. |
| `external_reward` | `object` or `array` | Yes | Owner-defined reward payload, echoed verbatim. Absent when not configured. |
| `rerolled_at` | `string` (ISO-8601 UTC timestamp) | No | Reroll timestamp. |

### `WEBHOOK_TEST` (dashboard test payload) -> `data`

| Field | Type | Nullable | Notes |
|---|---|---|---|
| `delivery_id` | `string` | No | Test payload delivery identifier. |

## Authentication Modes

Supported forwarding auth modes:

- `SIGNATURE`
- `AUTH_HEADER`

### SIGNATURE

Headers added:

- `X-VT-Timestamp`: unix seconds
- `X-VT-Signature`: hex HMAC-SHA256 of:

`{timestamp}.{raw_request_body}`

Use your signing secret as HMAC key.

### AUTH_HEADER

Header added:

- `Authorization: Bearer <generated_token>`

Notes:

- Header value is generated by VoteTracker and shown once on creation/rotation.

## Delivery Headers

Every forwarded request includes:

- `User-Agent: VoteTracker-Forwarder/1.0`
- `X-VT-Delivery-Id: <delivery_id>`
- `X-VT-Attempt: <attempt_number>`

Also includes auth headers depending on mode.

## Retry, Timeout, and Backoff

Current fixed delivery behavior:

- Timeout: `5000ms` per attempt
- Max retries: `5` (in addition to initial attempt)
- Backoff:
  - retry 1: `10s`
  - retry 2: `30s`
  - retry 3: `2m`
  - retry 4: `10m`
  - retry 5: `30m`

Final state after max retries: `DEAD_LETTER`.

## Delivery Status Lifecycle

Outbox statuses:

- `PENDING`
- `PROCESSING`
- `DELIVERED`
- `FAILED`
- `DEAD_LETTER`

Semantics:

- `DELIVERED`: 2xx response received.
- `FAILED`: non-2xx or transport error, scheduled for retry.
- `DEAD_LETTER`: retry budget exhausted.

## Delivery Logs and Detail

Delivery list gives overview fields (`delivery_id`, status, http code, attempt, etc.).

Delivery detail includes the same plus:

- `request_body` (the forwarded JSON payload body)
- Delivery history and additional delivery diagnostics can be reviewed in the [Vote Tracker Dashboard](https://votetracker.bot/dashboard/api).

## Idempotency Recommendation

Use both ids for different dedupe layers:

- `X-VT-Delivery-Id` for transport-level dedupe (same delivery envelope/retry handling)
- `data.id` for business-level dedupe (same vote across forwarding + API backfill)

Recommended:

- Store processed vote ids (`data.id`) as the canonical processed key.
- Optionally store processed delivery ids for short-lived transport dedupe.
- Return 2xx for duplicates after local dedupe.

## Automated Giveaway Rewards

Run giveaways where your own bot hands out the prize automatically. Example loop for a card bot
paying out in-game currency:

1. Create a recurring giveaway through the API (`POST /api/v1/integrations/{integrationId}/events/giveaways`)
   with `external_reward` set to whatever your bot understands, for example `{"kind": "currency", "amount": 500}`,
   `repeating: true` and `activate: true`. The bot must be in the target server and the integration's
   linked owner needs Manage Server there.
2. Voters enter automatically, VoteTracker draws the winners when the giveaway ends.
3. Your receiver gets `EVENT_WINNERS_SELECTED` with the winners and your `external_reward` echoed verbatim.
   Grant the reward to each winner, dedupe on `event_id` plus `user_platform_id`.
4. Report nothing back. If a delivery ends in `DEAD_LETTER`, reconcile through
   `GET /api/v1/integrations/{integrationId}/events?status=COMPLETED` and the per-event detail endpoint,
   both include winners and `external_reward`.
5. When a winner is invalid, reroll via the dashboard or
   `POST /api/v1/integrations/{integrationId}/events/{eventId}/winners/{userPlatformId}/reroll`. You receive
   `EVENT_WINNER_REROLLED`, revoke the previous winner's reward and grant the new one.

Notes:

- `external_reward` and recurring schedules are premium features.
- VoteTracker never interprets `external_reward`, it is stored and echoed verbatim.
- Winners are immutable outside rerolls, and replaced users can never win the same giveaway again.
- Creating giveaways needs an API key with the explicit `events:write` scope.

## Recovery After Downtime

If your receiver is down longer than the forwarding retry window, some deliveries may end in `DEAD_LETTER`.

Recovery flow for votes:

1. Backfill via `GET /api/v1/integrations/{integrationId}/votes?start_date=...`.
2. Process each vote by canonical `id`.
3. Deduplicate against already processed webhook votes using the same `id`.

Recovery flow for giveaway winners:

1. List completed giveaways via `GET /api/v1/integrations/{integrationId}/events?status=COMPLETED`.
2. Fetch details via `GET /api/v1/integrations/{integrationId}/events/{eventId}`, winners and
   `external_reward` are included.
3. Deduplicate grants on `event_id` plus `user_platform_id`.

## Security Recommendations

- Verify signature for `SIGNATURE` mode.
- Validate the `Authorization` header value for `AUTH_HEADER` mode and treat it as a secret.
- Use HTTPS destination URLs.
- Avoid exposing webhook endpoints to the public internet without authentication checks.

## Operational Recommendations

- Monitor dead letters regularly.
- Re-enable forwarding only after destination is healthy.
- Keep webhook handler latency low; defer heavy processing to background jobs on your side.

We're here to help. Drop by the **[support server](https://votetracker.bot/support)** if you need anything.
