> ## 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.

# Flow control hooks

> Approve signatures and store backup shares on your own server before Portal continues.

Flow control hooks are requests Portal makes to your server in the middle of a wallet operation. Portal waits for your response, and the response decides what happens: a signature goes through or is rejected, a backup is stored or fails, a recovery gets its share or fails.

You configure one base URL, and Portal appends a fixed path for each hook:

| Hook | Path | Portal calls it when | Your response decides |
| - | - | - | - |
| Signature approvals | `/sign-approval` | A client is about to sign | Whether the signature proceeds |
| Store a backup share | `/backup` | A client backs up a wallet | Whether the backup succeeds |
| Return a backup share | `/backup/fetch` | A client recovers a wallet, and right after every `/backup` call | Whether the recovery gets its share |

Signature approvals and self-managed backups are turned on and off separately. To create the configuration, follow [Set up flow control hooks](/resources/flow-control-hooks/set-up).

<Note>
  Signature approvals used to be the `PRE_SIGN_V1` informational webhook event. Existing `PRE_SIGN_V1` subscriptions were moved to flow control automatically. If you were subscribed to `PRE_SIGN_V1`, your endpoint keeps working as before: Portal still calls the same URL with the same request body, the same `X-WEBHOOK-SECRET` header, and the same secret.
</Note>

## Request headers

Every flow control hook request includes these headers:

