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

# Webhooks

> Register webhooks and receive real-time Portfolio Wallet events.

Subscribe to webhooks to receive real-time notifications about wallet workflow, deposit, rebalance, withdrawal, payout, and strategy lifecycle changes.

## Register a webhook

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X POST "$BASE_URL/v2/webhooks" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/ground/webhook",
	    "events": [
	      "portfolio_wallet.status_changed",
	      "portfolio_wallet.deposit.status_changed",
	      "portfolio_wallet.withdrawal.status_changed",
	      "portfolio_wallet.withdrawal.payout.status_changed",
	      "portfolio_wallet.strategy.status_changed",
	      "portfolio_wallet.rebalance.status_changed",
	      "portfolio_wallet.risk.alert.triggered",
	      "portfolio_wallet.risk.alert.resolved",
	      "portfolio_wallet.risk.action.status_changed",
	      "portfolio_wallet.risk.unwind.completed"
	    ],
    "description": "Production webhook endpoint"
  }'
```

The create response includes `id`, `url`, `events`, `createdAt`, and `secret`. Store `secret` on the server only (env var / secret manager). The `secret` field is **only returned on create** -- it is not included when listing endpoints.

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "a1b2c3d4-0000-4000-8000-000000000001",
	  "url": "https://example.com/ground/webhook",
	  "events": [
	    "portfolio_wallet.deposit.status_changed",
	    "portfolio_wallet.rebalance.status_changed"
	  ],
  "createdAt": "2026-02-05T08:00:00.000Z",
  "secret": "abcdef1234567890..."
}
```

Only `portfolio_wallet.*` event types are accepted. Requests that include event types outside the `portfolio_wallet.*` namespace will be rejected with a validation error.

## List webhooks

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X GET "$BASE_URL/v2/webhooks?limit=25" \
  -H "Authorization: Bearer $API_TOKEN"
```

The list is returned under the canonical list envelope key `data`. The legacy `endpoints` key is kept as a backwards-compatible alias containing the same array. Pass `nextCursor` as `cursor` to fetch the next page. Note that `secret` is **not** included in list responses. See [API Conventions](/docs/portfolio-wallets/api-conventions) for the list envelope.

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "data": [
    {
      "id": "a1b2c3d4-0000-4000-8000-000000000001",
      "url": "https://example.com/ground/webhook",
      "events": [
        "portfolio_wallet.deposit.status_changed",
        "portfolio_wallet.rebalance.status_changed"
      ],
      "createdAt": "2026-02-05T08:00:00.000Z"
    }
  ],
  "endpoints": [
    {
      "id": "a1b2c3d4-0000-4000-8000-000000000001",
      "url": "https://example.com/ground/webhook",
      "events": [
        "portfolio_wallet.deposit.status_changed",
        "portfolio_wallet.rebalance.status_changed"
      ],
      "createdAt": "2026-02-05T08:00:00.000Z"
    }
  ],
  "nextCursor": null,
  "hasMore": false
}
```

## Delete a webhook

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X DELETE "$BASE_URL/v2/webhooks/$ENDPOINT_ID" \
  -H "Authorization: Bearer $API_TOKEN"
```

Returns `{ "id": "...", "deleted": true }` on success.

## Portfolio Wallet events

| Event                                               | Fires when                                                                                                              |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `portfolio_wallet.status_changed`                   | The wallet's lifecycle status changes (e.g. `creating` -> `idle`, `rebalance_active`, `withdrawal_active`, or `failed`) |
| `portfolio_wallet.deposit.status_changed`           | A deposit posts to the wallet after observed finality and ledger accounting                                             |
| `portfolio_wallet.withdrawal.status_changed`        | A withdrawal workflow changes status or step progress                                                                   |
| `portfolio_wallet.withdrawal.payout.status_changed` | An individual withdrawal payout leg changes status or step progress                                                     |
| `portfolio_wallet.strategy.status_changed`          | A strategy update is applied                                                                                            |
| `portfolio_wallet.rebalance.status_changed`         | A rebalance workflow transitions status                                                                                 |
| `portfolio_wallet.risk.alert.triggered`             | A portal-configured risk rule triggers an alert                                                                         |
| `portfolio_wallet.risk.alert.resolved`              | A risk alert is resolved                                                                                                |
| `portfolio_wallet.risk.action.status_changed`       | A risk action changes status                                                                                            |
| `portfolio_wallet.risk.unwind.completed`            | A risk-triggered unwind finishes moving value to cash                                                                   |

## Event payloads

### `portfolio_wallet.status_changed`

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "event": "portfolio_wallet.status_changed",
  "organizationId": "43c78f4f-44a7-45ff-a524-8f010f7a4c72",
  "walletId": "9d1a1c83-3a1c-4c14-9c5a-0c9a57a4a7db",
  "previousStatus": "idle",
  "status": "rebalance_active",
  "occurredAt": "2026-02-05T10:28:00.000Z"
}
```

