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

# Webhooks

> Receive a signed card.claimed event when Hoppier sets a reward aside for a participant, verify the signature, and handle retries.

Hoppier can send a signed HTTPS `POST` to your server when something happens in your account. There is one event today: `card.claimed`.

## card.claimed

Hoppier sends `card.claimed` when it sets a reward aside for a participant:

* straight away, if the person you added already has a Hoppier account, or
* when a new person accepts their invite and signs in for the first time.

> [!WARNING]
>
> `card.claimed` does **not** mean the person spent anything, opened an email or chose a gift card. Treat it as "Hoppier has set this reward aside for this person", nothing more.

* It's sent for **gift-card programs** only, for now.
* `data.amount` is the program's amount per person, in **dollars**. On a program where each person has their own amount, it's `0`.
* `data.card_type_specific.provider` and `external_id` are always `null` at this point, because the gift-card brand is chosen later.

```json theme={null}
{
  "id": "evt_su_aB3xK9d2Qe",
  "type": "card.claimed",
  "created_at": "2026-10-21T15:00:00.000Z",
  "company_id": "cpy_7Ym2p0Lx3Q",
  "data": {
    "card_type": "gift_card",
    "participant_id": "pcpt_R4k1LmQ8vB",
    "participant_email": "ada@example.com",
    "program_id": "prg_Z8q3NnW2xK",
    "amount": 25,
    "currency": "usd",
    "claimed_at": "2026-10-21T15:00:00.000Z",
    "card_type_specific": { "provider": null, "external_id": null }
  }
}
```

Every field is described in the [card.claimed reference](/api-reference/webhooks/card-claimed.md).

## Set up an endpoint

During the beta, Hoppier registers endpoints for you:

1. Email your HTTPS URL to [support@hoppier.com](mailto:support@hoppier.com).
2. We send you the endpoint's signing secret, which starts with `whsec_`. You get it once, so store it in your secrets manager.
3. Ask us for a test event. It looks like a real one, with an `id` that starts with `evt_test_` and sample data.

To change the URL, rotate the secret or stop deliveries, email us again.

## Verify the signature

Every request carries an `X-Hoppier-Signature` header:

```text theme={null}
X-Hoppier-Signature: t=1792602000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

1. Read the **raw** request body, before any JSON parsing.
2. Compute the HMAC-SHA256 of `<t>.<raw body>`, as a hex string. The key is your whole signing secret, `whsec_` prefix included.
3. Compare the result, in constant time, with each `v1=` value in the header. Accept the request if any of them matches. While a secret is being rotated there are two `v1=` values, one for each secret.
4. Reject the request if `t` is more than 300 seconds old. Hoppier signs with the time it sends, so this stops old requests from being replayed.

```javascript Node.js (Express) theme={null}
const express = require('express');
const crypto = require('crypto');

const app = express();
const SECRET = process.env.HOPPIER_WEBHOOK_SECRET; // the whsec_… value

app.post('/hoppier/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Hoppier-Signature') || '';
  const parts = header.split(',').map((kv) => kv.split('='));
  const t = Number(parts.find(([k]) => k === 't')?.[1]);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) {
    return res.status(400).end(); // missing or old timestamp
  }

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${t}.${req.body}`) // req.body is the raw Buffer
    .digest('hex');
  const valid = parts
    .filter(([k]) => k === 'v1')
    .some(
      ([, sig]) =>
        sig.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    );
  if (!valid) return res.status(401).end();

  const event = JSON.parse(req.body);
  // Retries reuse the same event id: skip events you've already handled.
  if (alreadyProcessed(event.id)) return res.status(200).end();
  handleEvent(event); // keep this fast, or queue the work

  return res.status(200).end();
});
```

```python Python (Flask) theme={null}
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["HOPPIER_WEBHOOK_SECRET"].encode()  # the whsec_… value


@app.post("/hoppier/webhooks")
def hoppier_webhook():
    header = request.headers.get("X-Hoppier-Signature", "")
    parts = [part.split("=", 1) for part in header.split(",") if "=" in part]
    timestamp = next((value for key, value in parts if key == "t"), "")
    signatures = [value for key, value in parts if key == "v1"]

    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        abort(400)  # missing or old timestamp

    raw_body = request.get_data()  # the raw bytes, before any JSON parsing
    expected = hmac.new(
        SECRET, timestamp.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    if not any(hmac.compare_digest(expected, sig) for sig in signatures):
        abort(401)

    event = json.loads(raw_body)
    # Retries reuse the same event id: skip events you've already handled.
    if already_processed(event["id"]):
        return "", 200
    handle_event(event)  # keep this fast, or queue the work
    return "", 200
```

## Retries and duplicates

* Answer with any `2xx` status within 10 seconds. Do slow work after you've answered.
* Anything else (another status, a timeout, a network error) is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. After the last retry Hoppier stops and our team is alerted. Email us if you need an event sent again.
* The same event can arrive more than once. Ignore any event whose `id` you've already processed.
* Events can arrive out of order.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.