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

# Informational webhooks

> Get notified about on-chain transactions and wallet lifecycle events for your Portal clients.

Informational webhooks tell your server that something happened: a client received tokens, approved a token spender, created a wallet, or ejected it. The event has already happened when Portal sends it, so nothing waits on your response. Return a `2xx` and Portal marks the event as delivered.

Each environment has one informational webhook. It sends every event you subscribe to to a single endpoint URL.

* To create the webhook, follow [Set up informational webhooks](/resources/informational-webhooks/set-up).
* For what each event means and its payload, see [Informational webhook events](/resources/informational-webhooks/events).

## How delivery works

Portal sends each event as a `POST` request with a JSON body:

```json theme={null}
{
  "data": {},
  "metadata": {
    "environmentId": "environmentId"
  },
  "type": "WALLET_GENERATE_V1"
}
```

* `type` is the event type. Use it to route every event from one handler.
* `data` holds the event itself. Its shape depends on `type`.
* `metadata` always includes `environmentId`. Every event except the Solana events also includes `custodianId`.

### Headers

| Name | Value |
| - | - |
| `Content-Type` | `application/json` |
| `X-WEBHOOK-SECRET` | The secret shown on the **Informational** page of the [Portal Admin Dashboard](https://app.portalhq.io). |

<Warning>
  `X-WEBHOOK-SECRET` carries the secret itself. Portal does not sign the request body. Compare the header to your stored secret on every request, and reject requests that don't match.
</Warning>

### Responses and retries

Return a `2xx` within 10 seconds. Portal only reads the status code and ignores the response body.

If your server returns anything else, or doesn't respond in time, Portal retries the event with exponential backoff. Once the retries run out, the event is marked `FAILED` and you can [resend it](#resend-an-event).

`Wallet ejected` events are the exception: Portal makes one attempt and doesn't retry them automatically. Resend them from the event log if your server missed one.

Respond first and process the event afterwards. If your processing takes longer than 10 seconds, Portal counts the delivery as failed and sends the event again. Portal doesn't guarantee that events arrive in the order they happened.

### Delivery statuses

Portal records every event it sends as a delivery event, with one of these statuses:

| Status | Meaning |
| - | - |
| `PENDING` | Portal hasn't delivered the event yet, or is still retrying it. |
| `DELIVERED` | Your server returned a `2xx` on the latest attempt. |
| `FAILED` | The automatic retries ran out without a `2xx`, or a resend failed. |
| `REPLAYING` | A resend is in progress. |

```mermaid theme={null}
stateDiagram-v2
    [*] --> PENDING
    PENDING --> DELIVERED
    PENDING --> FAILED
    PENDING --> REPLAYING
    FAILED --> REPLAYING
    REPLAYING --> DELIVERED
    REPLAYING --> FAILED
    DELIVERED --> [*]
```

A resend is a single attempt with no automatic retries after it. If you resend an event that is still `PENDING`, Portal stops its automatic retries, and the resend becomes the event's last attempt.

## Avoid processing an event twice

Your server can receive the same event more than once:

* Retries and resends send the original payload again, unchanged.
* An EVM transaction arrives first with `confirmed: false` and again with `confirmed: true`.

The request has no event ID, so build an idempotency key from the payload. Two items describe the same transfer for the same wallet when these fields match:

| Event | Idempotency key |
| - | - |
| EVM wallet transactions | `data[].chainId`, `data[].metadata.userOperationHash` (or `data[].metadata.transactionHash` when it's `null`), `data[].metadata.triggeredBy`, `data[].to`, `data[].metadata.tokenAddress` |
| Solana wallet transactions | `data.rawEvents[].signature`, `data.rawEvents[].triggeredBy` |
| Solana token approvals, token revocations, and delegated transfers | `type`, `data.rawEvents[].signature`, `data.rawEvents[].triggeredBy` |
| [Archived Solana events](/resources/informational-webhooks/events#archived-events) | `type`, `data.rawEvents[].signature` |

For account abstraction wallets, prefer `userOperationHash`. A bundler can put several user operations in one transaction, so the transaction hash alone isn't unique. `userOperationHash` is the hash that [broadcast a user operation](/api-reference/transactions/broadcast-a-user-operation) returns, and it's `null` for transactions that aren't user operations. A batched user operation produces several `data[]` items with the same `userOperationHash`, so keep `to` and `tokenAddress` in the key.

For EVM events, treat the `confirmed: true` event as a status update to the `confirmed: false` event with the same key.

To process a transaction once instead of once per wallet, drop `triggeredBy` from the key. See the next section.

## Transfers between your own wallets

When both the sender and the receiver are addresses Portal watches for you, such as two of your clients or a client and one of your [external addresses](#external-addresses), one transfer produces two events:

* An `OUTBOUND` event for the sender.
* An `INBOUND` event for the receiver.

`from` and `to` are the same in both events, because they describe the on-chain transfer. Use `direction` and `triggeredBy` to tell the events apart. `triggeredBy` is the watched address the event was sent for: `data[].metadata.triggeredBy` on EVM events, and `data.rawEvents[].triggeredBy` on Solana events.

To match the two events, use `data[].metadata.transactionHash` and `data[].metadata.blockNumber` on EVM, or `data.rawEvents[].signature` on Solana.

If only one side of the transfer is watched, you get one event. The same rule applies to token approvals, revocations, and delegated transfers.

The example below shows one 1 USDC transfer on Ethereum Sepolia between two watched addresses. Only `direction` and `triggeredBy` differ.

<Tabs>
  <Tab title="OUTBOUND (sender)">
    ```json theme={null}
    {
      "data": [
        {
          "to": "0x14cea4af1ad06721ad4ae04ab1233e25e63575f0",
          "from": "0x04e15c61b2a9c8c54f29c9bbcb83c3e3826514e4",
          "amount": "1",
          "chainId": "eip155:11155111",
          "assetType": "NON_NATIVE_TOKEN",
          "chainName": "sepolia",
          "direction": "OUTBOUND",
          "tokenSymbol": "USDC",
          "metadata": {
            "sentAt": "2025-03-17T18:32:00.000Z",
            "confirmed": true,
            "rawAmount": "1000000",
            "tokenName": "USDC",
            "blockNumber": "12345678",
            "triggeredBy": "0x04e15c61b2a9c8c54f29c9bbcb83c3e3826514e4",
            "tokenAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238",
            "tokenDecimals": 6,
            "transactionHash": "0xabc123def4567890abc123def4567890abc123def4567890abc123def4567890",
            "userOperationHash": null
          }
        }
      ],
      "metadata": {
        "custodianId": "custodianId",
        "environmentId": "environmentId"
      },
      "type": "EIP_155_TX_V1"
    }
    ```
  </Tab>

  <Tab title="INBOUND (receiver)">
    ```json theme={null}
    {
      "data": [
        {
          "to": "0x14cea4af1ad06721ad4ae04ab1233e25e63575f0",
          "from": "0x04e15c61b2a9c8c54f29c9bbcb83c3e3826514e4",
          "amount": "1",
          "chainId": "eip155:11155111",
          "assetType": "NON_NATIVE_TOKEN",
          "chainName": "sepolia",
          "direction": "INBOUND",
          "tokenSymbol": "USDC",
          "metadata": {
            "sentAt": "2025-03-17T18:32:00.000Z",
            "confirmed": true,
            "rawAmount": "1000000",
            "tokenName": "USDC",
            "blockNumber": "12345678",
            "triggeredBy": "0x14cea4af1ad06721ad4ae04ab1233e25e63575f0",
            "tokenAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238",
            "tokenDecimals": 6,
            "transactionHash": "0xabc123def4567890abc123def4567890abc123def4567890abc123def4567890",
            "userOperationHash": null
          }
        }
      ],
      "metadata": {
        "custodianId": "custodianId",
        "environmentId": "environmentId"
      },
      "type": "EIP_155_TX_V1"
    }
    ```
  </Tab>
</Tabs>

## External addresses

Portal watches every address of your clients by default. Add an external address to also get transaction events for an address Portal doesn't manage, such as a treasury wallet or an exchange deposit address.

<Note>
  External addresses currently support Solana only. EVM support is coming soon.
</Note>

To add one in the dashboard:

1. Open **Webhooks** > **Informational** in the [Portal Admin Dashboard](https://app.portalhq.io).
2. Under **External addresses**, click **Add +**.
3. Enter the Solana address and click **Add**.

<Frame>
  <img src="https://mintlify.s3.us-west-1.amazonaws.com/portal-003221ec/images/webhooks/external-addresses.png" alt="External addresses section of the Informational page with a list of Solana addresses and the Add button" />
</Frame>

To remove one, click **Delete** next to it. Portal stops sending events for the address immediately.

You can also manage external addresses with the Custodian API:

* [`GET /custodians/me/alerts/webhooks/external-addresses`](/api-reference/informational-webhooks/list-external-addresses)
* [`POST /custodians/me/alerts/webhooks/external-addresses`](/api-reference/informational-webhooks/create-an-external-address)
* [`DELETE /custodians/me/alerts/webhooks/external-addresses/{externalAddressId}`](/api-reference/informational-webhooks/delete-an-external-address)

```json theme={null}
{
  "address": "So11111111111111111111111111111111111111112",
  "namespace": "solana"
}
```

Portal rejects malformed addresses and burn addresses. It also rejects Solana addresses with too many recent transactions to watch automatically. Contact our team to add one of those.

## Tunnel URLs

If your endpoint URL points to a tunneling service such as ngrok, trycloudflare, or webhook.site, Portal turns the webhook off when deliveries keep failing: right away if the domain no longer resolves, or after 10 failed deliveries in a row. While it's off, Portal marks new events as `FAILED` without sending them, and resends are rejected.

To turn it back on, [change the endpoint](#edit-the-endpoint) to a URL that works. Then resend the events you missed.

## Recognizing test events

The dashboard can send a test event of any type you're subscribed to. Test events go through the same delivery queue as real ones, with the same headers and body shape, and the request body has no field that marks them as tests. You can recognize them by these values:

| Field | Test value |
| - | - |
| `clientId` | `00000000-0000-4000-8000-000000000001` |
| `walletId` | `00000000-0000-4000-8000-000000000002` |
| EVM addresses | `0x000000000000000000000000000000000000dead` and `0x000000000000000000000000000000000000beef` |
| EVM transaction hash | `0x0000000000000000000000000000000000000000000000000000000000000001` |
| Solana address | `11111111111111111111111111111111` |
| Solana signature | 88 `1` characters |

Test events show up in the event log with a **Test** badge and don't count toward the 24-hour failure totals.

## Manage in the dashboard

Open **Webhooks** > **Informational** in the [Portal Admin Dashboard](https://app.portalhq.io).

<Frame>
  <img src="https://mintlify.s3.us-west-1.amazonaws.com/portal-003221ec/images/webhooks/informational-page.png" alt="Informational page showing the endpoint and secret, the event log summary, the subscribed events, the test event section, and external addresses" />
</Frame>

The page shows:

* **Endpoint** and **Secret header**.
* **Event log**: how many events Portal sent in the last 24 hours and how many failed.
* **Subscribed events**: the events you receive, grouped as they are in [Informational webhook events](/resources/informational-webhooks/events).
* **Test Informational Event**: send a sample of any subscribed event.
* **External addresses**.

### Edit the endpoint

Click the pencil icon next to **Endpoint**, enter the new URL, and click **Save endpoint**. The URL must start with `https://`. Events sent after you save go to the new URL.

### Change subscribed events

Check or uncheck events under **Subscribed events**. Changes show a **Will be added** or **Will be removed** badge until you click **Save changes**.

* Solana token approvals, token revocations, and delegated transfers require **Wallet transactions** in the same group. They're disabled until it's checked.
* If you uncheck every event, Portal stops sending you informational events.
* If you subscribe to both the current and the [archived Solana events](/resources/informational-webhooks/events#archived-events), each Solana transaction sends events for both versions.

### Rotate the secret

Click **Rotate** next to **Secret header**, type a new secret or click **Generate**, and click **Save secret**. Secrets must be 32 to 256 printable ASCII characters with no whitespace. Revealing the secret requires two-factor authentication.

<Warning>
  Saving a new secret invalidates the old one immediately. Update your server to accept both the old and the new value before you save, so no deliveries fail in between.
</Warning>

### Send a test event

Under **Test Informational Event**, pick a subscribed event and click **Send test event**. The page shows the body Portal sends. Delivery happens in the background, so check the event log or your server's logs for the result.

### Event log

Click **View events →** to see every event Portal sent to your endpoint in the last 30 days.

<Frame>
  <img src="https://mintlify.s3.us-west-1.amazonaws.com/portal-003221ec/images/webhooks/informational-events-page.png" alt="Informational events page with the Event type, Status, From, and To filters and a list of events showing type, status, attempts, and sent time" />
</Frame>

Filter by **Event type**, **Status**, and date range with **From** and **To**. Each row shows the event type, its [status](#delivery-statuses), the number of attempts, and when it was sent. Expand a row to see the last status code your server returned and the event payload.

### Resend an event

Click **Resend** on a failed event and confirm. Portal sends the stored event to your endpoint again with the same payload.

<Warning>
  Your server may process a resent event twice. Make sure your handler is [idempotent](#avoid-processing-an-event-twice) before you resend.
</Warning>

## Custodian API

You can read your webhook and its delivery events, and resend events, with the Custodian API:

* [`GET /custodians/me/alerts/webhooks`](/api-reference/informational-webhooks/list-informational-webhooks) lists your webhook, including whether it's turned off and why.
* [`GET /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events`](/api-reference/informational-webhooks/list-informational-webhook-delivery-events) lists delivery events. Filter with `statuses=FAILED` to find events that need a resend.
* [`POST /custodians/me/alerts/webhooks/{alertWebhookId}/delivery-events/{deliveryEventId}/retries`](/api-reference/informational-webhooks/retry-an-informational-webhook-delivery-event) resends one event. It returns `429` when 3 resends are already in progress, and `409` when the webhook is turned off.

## Security

* Endpoint URLs must use HTTPS.
* Check the `X-WEBHOOK-SECRET` header on every request.
* Only accept requests from Portal's IP addresses: `35.203.150.117`, `104.155.171.139`, and `35.185.20.23`.


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