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

# Webhook Events and Payload Structure

> Learn how Off the Hook structures webhook event payloads, what fields every delivery contains, and how to use the event ID for deduplication.

Every webhook delivery from Off the Hook is a Standard Webhooks-compliant HTTP POST to your destination URL. The body is a JSON envelope with a consistent shape across all event types: a unique event ID, a kind string that identifies what happened, a timestamp, and a `data` object containing the chain-specific transfer details. Your endpoint can use these fields to identify the event, verify it hasn't been processed before, and extract the transfer information you need.

## Payload structure

```json theme={null}
{
  "id": "evt_8xN9kP2QbA...",
  "kind": "wallet.transfer.broadcasted",
  "date": "2026-05-08T12:34:56Z",
  "data": {
    "chainId": "tron:mainnet",
    "blockNumber": 78912345,
    "blockHash": "00000000...",
    "blockTimestamp": 1730000000000,
    "txId": "abc123...",
    "logIndex": 0,
    "from": "TLsV52sRDL79HXGGm9yzwKibb6BeruhUzy",
    "to": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
    "amount": "1500000000",
    "asset": {
      "type": "fungible",
      "contract": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
      "symbol": "USDT",
      "decimals": 6
    },
    "matchedAddresses": [
      {
        "chainId": "tron:mainnet",
        "address": "TLsV52sRDL79HXGGm9yzwKibb6BeruhUzy",
        "role": "from"
      }
    ]
  }
}
```

## Envelope fields

<ResponseField name="id" type="string" required>
  Unique event identifier, prefixed `evt_`. This ID is stable across retries — if Off the Hook retries a failed delivery, the same `id` appears in every attempt. Use it as your idempotency key to avoid processing the same transfer twice.
</ResponseField>

<ResponseField name="kind" type="string" required>
  The event type string, such as `"wallet.transfer.broadcasted"`. Your subscription only receives events whose kinds appear in its `events` array. See [event types](/reference/event-types) for all supported values.
</ResponseField>

<ResponseField name="date" type="string" required>
  ISO 8601 timestamp of when the event was generated.
</ResponseField>

## Transfer data fields

<ResponseField name="data.chainId" type="string" required>
  CAIP-2 chain identifier for the network where the transfer occurred, such as `"tron:mainnet"`. See [supported networks](/concepts/chains).
</ResponseField>

<ResponseField name="data.blockNumber" type="number" required>
  The block number that included this transfer.
</ResponseField>

<ResponseField name="data.blockHash" type="string" required>
  The hash of the block that included this transfer.
</ResponseField>

<ResponseField name="data.blockTimestamp" type="number" required>
  Unix timestamp of the block in milliseconds.
</ResponseField>

<ResponseField name="data.txId" type="string" required>
  The TRON transaction ID.
</ResponseField>

<ResponseField name="data.logIndex" type="number" required>
  Position of this transfer within the block. For TRC-20 tokens this is the index of the `Transfer` log event (0-based). For native TRX transfers this is always `-1` — a deliberate sentinel that avoids colliding with the first TRC-20 log in the same transaction.
</ResponseField>

<ResponseField name="data.from" type="string" required>
  Sender address in canonical TRON base58check format (starts with `T`).
</ResponseField>

<ResponseField name="data.to" type="string" required>
  Recipient address in canonical TRON base58check format.
</ResponseField>

<ResponseField name="data.amount" type="string" required>
  Raw integer transfer amount as a string. Divide by `10^asset.decimals` to get the human-readable value. For example, `"1500000000"` with `decimals: 6` is 1,500 USDT.
</ResponseField>

<ResponseField name="data.asset" type="object" required>
  Describes the transferred token.

  <Expandable title="asset fields">
    <ResponseField name="data.asset.type" type="string" required>
      `"native"` for TRX, or `"fungible"` for TRC-20 tokens.
    </ResponseField>

    <ResponseField name="data.asset.contract" type="string | null" required>
      Contract address for TRC-20 tokens. `null` for native TRX.
    </ResponseField>

    <ResponseField name="data.asset.symbol" type="string | null">
      Token symbol, such as `"USDT"`. May be `null` for TRC-20 contracts whose metadata Off the Hook has not indexed yet — in that case read the raw `amount` directly.
    </ResponseField>

    <ResponseField name="data.asset.decimals" type="number | null">
      Decimal precision of the token. May be `null` for unknown TRC-20 contracts.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="data.matchedAddresses" type="object[]" required>
  The addresses from your subscription's filter list that caused this event to fire. Each entry includes the chain, the address, and the `role` it played in the transfer: `"from"` if the address sent the funds, or `"to"` if it received them. A single transfer can match the same address in both roles if your filter includes both sender and recipient.
</ResponseField>

## Deduplication

Off the Hook retries deliveries on `5xx`, `408`, `429`, and network errors using a backoff schedule of 200ms, 1s, 5s, 1min, 5min, 30min, and 2h. Every attempt — original and retries — carries the same `webhook-id` header, which matches the `id` field in the payload body.

To avoid processing the same transfer twice, record the `webhook-id` of every delivery you successfully handle and skip any delivery whose ID you've already seen.

```typescript theme={null}
app.post('/webhooks', async (req, res) => {
  const eventId = req.headers['webhook-id'];

  if (await db.eventAlreadyProcessed(eventId)) {
    return res.status(200).send('already processed');
  }

  // handle the event
  await processTransfer(req.body);
  await db.markEventProcessed(eventId);

  res.status(200).send('ok');
});
```

## Delivery headers

Each POST request Off the Hook sends includes the following headers:

| Header                  | Value                               |
| ----------------------- | ----------------------------------- |
| `content-type`          | `application/json`                  |
| `webhook-id`            | Event ID (e.g. `evt_8xN9kP2QbA...`) |
| `webhook-timestamp`     | Unix timestamp (seconds)            |
| `webhook-signature`     | `v1,<base64-hmac-sha256>`           |
| `x-oth-subscription-id` | Your subscription ID                |
| `user-agent`            | `OffTheHook/0.1`                    |

During a secret rotation grace window, the `webhook-signature` header contains multiple `v1,` values separated by spaces — one for each active secret. A valid signature from any of them means the delivery is authentic.

## Next steps

* [Event types](/reference/event-types) — full catalog of supported event kinds
* [Verifying signatures](/guides/verify-signatures) — how to validate the HMAC signature on each delivery
