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

# Webhooks for real-time wallet transaction notifications

> Subscribe to wallet activity and receive real-time webhook callbacks the moment a watched wallet sends or receives a transaction.

Zerion webhooks push transaction data to your server the moment activity is detected on a watched wallet, with no polling required. You create a **subscription**, add wallet addresses, and Zerion sends a POST request to your callback URL for every new transaction.

<Info>
  Webhooks are part of the [Subscriptions to Transactions](/api-reference/subscriptions-to-transactions/create-subscription) API. This page explains how the system works. For a hands-on walkthrough, see the [Wallet Activity Alerts](/recipes/wallet-activity-alerts) recipe.
</Info>

## How it works

<Steps>
  <Step title="Create a subscription">
    Call [Create Subscription](/api-reference/subscriptions-to-transactions/create-subscription) with a `callback_url` and a list of wallet `addresses`. Optionally filter by `chain_ids` to limit which chains you monitor.
  </Step>

  <Step title="Zerion monitors the wallets">
    Zerion watches all specified wallets across the selected chains (or all supported chains if none are specified).
  </Step>

  <Step title="Your server receives notifications">
    When a watched wallet sends or receives a transaction, Zerion sends a POST request to your callback URL with the full transaction payload.
  </Step>
</Steps>

## Payload format