| Name | Value |
| - | - |
| `Content-Type` | `application/json` |
| `Accept` | `application/json` |
| `X-Webhook-Secret` | The secret shown in the **Flow control** page of the [Portal Admin Dashboard](https://app.portalhq.io). |

All hooks use the same secret, unless you used `PRE_SIGN_V1` before. In that case `/sign-approval` keeps the secret it had and the backup hooks use a different one, so check each path against its own secret. See [Secrets](#secrets).

<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>

## Signature approvals

When signature approvals are on, Portal calls `POST [baseUrl]/sign-approval` before every signature any of your clients makes, and holds the signature until your server answers.

### Request body

| Field | Type | Description |
| - | - | - |
| `data.chainId` | String or `null` | The CAIP-2 chain ID of the signing request, for example `eip155:1`. `null` when the request has no valid chain ID, such as a raw sign. |
| `data.clientId` | String | The Portal client that is signing. |
| `data.signingRequest` | Object | The signing request the client sent. It includes `method` and `params`, and can include other fields such as `hash` and `curve`. |
| `metadata.custodianId` | String | Your custodian ID. |
| `metadata.environmentId` | String | The environment the client belongs to. |
| `metadata.signatureApprovalMemo` | String | Optional. A memo the client passed with the signing request. |
| `type` | String | Always `PRE_SIGN_V1`. |

<Tabs>
  <Tab title="EVM">
    ```json theme={null}
    {
      "data": {
        "chainId": "eip155:11155111",
        "clientId": "clientId",
        "signingRequest": {
          "method": "eth_sendTransaction",
          "params": "{\"from\":\"0xec445db8df2208dde9b5ad87e77b6a4d45855d4f\",\"to\":\"0xdFd8302f44727A6348F702fF7B594f127dE3A902\",\"value\":\"0x5af3107a4000\"}"
        }
      },
      "metadata": {
        "custodianId": "custodianId",
        "environmentId": "environmentId",
        "signatureApprovalMemo": "optional memo"
      },
      "type": "PRE_SIGN_V1"
    }
    ```
  </Tab>

  <Tab title="Solana">
    ```json theme={null}
    {
      "data": {
        "chainId": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1",
        "clientId": "clientId",
        "signingRequest": {
          "method": "sol_signAndSendTransaction",
          "params": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAED4QZTgurPkrah5b0uP9SoRjT2+hCnv/1KSr7nRZWS8aJaUV/q8rCkYm5Cw38QFa6J0WVbD1/rY7eCTuE2Bz8jVgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgje1UaxreFr4adOu8Z+RYX073+qUrItkboInHuIhJsQBAgIAAQwCAAAAQEIPAAAAAAA="
        }
      },
      "metadata": {
        "custodianId": "custodianId",
        "environmentId": "environmentId",
        "signatureApprovalMemo": "optional memo"
      },
      "type": "PRE_SIGN_V1"
    }
    ```
  </Tab>

  <Tab title="Tron">
    ```json theme={null}
    {
      "data": {
        "chainId": "tron:nile",
        "clientId": "clientId",
        "signingRequest": {
          "method": "tron_sendTransaction",
          "params": "CgJIziIID1+8YyspzttA0JCXytIyWmYIARJiCi10eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyYW5zZmVyQ29udHJhY3QSMQoVQWbePJYEuac1QStQiExU4/oKJzizEhVBAtvp5WmlMcd8vqwl2REpbKTUQ6EY6Adw8LuTytIy"
        }
      },
      "metadata": {
        "custodianId": "custodianId",
        "environmentId": "environmentId",
        "signatureApprovalMemo": "optional memo"
      },
      "type": "PRE_SIGN_V1"
    }
    ```
  </Tab>

  <Tab title="Stellar">
    ```json theme={null}
    {
      "data": {
        "chainId": "stellar:testnet",
        "clientId": "clientId",
        "signingRequest": {
          "method": "stellar_sendTransaction",
          "params": "AAAAAgAAAADhBlOC6s+StqHlvS4/1KhGNPb6EKe//UpKvudFlZLxogAAAGQAEwrRAAAAAQAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAADNomLKKfoL22U8hEmltigZsRZo6o6YhQHvKPjsDTu5yAAAAAAAAAAAAAAnEAAAAAAAAAAA"
        }
      },
      "metadata": {
        "custodianId": "custodianId",
        "environmentId": "environmentId",
        "signatureApprovalMemo": "optional memo"
      },
      "type": "PRE_SIGN_V1"
    }
    ```
  </Tab>
</Tabs>

### Response

Portal only reads the status code. The response body is ignored.

| Your response | Result |
| - | - |
| Any `2xx` | The signature proceeds. |
| `400` | The signature is rejected. The client's signing request fails with `Signature approval rejected`. |
| Any other status, no response within 10 seconds, or a network error | The signature fails. The client's signing request fails with `Failed to request signature approval`. |

Portal makes one attempt per signature and doesn't retry. If your server is down, every signature fails until it comes back or you turn signature approvals off.

## Self-managed backups

By default, Portal stores the custodian backup share for each of your clients. With self-managed backups, Portal sends that share to your server instead, and asks for it back when a client recovers. Portal only holds the share in memory while a backup or recovery runs.

If your environment uses Portal-managed backups, the dashboard hides these hooks and you don't need to build them.

### `POST [baseUrl]/backup`

Portal calls `/backup` when a client backs up a wallet. The request body has three fields:

| Field | Type | Description |
| - | - | - |
| `backupMethod` | String | The backup method, for example `GDRIVE`, `ICLOUD`, `PASSWORD`, `PASSKEY`, `FIREBASE`, `CUSTOM`, or `UNKNOWN`. Newer SDKs add the curve, as in `GDRIVE-SECP256K1` or `GDRIVE-ED25519`. Older SDKs omit this field. Store it as a plain string, because new values can appear. |
| `clientId` | String | The Portal client the share belongs to. |
| `share` | String | The custodian backup share, as a JSON string. |

Store the share by `clientId` and `backupMethod`, replacing any share you already hold for that pair, then respond with a `2xx` status such as `200`. Any other response, or no response within 10 seconds, fails the backup.

Right after a successful `/backup` call, Portal calls `/backup/fetch` for the same client to check that the share was stored. If the share Portal gets back doesn't match the one it sent, the backup fails.

<Warning>
  Store every share Portal sends you, even for curves you don't use yet. If you only support Solana and receive both `GDRIVE-SECP256K1` and `GDRIVE-ED25519`, store both. Recovery needs all of them.
</Warning>

<Warning>
  The backup share protects your clients' wallets. Don't log it or the request body, encrypt it at rest, and restrict who can read the table it lives in.
</Warning>

### `POST [baseUrl]/backup/fetch`

Portal calls `/backup/fetch` when a client recovers a wallet, and right after every `/backup` call. The request body has one field, `clientId`.

Respond with `200` and every custodian backup share you hold for that client, exactly as you received them in `/backup`:

```json theme={null}
{
  "backupShares": ["{...}", "{...}"]
}
```

Portal picks the share whose `backupSharePairId` matches the backup the client is recovering from. A single share in a `backupShare` string field also works, but `backupShares` lets Portal choose. If neither field is present, or no share matches, the recovery fails.

### Example database model

With self-managed backups you store two kinds of share, both unique by client and backup method:

* The **custodian backup share**, which Portal sends to `/backup`.
* The **encrypted client backup share** (`cipherText`), which your app receives from the SDK's backup method and sends to your backend. Your app fetches it again before calling the SDK's recover method.

```prisma theme={null}
model CustodianBackupShare {
  id           String   @id @default(cuid())
  clientId     String
  backupMethod String   @default("UNKNOWN")
  share        String
  createdAt    DateTime @default(now())

  @@unique([clientId, backupMethod])
}

model ClientBackupShare {
  id           String   @id @default(cuid())
  clientId     String
  backupMethod String   @default("UNKNOWN")
  cipherText   String
  createdAt    DateTime @default(now())

  @@unique([clientId, backupMethod])
}
```

How your app stores the encrypted client backup share depends on the platform. See the backup guide for your SDK, for example [Back up a wallet](/sdks/web/guide/back-up-a-wallet).

## Example server

This Express server handles all three hooks and checks the secret header on every request:

```typescript theme={null}
import crypto from 'crypto'
import express, { NextFunction, Request, Response } from 'express'
import { PrismaClient } from '@prisma/client'

const db = new PrismaClient()
const app = express()
app.use(express.json())

const requireEnv = (name: string): string => {
  const value = process.env[name]
  if (!value) {
    throw new Error(`${name} is not set`)
  }
  return value
}

const WEBHOOK_SECRET = requireEnv('PORTAL_FLOW_CONTROL_SECRET')
const ENCRYPTION_KEY = Buffer.from(requireEnv('BACKUP_SHARE_ENCRYPTION_KEY'), 'base64')

const encrypt = (plaintext: string): string => {
  const iv = crypto.randomBytes(12)
  const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv)
  const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
  return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64')
}

const decrypt = (encrypted: string): string => {
  const data = Buffer.from(encrypted, 'base64')
  const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, data.subarray(0, 12))
  decipher.setAuthTag(data.subarray(12, 28))
  return Buffer.concat([decipher.update(data.subarray(28)), decipher.final()]).toString('utf8')
}

const verifySecret = (req: Request, res: Response, next: NextFunction) => {
  const received = Buffer.from(req.get('X-Webhook-Secret') ?? '')
  const expected = Buffer.from(WEBHOOK_SECRET)

  if (
    received.length !== expected.length ||
    !crypto.timingSafeEqual(received, expected)
  ) {
    return res.status(401).send()
  }

  next()
}

app.use(verifySecret)

app.post('/sign-approval', (req: Request, res: Response) => {
  const { clientId, signingRequest } = req.body.data

  if (isAllowed(clientId, signingRequest)) {
    return res.status(200).send()
  }

  res.status(400).send()
})

app.post('/backup', async (req: Request, res: Response) => {
  const { backupMethod = 'UNKNOWN', clientId, share } = req.body
  const encryptedShare = encrypt(share)

  await db.custodianBackupShare.upsert({
    where: { clientId_backupMethod: { clientId, backupMethod } },
    create: { backupMethod, clientId, share: encryptedShare },
    update: { share: encryptedShare },
  })

  res.status(200).send()
})

app.post('/backup/fetch', async (req: Request, res: Response) => {
  const { clientId } = req.body

  const rows = await db.custodianBackupShare.findMany({ where: { clientId } })

  res.status(200).json({ backupShares: rows.map((row) => decrypt(row.share)) })
})

const isAllowed = (clientId: string, signingRequest: { method?: string }) =>
  signingRequest.method !== 'eth_signTypedData_v4'

app.listen(3000)
```

Replace `isAllowed` with your own approval rules. `BACKUP_SHARE_ENCRYPTION_KEY` is a base64-encoded 32-byte key. In production, keep it in a key management service rather than an environment variable.

## Recognizing test calls

The dashboard can send a test call to each hook. Test calls use the same headers and body shape as real calls, and the request body has no field that marks them as tests. You can recognize them by these values:

| Field | Test value |
| - | - |
| `clientId` (every hook) | `00000000-0000-4000-8000-000000000001` |
| `share` (`/backup`) | A JSON string whose `share` is `PORTAL_TEST_SHARE_NOT_RECOVERABLE` |
| `backupMethod` (`/backup`) | `PASSWORD` |
| `data.signingRequest.params` (`/sign-approval`) | A transfer from `0x000000000000000000000000000000000000dead` to `0x000000000000000000000000000000000000beef` on `eip155:1` |

<Warning>
  A test call to `/backup` reaches your real backup storage with a fake share for a fake client. Store it or ignore it, but respond with a `2xx` so the test passes. The share can't recover any wallet.
</Warning>

Test calls show up in the flow control event log with a **Test** badge and don't count toward the 24-hour stats or hook status.

## Manage in the dashboard

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

The page shows:

* **Calls · 24h**, **Approved**, **Rejected**, and **Failed**: counts for the last 24 hours. Approved and Rejected only cover signature approvals. Failed includes errors and timeouts.
* **Flow control event log**: every call Portal made and the exact response your server returned.
* **Hooks**: each hook with its path, status, number of calls in the last 24 hours, and time of the last call.
* **Base URL**, your secrets, and the **Email alerts** setting.

### Hook status

| Badge | Meaning |
| - | - |
| No badge | Every call in the last 24 hours succeeded. |
| **{n} failed** | Some calls in the last 24 hours failed. |
| **Failing** | Every call in the last 24 hours failed. |
| **No calls in 24h** | The hook is on but Portal hasn't called it in the last 24 hours. |
| **Off** | The hook is turned off. |

A `400` from `/sign-approval` is a rejection, not a failure.

### Turn hooks on or off

Check or uncheck **Signature approvals** or **Self-managed backups** in the **Hooks** list, then click **Save changes** and confirm. Turning a hook off has these effects:

* **Signature approvals off**: Portal stops calling `/sign-approval`, and signatures proceed without your approval.
* **Self-managed backups off**: Portal stops calling `/backup` and `/backup/fetch`, and backups and recoveries fail until you turn them back on.

The base URL can't be changed from the dashboard after setup. Contact our team if you need to move it.

### Test your hooks

Click **Test all hooks** at the top of the page, or **Test** on a single hook. Portal sends a [test call](#recognizing-test-calls) and shows the status code your server returned, the response time, and the response body. Click **Show test request body** on a hook to see what Portal sends.

### Secrets

Click the reveal icon next to a secret to see it. Revealing a secret requires two-factor authentication.

When `/sign-approval` and the backup hooks use different secrets, the page shows two: **Sign approval secret** for `/sign-approval` and **Backup secret** for `/backup` and `/backup/fetch`.

### Email alerts

Turn on **Email alerts** to get an email when a hook keeps failing. Portal sends at most one failure email per hook per hour, and a recovery email once the hook works again.

### Flow control events

Click **View flow control events** to see every call Portal made to your base URL in the last 30 days.

Filter by **Hook** and by date range with **From** and **To**. Each row shows the hook, the status code, the outcome, and when Portal received the response. Expand a row to see the request body and your server's response. Portal hides backup shares and the secret header in this log.

| Outcome | Meaning |
| - | - |
| **Approved** | `/sign-approval` returned a `2xx`. |
| **Rejected** | `/sign-approval` returned `400`. |
| **Share stored** | `/backup` returned a `2xx`. |
| **Share returned** | `/backup/fetch` returned a `2xx` with `backupShares` or `backupShare`. |
| **Failed** | Your server returned a status that fails the hook. |
| **Timed out** | Your server didn't respond within 10 seconds. |
| **Missing share** | `/backup/fetch` returned neither `backupShares` nor `backupShare`. |
| **Share mismatch** | `/backup/fetch` didn't return the share Portal sent to `/backup`. |
| **Verification failed** | `/backup` succeeded, but the `/backup/fetch` check after it failed. |

## Security

* Base 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.