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.
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/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.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 webhooks
curl -X GET "$BASE_URL/v2/webhooks" \
-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
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 → active, or failed) |
portfolio_wallet.balance.updated | Wallet balance changes (deposits settling, yield accruing, rebalances) |
portfolio_wallet.deposit.status_changed | A deposit transitions status (processing -> completed, or failed) |
portfolio_wallet.withdrawal.status_changed | A withdrawal transitions status |
portfolio_wallet.withdrawal.payout.status_changed | An individual withdrawal payout leg transitions status |
portfolio_wallet.position.updated | A position’s value, weight, or rate changes |
portfolio_wallet.strategy.status_changed | A 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",
"reservedUsd": "0.000000",
"earnedUsd": "250.000000"
},
"positions": [
{ "id": "syrup-usdc", "kind": "yield_source", "label": "Syrup USDC", "valueUsd": "33000.000000", "pct": 40 },
{ "id": "morpho-gauntlet-usdc", "kind": "yield_source", "label": "Morpho Gauntlet USDC Prime", "valueUsd": "24750.000000", "pct": 30 },
{ "id": "morpho-steakhouse-usdc", "kind": "yield_source", "label": "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",
"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": [
{ "label": "Sending payout", "status": "pending_customer_approval", "txHash": null, "txStatus": null }
]
}
],
"failureReason": null,
"createdAt": "2026-02-05T11:00:00.000Z",
"completedAt": null
}
}
The withdrawal object is the same shape returned by GET /v2/wallets/{id}/withdrawals/{withdrawalId} — see Withdraw Funds for the full payoutLegs structure.
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",
"status": "completed",
"turnkeyActivityId": null,
"approvalRequestedAt": null,
"approvalValidBefore": null,
"amountUsd": "35000.000000",
"feeUsd": "0.000000",
"txHash": "0xabc123...",
"failureReason": null,
"steps": [
{ "label": "Sending payout", "status": "completed", "txHash": "0xabc123...", "txStatus": "confirmed" }
],
"createdAt": "2026-02-05T12:39:00.000Z",
"completedAt": "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",
"id": "morpho-gauntlet-usdc",
"kind": "yield_source",
"label": "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": "Unsupported event types: yield_wallet.balance.updated",
"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.balance.updated) |
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
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.