### `portfolio_wallet.deposit.status_changed`

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "event": "portfolio_wallet.deposit.status_changed",
  "observedAt": "2026-02-05T09:45:00.000Z",
  "deposit": {
    "id": "2a3ad0af-11b3-41d5-96c5-2b9d8799f1e2",
    "amount": "50000.000000",
    "token": "usdc",
    "chain": "ethereum",
    "fromAddress": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
    "txHash": "0xabc123...",
    "status": "completed",
    "createdAt": "2026-02-05T09:40:00.000Z",
    "completedAt": "2026-02-05T09:45:00.000Z"
  }
}
```

### `portfolio_wallet.withdrawal.status_changed`

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "event": "portfolio_wallet.withdrawal.status_changed",
  "observedAt": "2026-02-05T11:05:00.000Z",
  "withdrawal": {
    "id": "11b17950-1f5c-4d36-8f0d-0f3d1d0c6a45",
    "amountRequestedUsd": "65000.000000",
    "amountPaidUsd": null,
    "feeUsd": "0.000000",
    "destinationChain": "ethereum",
    "destinationAddress": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
    "destinationToken": "usdc",
    "status": "processing",
    "legsCompleted": 0,
    "legsTotal": 1,
    "payoutLegs": [
      {
        "status": "pending_customer_approval",
        "amountUsd": "65000.000000",
        "from": { "kind": "cash", "id": "cash:ethereum:usdc", "label": "Cash (Ethereum)" },
        "to": { "kind": "external_payout", "id": "external_payout:ethereum:usdc", "label": "External payout (Ethereum)" },
        "startedAt": "2026-02-05T11:00:00.000Z",
        "completedAt": null,
        "stepsCompleted": 0,
        "stepsTotal": 1,
        "steps": [
          {
            "name": "Sending payout",
            "chain": "ethereum",
            "state": "pending_customer_approval",
            "txKind": "external_payout_transfer",
            "stepKind": "external_payout_transfer",
            "startedAt": "2026-02-05T11:00:00.000Z",
            "completedAt": null,
            "cancelledAt": null,
            "stepOrdinal": 0,
            "protocolType": "external_payout",
            "sequenceRole": "external_payout_transfer"
          }
        ]
      }
    ],
    "failureReason": null,
    "createdAt": "2026-02-05T11:00:00.000Z",
    "completedAt": null
  }
}
```

The `withdrawal` object is a workflow snapshot for the withdrawal, including payout legs and their current execution steps. See [Withdraw Funds](/docs/portfolio-wallets/withdraw-funds) for the full `payoutLegs` structure.

### `portfolio_wallet.withdrawal.payout.status_changed`

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "event": "portfolio_wallet.withdrawal.payout.status_changed",
  "observedAt": "2026-02-05T12:40:00.000Z",
  "payout": {
    "id": "4f70f27a-760c-45f1-8ac9-01878d8d2d6c",
    "status": "completed",
    "turnkeyActivityId": null,
    "approvalRequestedAt": null,
    "approvalValidBefore": null,
    "amountUsd": "35000.000000",
    "feeUsd": "0.000000",
    "txHash": "0xabc123...",
    "failureReason": null,
    "steps": [
      {
        "name": "Redeeming from Syrup USDC",
        "stepKind": "syrup_request_redeem",
        "sequenceRole": "yield_redeem",
        "protocolType": "syrup",
        "stepOrdinal": 0,
        "chain": "ethereum",
        "txKind": "yield_source_redeem",
        "state": "completed",
        "startedAt": "2026-02-05T12:35:00.000Z",
        "completedAt": "2026-02-05T12:36:00.000Z",
        "cancelledAt": null
      },
      {
        "name": "Sending payout",
        "stepKind": "external_payout_transfer",
        "sequenceRole": "external_payout_transfer",
        "protocolType": "external_payout",
        "stepOrdinal": 1,
        "chain": "ethereum",
        "txKind": "external_payout_transfer",
        "state": "completed",
        "startedAt": "2026-02-05T12:38:00.000Z",
        "completedAt": "2026-02-05T12:40:00.000Z",
        "cancelledAt": null
      }
    ],
    "createdAt": "2026-02-05T12:34:00.000Z",
    "completedAt": "2026-02-05T12:40:00.000Z"
  }
}
```

### `portfolio_wallet.strategy.status_changed`

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "event": "portfolio_wallet.strategy.status_changed",
  "observedAt": "2026-02-05T09:30:00.000Z",
  "strategy": {
    "walletId": "9d1a1c83-3a1c-4c14-9c5a-0c9a57a4a7db",
    "requestId": "6d74dc1c-f1b5-4f2c-8f33-9f7b9e27cd4b",
    "status": "completed",
    "allocations": [
      { "yieldSourceId": "syrup-usdc", "pct": 40 },
      { "yieldSourceId": "morpho-gauntlet-usdc", "pct": 30 },
      { "yieldSourceId": "morpho-steakhouse-usdc", "pct": 30 }
    ],
    "createdAt": "2026-02-05T09:29:00.000Z"
  }
}
```

