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

# Vote webhook

> We POST to your URL the moment somebody votes. Signed, retried five times over about two and a half hours.

Polling `/votes` tells you who voted since the last time you asked. The webhook
tells you *as it happens*, which is what you want if voting unlocks something in
your bot.

## Set it up

<Steps>
  <Step title="Add the URL">
    On your bot's edit page, under **Vote webhook**. It must be `https` on a
    public host — loopback and private addresses are refused, because a URL we
    cannot reach is a delivery queue that fills up forever.
  </Step>

  <Step title="Copy the secret">
    Generated with the URL. It is both the `Authorization` value we send and the
    HMAC key we sign with.
  </Step>

  <Step title="Verify, then act">
    Reject anything carrying neither a matching `Authorization` header nor a
    valid signature. Your endpoint is public; that check is the only thing
    standing between it and somebody handing your bot free rewards.
  </Step>
</Steps>

## What we send

```http theme={null}
POST https://your-bot.example.com/webhooks/votes
Content-Type: application/json
User-Agent: DiscordBotList-Webhook/1
Authorization: <your webhook secret>
X-Webhook-Event: vote.create
X-Webhook-Delivery: 8f14e45fceea167a5a36dedd
X-Webhook-Signature-256: sha256=<hmac of the raw body>

{
  "event": "vote.create",
  "bot_id": "1092194789458481202",
  "user_id": "336878018143223809",
  "voted_at": "2026-09-12T08:15:00.000Z"
}
```

| Header                    | What it is for                                                                                   |
| ------------------------- | ------------------------------------------------------------------------------------------------ |
| `Authorization`           | Your secret, verbatim. The simple check.                                                         |
| `X-Webhook-Signature-256` | `sha256=` plus the HMAC-SHA256 of the **raw** body, keyed with the same secret. The safer check. |
| `X-Webhook-Delivery`      | Stable per delivery and constant across retries. Use it to make your handler idempotent.         |
| `X-Webhook-Event`         | `vote.create` today, and the reason your handler should switch on it rather than assume.         |

## Verifying the signature

Compare in constant time, and hash the **raw** body — re-serialising the parsed
JSON changes the bytes and the signature will never match.

<CodeGroup>
  ```js Express theme={null}
  import crypto from 'node:crypto';
  import express from 'express';

  const app = express();

  app.post(
    '/webhooks/votes',
    express.raw({ type: 'application/json' }),
    (request, response) => {
      const expected =
        'sha256=' +
        crypto
          .createHmac('sha256', process.env.DBL_WEBHOOK_SECRET)
          .update(request.body)
          .digest('hex');
      const sent = request.header('X-Webhook-Signature-256') ?? '';

      // timingSafeEqual throws on a length mismatch, so check that first.
      const ok =
        sent.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));
      if (!ok) return response.status(401).end();

      const vote = JSON.parse(request.body.toString('utf8'));
      grantReward(vote.user_id);
      response.status(204).end();
    }
  );
  ```

  ```python FastAPI theme={null}
  import hmac, hashlib, os
  from fastapi import FastAPI, Request, Response

  app = FastAPI()

  @app.post("/webhooks/votes")
  async def votes(request: Request):
      raw = await request.body()
      expected = "sha256=" + hmac.new(
          os.environ["DBL_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256
      ).hexdigest()

      if not hmac.compare_digest(
          request.headers.get("x-webhook-signature-256", ""), expected
      ):
          return Response(status_code=401)

      vote = await request.json()
      grant_reward(vote["user_id"])
      return Response(status_code=204)
  ```
</CodeGroup>

## Retries

Any response outside `2xx`, a connection failure, or more than **10 seconds**
without a reply counts as a failure. We retry four times after the first
attempt:

<Steps>
  <Step title="1 minute" />

  <Step title="5 minutes" />

  <Step title="30 minutes" />

  <Step title="2 hours" />
</Steps>

Five attempts in total, spread over roughly two and a half hours; after that the
delivery is marked failed and we stop. The last ten attempts for your bot are
listed on its edit page with the status code each one got back, which is the
first place to look when a reward did not land.

<Warning>
  Redirects are **not** followed. A URL that 301s to the real handler reads as a
  failure — point us at the final address.
</Warning>

<Tip>
  Answer quickly and do the work afterwards. Return `204` as soon as the
  signature checks out and queue the reward; a handler that waits on your
  database is a handler that eventually crosses the 10-second timeout and gets
  the same vote delivered five times.
</Tip>

## Being delivered twice is normal

A timeout on our side and a success on yours look identical to us, so the same
`vote.create` can arrive again. Key your handling on `X-Webhook-Delivery`, or on
`user_id` plus `voted_at`, and make the second one a no-op.
