Skip to main content
Subscribe to webhooks to receive real-time notifications about balance changes, deposits, withdrawals, positions, and strategy updates.

Register a webhook

curl -X POST "$BASE_URL/v2/webhook-endpoints" \
  -H "Authorization: Bearer $API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/ground/webhook",
    "events": [
      "portfolio_wallet.balance.updated",
      "portfolio_wallet.deposit.status_changed",
      "portfolio_wallet.withdrawal.status_changed",
      "portfolio_wallet.withdrawal.payout.status_changed",
      "portfolio_wallet.position.updated",
      "portfolio_wallet.strategy.status_changed"
    ],
    "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.
{
  "id": "a1b2c3d4-0000-4000-8000-000000000001",
  "url": "https://example.com/ground/webhook",
  "events": [
    "portfolio_wallet.balance.updated",
    "portfolio_wallet.deposit.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 webhook endpoints

curl -X GET "$BASE_URL/v2/webhook-endpoints" \
  -H "Authorization: Bearer $API_TOKEN"
The list response wraps endpoints in an endpoints array. Note that secret is not included in list responses.
{
  "endpoints": [
    {
      "id": "a1b2c3d4-0000-4000-8000-000000000001",
      "url": "https://example.com/ground/webhook",
      "events": [
        "portfolio_wallet.balance.updated",
        "portfolio_wallet.deposit.status_changed"
      ],
      "createdAt": "2026-02-05T08:00:00.000Z"
    }
  ]
}

Delete a webhook endpoint

curl -X DELETE "$BASE_URL/v2/webhook-endpoints/$ENDPOINT_ID" \
  -H "Authorization: Bearer $API_TOKEN"
Returns { "id": "...", "deleted": true } on success.

Portfolio Wallet events

EventFires when
portfolio_wallet.balance.updatedWallet balance changes (deposits settling, yield accruing, rebalances)
portfolio_wallet.deposit.status_changedA deposit transitions status (processing -> completed, or failed)
portfolio_wallet.withdrawal.status_changedA withdrawal transitions status
portfolio_wallet.withdrawal.payout.status_changedAn individual withdrawal payout leg transitions status
portfolio_wallet.position.updatedA position’s value, weight, or rate changes
portfolio_wallet.strategy.status_changedA strategy update is applied

Event payloads

portfolio_wallet.balance.updated

{
  "event": "portfolio_wallet.balance.updated",
  "observedAt": "2026-02-05T10:05:00.000Z",
  "wallet": {
    "id": "9d1a1c83-3a1c-4c14-9c5a-0c9a57a4a7db",
    "label": "Treasury Portfolio",
    "createdAt": "2026-02-05T08:15:00Z",
    "depositAddresses": {
      "arbitrum": "0x21246509968c4d24611f414560971AEc2e3A079B",
      "base": "0x21246509968c4d24611f414560971AEc2e3A079B",
      "ethereum": "0x21246509968c4d24611f414560971AEc2e3A079B",
      "polygon": "0x21246509968c4d24611f414560971AEc2e3A079B",
      "solana": "7nYzKxM3bP4oEFbqkPmA5E2rYJ8HqKz8vFg9abc1"
    },
    "balance": {
      "totalUsd": "82500.000000",
      "withdrawableUsd": "82500.000000",
      "availableToInitiateUsd": "82500.000000",
      "pendingWithdrawalUsd": "0.000000",
      "inTransitUsd": "0.000000",
      "earnedUsd": "250.000000"
    },
    "positions": [
      { "yieldSourceId": "syrup-usdc", "name": "Syrup USDC", "valueUsd": "33000.000000", "pct": 40 },
      { "yieldSourceId": "morpho-gauntlet-usdc", "name": "Morpho Gauntlet USDC Prime", "valueUsd": "24750.000000", "pct": 30 },
      { "yieldSourceId": "morpho-steakhouse-usdc", "name": "Morpho Steakhouse USDC Prime", "valueUsd": "24750.000000", "pct": 30 }
    ]
  }
}

portfolio_wallet.deposit.status_changed

{
  "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": null,
    "status": "processing",
    "createdAt": "2026-02-05T09:40:00.000Z",
    "completedAt": null
  }
}

portfolio_wallet.withdrawal.status_changed

{
  "event": "portfolio_wallet.withdrawal.status_changed",
  "observedAt": "2026-02-05T11:05:00.000Z",
  "withdrawal": {
    "id": "11b17950-1f5c-4d36-8f0d-0f3d1d0c6a45",
    "amountUsd": "65000.000000",
    "feeUsd": "0.000000",
    "destinationChain": "ethereum",
    "destinationAddress": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
    "status": "processing",
    "txHash": null,
    "payouts": [],
    "failureReason": null,
    "createdAt": "2026-02-05T11:00:00.000Z",
    "completedAt": null
  }
}

portfolio_wallet.withdrawal.payout.status_changed

{
  "event": "portfolio_wallet.withdrawal.payout.status_changed",
  "observedAt": "2026-02-05T12:40:00.000Z",
  "payout": {
    "id": "0c7c6fcb-3c49-4f6d-9a8d-1d2b8d1ef7b0",
    "yieldSourceId": "morpho-steakhouse-usdc",
    "status": "completed",
    "turnkeyActivityId": null,
    "approvalRequestedAt": null,
    "approvalValidBefore": null,
    "amount": "35000.000000",
    "token": "usdc",
    "chain": "ethereum",
    "destinationAddress": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
    "txHash": "0xabc123...",
    "failureReason": null,
    "createdAt": "2026-02-05T12:39:00.000Z",
    "updatedAt": "2026-02-05T12:40:00.000Z"
  }
}

portfolio_wallet.position.updated

{
  "event": "portfolio_wallet.position.updated",
  "observedAt": "2026-02-05T10:20:00.000Z",
  "position": {
    "walletId": "9d1a1c83-3a1c-4c14-9c5a-0c9a57a4a7db",
    "yieldSourceId": "morpho-gauntlet-usdc",
    "name": "Morpho Gauntlet USDC Prime",
    "valueUsd": "24750.000000",
    "pct": 30
  }
}

portfolio_wallet.strategy.status_changed

{
  "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"
  }
}

Error responses

Webhook registration and management endpoints return errors in a consistent {error, code} structure:
{
  "error": "Invalid event type: yield_wallet.balance.updated",
  "code": "validation_error"
}

Delivery and headers

Ground POSTs to your registered url with these headers:
HeaderDescription
Content-Typeapplication/json
Ground-Event-IdUnique event identifier
Ground-Event-TypeEvent type (e.g. portfolio_wallet.balance.updated)
Ground-SignatureHMAC 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

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.