### `portfolio_wallet.rebalance.status_changed`

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "event": "portfolio_wallet.rebalance.status_changed",
  "observedAt": "2026-02-05T10:30:00.000Z",
  "rebalance": {
    "id": "b6c7d8e9-1234-4abc-8def-0123456789ab",
    "status": "processing",
    "reason": "deposit_deployment",
    "amountUsd": "50000.000000",
    "legsCompleted": 0,
    "legsTotal": 2,
    "rebalanceLegs": [
      {
        "status": "processing",
        "amountUsd": "25000.000000",
        "from": { "kind": "cash", "id": "cash:ethereum:usdc", "label": "Cash (Ethereum)" },
        "to": { "kind": "yield_source", "id": "morpho-gauntlet-usdc", "label": "Morpho Gauntlet USDC Prime" },
        "startedAt": "2026-02-05T10:29:00.000Z",
        "completedAt": null,
        "stepsCompleted": 0,
        "stepsTotal": 1,
        "steps": [
          {
            "name": "Depositing into Morpho Gauntlet USDC Prime",
            "chain": "ethereum",
            "state": "processing",
            "txKind": "yield_source_deposit",
            "stepKind": "yield_source_deposit",
            "startedAt": "2026-02-05T10:29:00.000Z",
            "completedAt": null,
            "cancelledAt": null,
            "stepOrdinal": 0,
            "protocolType": "morpho",
            "sequenceRole": "yield_deposit"
          }
        ]
      }
    ],
    "failureReason": null,
    "createdAt": "2026-02-05T10:28:00.000Z",
    "startedAt": "2026-02-05T10:29:00.000Z",
    "completedAt": null
  }
}
```

Rebalance and withdrawal step objects expose workflow execution metadata. Broadcast transaction hashes are exposed on payout rows when available, not on every workflow step.

## Error responses

Webhook registration and management endpoints return errors in a consistent `{error, code}` structure:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "error": "Unsupported event types: portfolio_wallet.legacy_event",
  "code": "validation_error"
}
```

## Delivery and headers

Ground POSTs to your registered `url` with these headers:

| Header              | Description                                                   |
| ------------------- | ------------------------------------------------------------- |
| `Content-Type`      | `application/json`                                            |
| `Ground-Event-Id`   | Unique event identifier                                       |
| `Ground-Event-Type` | Event type (e.g. `portfolio_wallet.rebalance.status_changed`) |
| `Ground-Signature`  | HMAC signature for verification                               |

## Signature verification

Ground uses a Stripe-style HMAC scheme:

* Header format: `Ground-Signature: t=<unix_timestamp>,v1=<hex_hmac>`
* HMAC: `v1 = HMAC_SHA256(key = secret, message = t + "." + rawBody)`
* `rawBody` is the exact request body string (before `JSON.parse`).

### Node / Express verification example

```javascript theme={"theme":{"light":"github-light","dark":"github-dark"}}
const crypto = require("crypto");
const express = require("express");

function verifyGroundSignature(
  rawBody,
  signatureHeader,
  signingSecret,
  toleranceSeconds = 300,
) {
  if (!signatureHeader) throw new Error("Missing Ground-Signature header");

  const parts = signatureHeader.split(",");
  const tPart = parts.find((p) => p.startsWith("t="));
  const v1Part = parts.find((p) => p.startsWith("v1="));
  if (!tPart || !v1Part)
    throw new Error("Invalid Ground-Signature header format");

  const timestamp = Number(tPart.split("=")[1]);
  const signature = v1Part.split("=")[1];

  // Optional replay protection
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > toleranceSeconds) {
    throw new Error("Ground-Signature timestamp outside allowed window");
  }

  const payloadToSign = `${timestamp}.${rawBody}`;
  const computed = crypto
    .createHmac("sha256", signingSecret)
    .update(payloadToSign)
    .digest("hex");

  const expected = Buffer.from(computed, "utf8");
  const actual = Buffer.from(signature, "utf8");
  if (
    expected.length !== actual.length ||
    !crypto.timingSafeEqual(expected, actual)
  ) {
    throw new Error("Invalid Ground-Signature");
  }
}

const app = express();

// Use raw body so you can verify before parsing
app.post(
  "/ground/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf8");
    const signatureHeader = req.get("Ground-Signature");
    const signingSecret = process.env.GROUND_WEBHOOK_SECRET;

    try {
      verifyGroundSignature(rawBody, signatureHeader, signingSecret);
    } catch (err) {
      console.error("Webhook signature verification failed:", err.message);
      return res.status(400).json({ error: "Invalid signature" });
    }

    const event = JSON.parse(rawBody);

    if (event.event === "portfolio_wallet.withdrawal.status_changed") {
      // handle withdrawal status change
    }

    res.status(200).json({ received: true });
  },
);
```

## Quick checklist

* **Register** a webhook and persist `secret`.
* **Verify** every request with `t + "." + rawBody` and constant-time compare.
* **Enforce** a timestamp window and use HTTPS.
* **Log** `Ground-Event-Id` and `Ground-Event-Type` for debugging.
* **Respond** with `200` quickly -- do heavy processing asynchronously.