Every webhook notification is a POST request with a JSON body following the [JSON:API](https://jsonapi.org/) structure. The top-level `data` object describes the notification, while the `included` array contains the full transaction details.

```json theme={null}
{
  "data": {
    "id": "notification-id",
    "type": "callback",
    "attributes": {
      "timestamp": "2024-07-31T00:17:36Z",
      "callback_url": "https://example.com/callback",
      "address": "0x42b9df65b219b3dd36ff330a4dd8f327a6ada990"
    },
    "relationships": {
      "subscription": {
        "type": "tx-subscriptions",
        "id": "87db77a6-17eb-4ca8-af0e-e43cbe9c83c6"
      }
    }
  },
  "included": [
    {
      "type": "transactions",
      "id": "52d994a173d755e99845e861d534a419",
      "attributes": {
        "operation_type": "send",
        "hash": "0xabc123...",
        "mined_at": "2024-07-31T00:17:35Z",
        "mined_at_block": 7490818,
        "sent_from": "0x42b9df65b219b3dd36ff330a4dd8f327a6ada990",
        "sent_to": "0x1234567890abcdef1234567890abcdef12345678",
        "status": "confirmed",
        "nonce": 250,
        "fee": {
          "fungible_info": { "symbol": "ETH", "name": "Ethereum" },
          "quantity": { "float": 0.00042, "int": "420000000000000", "decimals": 18 }
        },
        "transfers": [
          {
            "direction": "out",
            "quantity": { "float": 0.5, "int": "500000000000000000", "decimals": 18 },
            "fungible_info": { "symbol": "ETH", "name": "Ethereum" }
          }
        ],
        "approvals": [],
        "flags": { "is_trash": false }
      },
      "relationships": {
        "chain": { "type": "chains", "id": "ethereum" },
        "dapp": { "type": "dapps", "id": "" }
      }
    }
  ]
}
```

### Key fields

| Field                                        | Description                                                                                                                                |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `data.attributes.address`                    | The watched wallet that triggered this notification                                                                                        |
| `data.relationships.subscription.id`         | The subscription this notification belongs to                                                                                              |
| `included[].attributes.operation_type`       | Transaction type: `send`, `receive`, `trade`, `execute`, etc.                                                                              |
| `included[].attributes.status`               | `confirmed` or `failed`                                                                                                                    |
| `included[].attributes.transfers`            | Array of token movements with direction, amount, and token info                                                                            |
| `included[].attributes.application_metadata` | Present only for recognized contract interactions; carries the dapp `name`, `contract_address`, and `method`. Omitted for plain transfers. |
| `included[].attributes.flags.is_trash`       | Whether Zerion classifies the transaction as spam. See note below.                                                                         |
| `included[].relationships.chain.id`          | The chain where the transaction occurred                                                                                                   |

<Info>
  The webhook **labels** every transaction with `flags.is_trash`; it does not drop spam from the stream. To suppress spam notifications, filter on this field in your handler (skip transactions where `is_trash` is `true`). This also lets you apply your own threshold. See the [Spam Filtering](/spam-filtering) guide for how the classification works.
</Info>

<Warning>
  Token prices are always `null` in webhook payloads. Prices are calculated asynchronously and are not available at the time of delivery. If you need prices, fetch them separately using the [Fungibles API](/api-reference/fungibles/get-fungible-asset-by-id).
</Warning>

### Contract interactions

When a transaction is a recognized contract interaction, the transaction object includes an `application_metadata` object:

```json theme={null}
"application_metadata": {
  "name": "Uniswap",
  "contract_address": "0x7a250d5630b4cf539739df2c5dacb4c659f2488d",
  "method": { "id": "0x18cbafe5", "name": "swapExactTokensForETH" }
}
```

It is **omitted** when the transaction is a plain transfer with no recognized contract interaction (as in the example above). Only contract and method metadata that Zerion already decodes is surfaced. Custom events emitted by your own contracts are not parsed into the payload. To read a custom event, fetch the transaction logs from your own node or a block explorer using the transaction `hash`.

## Signature verification

Every webhook request includes headers for verifying authenticity. Always verify signatures in production to ensure requests originate from Zerion.

| Header              | Description                            |
| ------------------- | -------------------------------------- |
| `X-Timestamp`       | ISO 8601 timestamp of the request      |
| `X-Signature`       | Base64-encoded RSA-SHA256 signature    |
| `X-Certificate-URL` | URL to download the public certificate |

### Verification steps

1. Concatenate the signing string: `${X-Timestamp}\n${request_body}\n`
2. Fetch the public certificate from the `X-Certificate-URL` header
3. Verify the `X-Signature` against the signing string using RSA-PKCS1v15 with SHA-256

<CodeGroup>
  ```python Python theme={null}
  import base64
  import requests
  from cryptography.x509 import load_pem_x509_certificate
  from cryptography.hazmat.primitives import hashes
  from cryptography.hazmat.primitives.asymmetric import padding

  def verify_webhook(timestamp, body, signature_b64, certificate_url):
      # Build the signing string
      signing_string = f"{timestamp}\n{body}\n"

      # Fetch and parse the certificate
      cert_pem = requests.get(certificate_url).content
      cert = load_pem_x509_certificate(cert_pem)
      public_key = cert.public_key()

      # Verify the signature
      signature = base64.b64decode(signature_b64)
      public_key.verify(
          signature,
          signing_string.encode(),
          padding.PKCS1v15(),
          hashes.SHA256()
      )
  ```

  ```javascript JavaScript theme={null}
  const crypto = require("crypto");

  async function verifyWebhook(timestamp, body, signatureB64, certificateUrl) {
    // Build the signing string
    const signingString = `${timestamp}\n${body}\n`;

    // Fetch the certificate
    const certResponse = await fetch(certificateUrl);
    const certPem = await certResponse.text();

    // Verify the signature
    const verifier = crypto.createVerify("RSA-SHA256");
    verifier.update(signingString);
    const isValid = verifier.verify(certPem, signatureB64, "base64");

    if (!isValid) {
      throw new Error("Invalid webhook signature");
    }
  }
  ```
</CodeGroup>

<Tip>
  Cache the certificate after the first fetch to avoid an extra HTTP call on every webhook. If signature verification fails, re-fetch the certificate in case it has rotated.
</Tip>

## Retry behavior

If your server returns a `5xx` error or the request times out, Zerion retries delivery up to **3 times**, spaced about **20 seconds apart** (roughly a 60-second window). After the final failed attempt, the notification is dropped permanently.

A `4xx` response is treated as acknowledged and is **not** retried.

To minimize missed notifications:

* Return a `200` response as quickly as possible, and process the payload asynchronously
* Keep your endpoint available with high uptime
* Monitor your endpoint for errors and slow responses

## Rollbacks

If a transaction is removed from the canonical chain (e.g., due to a chain reorganization), Zerion sends a second webhook for the same transaction with `deleted: true` set on the transaction resource inside `included`:

```json theme={null}
{
  "data": {
    "id": "notification-id",
    "type": "callback",
    "attributes": {
      "timestamp": "2024-07-31T00:18:12Z",
      "callback_url": "https://example.com/callback",
      "address": "0x42b9df65b219b3dd36ff330a4dd8f327a6ada990"
    }
  },
  "included": [
    {
      "type": "transactions",
      "id": "52d994a173d755e99845e861d534a419",
      "attributes": {
        "hash": "0xabc123...",
        "deleted": true
      }
    }
  ]
}
```

When `included[0].attributes.deleted` is `true`, the transaction has been rolled back and is no longer part of the canonical chain. Use the transaction `hash` to match it against the original notification and remove or mark it accordingly in your system.

<Warning>
  A single transaction can trigger two webhooks: one on initial confirmation and one on rollback. Make sure your handler accounts for this rather than assuming one webhook per transaction.
</Warning>

## Delivery guarantees

Zerion webhooks are **best-effort**:

* **Not guaranteed**: if all 3 delivery attempts fail, the notification is dropped
* **Order is not guaranteed**: notifications may arrive out of order relative to on-chain transaction ordering
* **Duplicates are possible**: your server should handle the same notification arriving more than once

Design your webhook handler to be **idempotent**: use the transaction `hash` and `chain` to deduplicate, and don't assume notifications arrive in chronological order.

## Subscription limits

|                          | Free plan | Paid plan |
| ------------------------ | --------- | --------- |
| Wallets per subscription | 5         | Unlimited |

On the free plan, each subscription can monitor up to **5 wallets**. On a paid plan, there is no limit, so you can add as many wallets as you need. The API accepts up to **100 wallets per request**, so for larger lists, batch your additions across multiple calls.

## Testing webhooks

Use [webhook.site](https://webhook.site) to get a temporary callback URL for testing:

1. Go to [webhook.site](https://webhook.site) and copy your unique URL
2. Create a subscription with that URL as the `callback_url`
3. Trigger a transaction on a watched wallet
4. Inspect the payload and headers on webhook.site

If you want to test with your own URL or move to production, go to the [Dashboard](https://dashboard.zerion.io) and click **Support** to request whitelisting for your callback URL.
