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

# Transaction Review Webhooks

> Inspect and approve or deny embedded wallet signing requests from your own backend before Dynamic signs them.

<Warning>
  This feature is available for all Dynamic v3 embedded wallets (TSS-MPC). [Upgrade](/react/wallets/embedded-wallets/mpc/upgrade-guide) is required if you are on v2 or earlier.
</Warning>

## Overview

Transaction Review lets you approve or deny embedded wallet signing requests from your own backend before Dynamic signs them. Dynamic sends each signing request to a webhook endpoint you control; your endpoint returns an approve or deny decision.

Transaction Review complements [Policies & Rules](/overview/wallets/embedded-wallets/mpc/policies). Policies enforce rules you configure in the dashboard; Transaction Review delegates the decision to your own service. Both apply before a transaction is signed.

## How it works

1. A user initiates a signing operation (EVM transaction, Solana transaction, message signing, or others).
2. Before signing, Dynamic sends a `POST` request with the signing context to your webhook URL.
3. Your endpoint returns `{ "proceed": true }` to approve, or `{ "proceed": false, "reason": "..." }` to deny.
4. If the request is denied, Dynamic returns a `TransactionReviewDenied` error to the SDK. The signing is blocked and the transaction is not submitted.

If your webhook is unreachable or times out, the outcome is determined by your configured [failure policy](#failure-policy).

## Configuration

Configure your webhook from the Dynamic dashboard under **Wallets → Transaction Review**.

| Field                         | Description                                                                              |
| ----------------------------- | ---------------------------------------------------------------------------------------- |
| **Webhook URL**               | HTTPS endpoint that receives signing requests.                                           |
| **Webhook Secret**            | Shared secret used to verify requests came from Dynamic. Optional but recommended.       |
| **Response Verification Key** | Your Ed25519 public key in PEM format. If set, Dynamic verifies your response signature. |
| **Failure Policy**            | `DENY` (default) blocks signing if your webhook is unreachable; `ALLOW` proceeds.        |

## Request format

Dynamic sends a `POST` request with `Content-Type: application/json`.

### Headers

| Header                | Description                                                                                                              |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `Content-Type`        | `application/json`.                                                                                                      |
| `x-dynamic-signature` | HMAC-SHA256 hex digest of the raw request body, signed with your webhook secret. Present only if a secret is configured. |

### Body

```json theme={"system"}
{
  "environmentId": "abc123",
  "walletId": "wallet_xyz",
  "userId": "user_abc",
  "shareSetType": "rootUser",
  "message": "0xdeadbeef...",
  "context": {
    "evmTransaction": {
      "chainId": 1,
      "to": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
      "value": "0x0",
      "data": "0xa9059cbb...",
      "gas": "0x5208",
      "maxFeePerGas": "0x3b9aca00",
      "maxPriorityFeePerGas": "0x3b9aca00",
      "nonce": "0x5"
    }
  }
}
```

### `shareSetType` values

| Value       | Meaning                                            |
| ----------- | -------------------------------------------------- |
| `rootUser`  | Standard user wallet.                              |
| `delegated` | Delegated signing (server-side on behalf of user). |
| `server`    | Server-initiated signing.                          |

### `context` fields

The `context` object contains chain-specific transaction details. Only one field is populated per request.

#### EVM transaction (`evmTransaction`)

```json theme={"system"}
{
  "chainId": 1,
  "to": "0x...",
  "value": "0x...",
  "data": "0x...",
  "gas": "0x...",
  "maxFeePerGas": "0x...",
  "maxPriorityFeePerGas": "0x...",
  "nonce": "0x..."
}
```

#### EVM UserOperation (`evmUserOperation`)

For ERC-4337 account abstraction:

```json theme={"system"}
{
  "chainId": 1,
  "sender": "0x...",
  "callData": "0x...",
  "entryPoint": "0x...",
  "operation": { }
}
```

#### Solana transaction (`svmTransaction`)

```json theme={"system"}
{
  "chainId": "mainnet-beta",
  "method": "signTransaction",
  "serializedTransactions": ["base64encodedtx..."]
}
```

#### Solana message (`svmMessage`)

A raw base64-encoded message string.

#### EVM typed data (`evmTypedData`)

EIP-712 structured data for `eth_signTypedData` requests.

#### EVM message (`evmMessage`)

Plain message signing (`personal_sign`).

## Response format

Your endpoint must return `200 OK` with a JSON body.

To approve:

```json theme={"system"}
{
  "proceed": true
}
```

To deny:

```json theme={"system"}
{
  "proceed": false,
  "reason": "Transaction blocked by policy: suspicious contract interaction"
}
```

The `reason` field is optional but recommended. It appears in Dynamic's logs and events for debugging.

<Warning>
  Your endpoint must respond within **5 seconds**. A timeout is treated as a failure and falls back to your [failure policy](#failure-policy).
</Warning>

## Verifying requests (HMAC signature)

If you configure a **Webhook Secret**, Dynamic includes an `x-dynamic-signature` header containing an HMAC-SHA256 hex digest of the raw request body. Verify this signature before parsing or acting on the body.

```typescript theme={"system"}
import { createHmac, timingSafeEqual } from 'crypto';
import express, { type Request } from 'express';

const app = express();
app.use(express.raw({ type: 'application/json' }));

// Reusable guard: returns true only when a valid HMAC accompanies the request.
// The rule examples below all call this first.
const isSignatureValid = (req: Request): boolean => {
  const secret = process.env.DYNAMIC_WEBHOOK_SECRET!;
  const signature = req.headers['x-dynamic-signature'] as string | undefined;
  if (!signature) return false;

  const expectedBuf = Buffer.from(createHmac('sha256', secret).update(req.body).digest('hex'), 'hex');
  const receivedBuf = Buffer.from(signature, 'hex');

  return expectedBuf.length === receivedBuf.length && timingSafeEqual(expectedBuf, receivedBuf);
};

app.post('/webhook/transaction-review', (req, res) => {
  if (!isSignatureValid(req)) {
    return res.status(401).json({ proceed: false, reason: 'Invalid signature' });
  }

  const payload = JSON.parse(req.body.toString());
  // ... evaluate payload
  return res.json({ proceed: true });
});
```

<Warning>
  HMAC verification requires the exact raw bytes. Use `express.raw({ type: 'application/json' })`, not `express.json()`, and parse the body yourself with `JSON.parse(req.body.toString())`. `express.json()` mutates the payload (whitespace and key order) and breaks signature verification. The rule examples below reuse the same raw-body setup and the `isSignatureValid` guard defined here. Always verify the signature before parsing or acting on the body.
</Warning>

## Signing responses (Ed25519)

If you configure a **Response Verification Key**, Dynamic verifies that your webhook response was signed with the corresponding Ed25519 private key. This prevents response tampering by an intermediary.

### Generating a key pair

```bash theme={"system"}
# Generate Ed25519 private key
openssl genpkey -algorithm ed25519 -out private.pem

# Extract public key (paste this into the Dynamic dashboard)
openssl pkey -in private.pem -pubout -out public.pem
```

### Signing your response

Your endpoint must include an `x-dynamic-response-signature` header containing the Base64-encoded Ed25519 signature of the **raw response body bytes**.

```typescript theme={"system"}
import { createPrivateKey, sign } from 'crypto';
import { readFileSync } from 'fs';
import express from 'express';

const app = express();
const privateKeyPem = readFileSync('./private.pem', 'utf8');
const privateKey = createPrivateKey(privateKeyPem);

app.post('/webhook/transaction-review', (req, res) => {
  const responseBody = JSON.stringify({ proceed: true });
  const signature = sign(null, Buffer.from(responseBody), privateKey);

  res.setHeader('x-dynamic-response-signature', signature.toString('base64'));
  res.setHeader('Content-Type', 'application/json');
  res.send(responseBody);
});
```

<Warning>
  Sign the raw bytes of the response body exactly as sent. Do not re-serialize. Any difference in whitespace or key ordering fails verification.
</Warning>

## Failure policy

The failure policy determines the outcome when your webhook is unreachable or times out.

| Policy           | Behavior when the webhook is unreachable or times out                    |
| ---------------- | ------------------------------------------------------------------------ |
| `DENY` (default) | Signing is blocked; the user receives a `TransactionReviewDenied` error. |
| `ALLOW`          | Signing proceeds as if approved.                                         |

Choose `ALLOW` only if you prefer availability over strict enforcement and trust your infrastructure to handle outages.

## Events

Dynamic emits events you can subscribe to via [webhooks](/overview/developer-dashboard/webhooks/overview) for observability.

| Event                              | Description                                      |
| ---------------------------------- | ------------------------------------------------ |
| `waas.transaction.review.approved` | Transaction review passed; signing will proceed. |
| `waas.transaction.review.denied`   | Transaction review denied; signing was blocked.  |

## Example: Block transactions to specific contracts

```typescript theme={"system"}
import express from 'express';

const BLOCKED_CONTRACTS = new Set([
  '0xdeadbeef...', // known malicious contract
]);

app.post('/webhook/transaction-review', express.raw({ type: 'application/json' }), (req, res) => {
  // Always verify the signature before parsing or acting on the body
  // (reuses the `isSignatureValid` guard from "Verifying requests" above).
  if (!isSignatureValid(req)) {
    return res.status(401).json({ proceed: false, reason: 'Invalid signature' });
  }

  const { context } = JSON.parse(req.body.toString());

  const targetContract = context.evmTransaction?.to?.toLowerCase();

  if (targetContract && BLOCKED_CONTRACTS.has(targetContract)) {
    // Log the specific target server-side; keep the response `reason` a fixed
    // string so request-controlled input is never reflected back to the caller.
    console.warn('Blocked transaction to contract', { targetContract });
    return res.json({
      proceed: false,
      reason: 'Transaction to a blocked contract',
    });
  }

  return res.json({ proceed: true });
});
```

## Example: Solana — inspect program instructions

```typescript theme={"system"}
import { Transaction } from '@solana/web3.js';

app.post('/webhook/transaction-review', express.raw({ type: 'application/json' }), (req, res) => {
  // Always verify the signature before parsing or acting on the body
  // (reuses the `isSignatureValid` guard from "Verifying requests" above).
  if (!isSignatureValid(req)) {
    return res.status(401).json({ proceed: false, reason: 'Invalid signature' });
  }

  const { context } = JSON.parse(req.body.toString());

  if (context.svmTransaction) {
    const { serializedTransactions } = context.svmTransaction;

    for (const serialized of serializedTransactions) {
      const txBuffer = Buffer.from(serialized, 'base64');
      const tx = Transaction.from(txBuffer);

      for (const instruction of tx.instructions) {
        const programId = instruction.programId.toBase58();

        if (programId !== 'YourExpectedProgramId111111111111111111111') {
          // Log the specific program server-side; keep the response `reason` a
          // fixed string so request-controlled input isn't reflected back.
          console.warn('Unexpected Solana program interaction', { programId });
          return res.json({
            proceed: false,
            reason: 'Unexpected program interaction',
          });
        }
      }
    }
  }

  return res.json({ proceed: true });
});
```

## Testing your webhook

Use a tool like [ngrok](https://ngrok.com) to expose a local server during development:

```bash theme={"system"}
ngrok http 3000
```

Set the generated HTTPS URL as your webhook URL in the Dynamic dashboard. Use a sandbox environment to test without affecting production.

### Dashboard test panel

Once your webhook URL is in the form, the **Transaction Review** side panel exposes a **Test webhook** card with a scenario dropdown and a **Send test** button. Select a scenario (`Sign message`, `EVM transaction`, `EVM token transfer`, `EVM user operation`, `EVM typed data`, or `Solana transaction`) and click **Send test** to drive a synthetic payload through the same HMAC signing, response signature verification, timeout, and failure-policy code paths as live signing. The test panel emits no events, performs no signing, and writes nothing to the database.

The result panel shows the decision, latency, HTTP status, signature verification state, and the full request and response bodies, so you can iterate on your endpoint without issuing a real transaction.

To simulate a denied response and verify your SDK error handling:

```typescript theme={"system"}
// This stub never reads the request body, so the body-parsing middleware
// choice doesn't matter here. Once you add HMAC verification, switch to the
// `express.raw({ type: 'application/json' })` setup shown above.
app.post('/webhook/transaction-review', (_req, res) => {
  // Deny everything in test mode
  res.json({ proceed: false, reason: 'Test denial' });
});
```

The SDK surfaces this as:

```
TransactionReviewDenied: Test denial
```

## Reference implementation

For a complete, runnable starting point, see the [Transaction Review webhook example server](https://github.com/dynamic-labs-oss/examples/tree/main/examples/nodejs-transaction-review-webhook). It verifies inbound HMAC signatures, optionally Ed25519-signs responses, and exposes hot-switchable `allow`, `deny`, `slow`, and `crash` modes for end-to-end testing against the dashboard test panel.
