External Portfolio Read API
Authenticate, sign, and call the read-only external portfolio API to retrieve linked client metadata and portfolio snapshots.
The External Portfolio Read API exposes portfolio data for a partner team through three HMAC-protected, read-only endpoints. One API key set is scoped to a single team and can read every client account linked to that team.
Replace the example key identifiers and account ids with the credentials issued to your team.
Contents
- Quick start
- Environments
- Authentication
- Conventions
- GET /clients
- POST /portfolio/latest
- POST /portfolio/history
- Errors
- Rate limits and credentials
- Integration tips
Quick start
- Ask a team owner to open API access (
/home/{team-slug}/external-api) and create a key. Copy the public key id and the one-time HMAC secret immediately. - Sign every request with HMAC-SHA256 (see Authentication).
- Call
GET /clientsto discover linkedaccount_uidvalues. - Call
POST /portfolio/latestwith those ids for the current snapshot. - Use
POST /portfolio/historyfor backfills. Keep ranges within the team history cap (default 366 inclusive UTC days).
Every response includes x-request-id. Send that value when you open a support ticket.
Environments
| Environment | Base URL |
|---|---|
| Staging | https://www.staging.app.ice-block.net/api/v1/b2b |
| Production | https://app.iceblockinvestments.com/api/v1/b2b |
All requests must use HTTPS. Paths relative to the base URL:
GET /clientsPOST /portfolio/latestPOST /portfolio/history
Authentication
Every request is authenticated with an HMAC-SHA256 signature over the method, path, canonical query string, body hash, and timestamp.
| Header | Required | Description |
|---|---|---|
x-iceblock-key | Yes | Public API key identifier issued to your team. |
x-timestamp | Yes | Unix timestamp in seconds (UTC). Used for replay protection. |
x-signature | Yes | sha256=<base64-encoded-hmac> computed from the string to sign. |
String to sign
METHOD PATHNAME CANONICAL_QUERY BODY_SHA256 TIMESTAMP
METHOD: uppercase HTTP method (GET,POST).PATHNAME: the exact request path, for example/api/v1/b2b/portfolio/latest.CANONICAL_QUERY: the normalized query string, or the empty string when there are no query parameters.BODY_SHA256: lowercase hexadecimal SHA-256 of the raw request body. ForGET, hash the empty string.TIMESTAMP: the same Unix timestamp sent inx-timestamp.
Canonical query rules
- Parse every query parameter from the URL.
- Sort parameters by key ascending.
- If a key appears multiple times, sort by value ascending.
- Percent-encode keys and values with
encodeURIComponent. - Join each pair as
<key>=<value>and concatenate them with&. - Use the empty string when there are no query parameters.
Replay protection
- Allowed clock skew is
±300seconds. - Requests outside this window return
403withtimestamp_out_of_window. - Missing or malformed timestamps return
400withmalformed_timestamp.
Signing example (Node.js)
const crypto = require('node:crypto');
function canonicalQuery(searchParams) {
return Array.from(searchParams.entries())
.sort(([keyA, valueA], [keyB, valueB]) => {
if (keyA === keyB) {
return valueA.localeCompare(valueB);
}
return keyA.localeCompare(keyB);
})
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
)
.join('&');
}
function sha256Hex(input) {
return crypto.createHash('sha256').update(input).digest('hex');
}
function signRequest({ method, pathname, searchParams, body, timestamp, secret }) {
const rawBody = body ?? '';
const stringToSign = [
method.toUpperCase(),
pathname,
canonicalQuery(searchParams),
sha256Hex(rawBody),
String(timestamp),
].join('\n');
const hmac = crypto
.createHmac('sha256', secret)
.update(stringToSign)
.digest('base64');
return `sha256=${hmac}`;
}
Implementation notes
- For
GET, sign withbody = ''. - For
POST, sign the exact raw JSON string that will be sent on the wire. Do not reformat whitespace or reorder JSON keys between signing and sending. - If your HTTP client serializes JSON for you, hash the serialized string, not an in-memory object.
- Signature mismatches almost always come from hashing a parsed object instead of the raw body, signing one query-string order and sending another, or signing a different path than the one requested.
Conventions
- Monetary amounts and asset quantities are returned as strings to preserve decimal precision. Do not parse them as IEEE floating-point numbers for accounting logic.
- UUIDs are strings; timestamps are ISO 8601 UTC; history dates are
YYYY-MM-DD. - One API key covers all client accounts linked to your team. An
account_uidthat exists but is not linked to your team returnsnot_authorized. account_uidsaccepts up to 200 UUIDs per request; duplicates are ignored after the first occurrence.- Request bodies must stay under 64 KiB.
- Borrow exposure uses a negative
value_usd.is_stablecoin = trueidentifies stablecoin positions. - In history responses, each daily entry is the latest snapshot on or before the end of that UTC day.
is_carry_forward = truemeans an earlier snapshot was reused. - Successful and error responses set
cache-control: no-storeand includex-request-id.
GET /clients
Returns every client account linked to your team with contact and custody metadata only. It does not return balances or holdings.
Query parameters
| Query parameter | Type | Required | Notes |
|---|---|---|---|
updated_since | string (ISO8601) | No | Returns only clients whose exposed metadata changed on or after the timestamp. |
include | string | No | Comma-separated values from contact,wallets,cefi_accounts. Defaults to all three. |
Example request
GET /api/v1/b2b/clients?include=contact,wallets HTTP/1.1 x-iceblock-key: your_key_id x-timestamp: 1775030400 x-signature: sha256=...
Example response
{
"generated_at": "2026-04-01T09:30:00Z",
"data": [
{
"account_uid": "11111111-1111-4111-8111-111111111111",
"full_name": "Jane Doe",
"email": "jane@example.com",
"linked_at": "2026-03-01T10:00:00Z",
"wallets": [
{
"address": "0xabc123...",
"blockchain_namespace": "eip155:1"
}
],
"cefi_accounts": [
{
"platform": "binanceInternational"
}
]
}
]
}
Response fields
| Field | Type | Description |
|---|---|---|
generated_at | string (ISO8601) | Server generation timestamp. |
account_uid | string (uuid) | IceBlock client account identifier. |
full_name | string | Client display name. Present when include contains contact. |
email | string | Client email address. Present when include contains contact. |
linked_at | string (ISO8601) | Timestamp when the client was linked to your team. |
wallets[].address | string | On-chain wallet address. Present when include contains wallets. |
wallets[].blockchain_namespace | string | CAIP-2 namespace, for example eip155:1. |
cefi_accounts[].platform | string | CeFi platform identifier. Present when include contains cefi_accounts. |
POST /portfolio/latest
Returns the latest available portfolio snapshot for each requested client account.
Request body
{
"account_uids": [
"11111111-1111-4111-8111-111111111111",
"22222222-2222-4222-8222-222222222222"
],
"include_positions": true
}
| Field | Type | Required | Notes |
|---|---|---|---|
account_uids | string[] | Yes | Up to 200 UUIDs. |
include_positions | boolean | No | Defaults to true. When false, the snapshot omits positions. |
Example response
{
"generated_at": "2026-04-01T09:30:00Z",
"results": [
{
"account_uid": "11111111-1111-4111-8111-111111111111",
"status": "ok",
"snapshot": {
"snapshot_at": "2026-03-31T23:55:00Z",
"total_mandate_value_usd": "125000.45",
"total_crypto_value_usd": "95000.45",
"total_stablecoin_value_usd": "30000.00",
"positions": [
{
"asset_symbol": "BTC",
"asset_name": "Bitcoin",
"asset_sector": "payment",
"is_stablecoin": false,
"position_source": "cefi",
"position_kind": "spot",
"quantity": "1.25000000",
"price_usd": "68000.00",
"value_usd": "85000.00",
"platform": "binanceInternational"
}
]
}
},
{
"account_uid": "22222222-2222-4222-8222-222222222222",
"status": "no_snapshot"
}
]
}
Per-account status values
| Status | Meaning |
|---|---|
ok | Snapshot returned. |
not_found | account_uid does not exist. |
not_authorized | account_uid exists but is not linked to your team. |
no_snapshot | account_uid is linked but no snapshot is available yet. |
Snapshot fields
| Field | Type | Description |
|---|---|---|
snapshot_at | string (ISO8601) | Timestamp of the underlying snapshot. |
total_mandate_value_usd | string | Total portfolio value at the snapshot timestamp. |
total_crypto_value_usd | string | Non-stablecoin portion of the portfolio value. |
total_stablecoin_value_usd | string | Stablecoin portion of the portfolio value. |
positions | array | Normalized holdings and DeFi exposures. Present when include_positions = true. |
Position object
| Field | Type | Description |
|---|---|---|
asset_symbol | string | Asset ticker, for example BTC, ETH, USDC. |
asset_name | string | Human-readable asset name. |
asset_sector | string | null | Asset sector when available. |
is_stablecoin | boolean | true when the asset is a stablecoin. |
position_source | string | wallet, cefi, or defi. |
position_kind | string | spot, supply, or borrow. |
quantity | string | Position quantity. |
price_usd | string | null | Snapshot valuation price in USD when available. |
value_usd | string | Snapshot valuation in USD. Negative for borrow positions. |
wallet_address | string | Present for wallet and DeFi positions. |
blockchain_namespace | string | Present for wallet and DeFi positions. |
platform | string | Present for CeFi positions. |
protocol | string | Present for DeFi positions. |
POST /portfolio/history
Returns daily portfolio history for a batch of client accounts over a requested UTC date range.
Request body
{
"account_uids": ["11111111-1111-4111-8111-111111111111"],
"from": "2026-01-01",
"to": "2026-01-31",
"include_positions": false
}
| Field | Type | Required | Notes |
|---|---|---|---|
account_uids | string[] | Yes | Up to 200 UUIDs. |
from | string (YYYY-MM-DD) | Yes | Inclusive start date (UTC). |
to | string (YYYY-MM-DD) | Yes | Inclusive end date (UTC). Must be greater than or equal to from. |
include_positions | boolean | No | Defaults to true. When false, each daily snapshot omits positions. |
The inclusive range (to - from + 1 days) must not exceed the team history cap. The default cap is 366 days and is configurable per team by IceBlock administrators. Split longer backfills into multiple calls.
Example response
{
"generated_at": "2026-04-01T09:30:00Z",
"results": [
{
"account_uid": "11111111-1111-4111-8111-111111111111",
"status": "ok",
"snapshots": [
{
"date": "2026-03-30",
"snapshot_at": "2026-03-30T23:54:00Z",
"is_carry_forward": false,
"total_mandate_value_usd": "124100.10",
"total_crypto_value_usd": "94100.10",
"total_stablecoin_value_usd": "30000.00",
"positions": []
},
{
"date": "2026-03-31",
"snapshot_at": "2026-03-30T23:54:00Z",
"is_carry_forward": true,
"total_mandate_value_usd": "124100.10",
"total_crypto_value_usd": "94100.10",
"total_stablecoin_value_usd": "30000.00",
"positions": []
}
]
}
]
}
History behavior
- Granularity is fixed to daily in v1.
- Once an account has at least one snapshot on or before a requested day, the response includes one entry per UTC day through
to. - If no snapshot exists yet for a linked account within the requested range, that account returns
status = "no_snapshot". - Per-account
statusvalues matchPOST /portfolio/latest. - When
include_positions = true,positionsuses the same object shape asPOST /portfolio/latest.
Errors
| Status | Typical codes | Reason |
|---|---|---|
400 Bad Request | malformed_headers, malformed_timestamp, malformed_signature, malformed_account_uid, invalid_json, invalid_body, invalid_query | Missing headers, malformed JSON, invalid UUID, invalid timestamp, invalid date format, or invalid query parameter. |
401 Unauthorized | invalid_key, invalid_signature | Unknown API key or signature mismatch. |
403 Forbidden | timestamp_out_of_window, credential_disabled, credential_revoked, team_feature_disabled, global_feature_disabled | Replay window exceeded, disabled/revoked credentials, or feature gate off. |
422 Unprocessable Entity | invalid_date_range, too_many_account_uids, history_range_too_large, body_too_large | Semantically invalid request (for example from > to, more than 200 account_uids, range over the history cap, or body over 64 KiB). |
429 Too Many Requests | rate_limit_exceeded, daily_limit_exceeded, rate_limit_unavailable | Per-key rate limit exceeded, daily cap exceeded, or the rate limiter is temporarily unavailable. Honor Retry-After. |
500 Internal Server Error | internal_error, vault_secret_unavailable | Unexpected server failure. |
All errors use the same shape:
{
"error": {
"code": "invalid_signature",
"message": "Signature mismatch"
}
}
Rate limits and credentials
- Requests are rate limited per key (default 300 requests/minute, configurable per team). An optional daily request cap (UTC) is enforced when IceBlock administrators configure it. When a limit is exceeded — or if the rate limiter is briefly unavailable — the API returns
429with aRetry-Afterheader. - Team owners and super-admins manage keys on the team API access page (and on the admin account detail page for super-admins).
- API keys can be created, rotated, disabled, revoked, and re-enabled from that page.
- The HMAC secret is shown only once, at creation or rotation. Store it securely and rotate it if it is ever exposed. Rotation requires confirmation, issues a replacement key, and immediately revokes the prior key so only one active credential remains.
Usage metrics and audit traces (team managers)
- Usage metrics appear per key on the credentials card: last used, today's request count, and request count in the last 30 days. These are transient Redis snapshots with a 30-day inactivity TTL;
Neverand0mean no snapshot is currently retained. - Audit traces record credential and settings lifecycle events only (
credential.created,credential.rotated,settings.updated, the super-admin emergency disable, and similar). Operator email is stored denormalized when the event is written.
Per-request activity (route, HTTP status, latency) is not stored in the audit log or in permanent database counters — it is captured in server logs for debugging. No secrets or request bodies are recorded.
Integration tips
- Use
GET /clientsto refresh availableaccount_uidvalues and custody metadata. - Use
POST /portfolio/latestfor normal daily synchronization. - Use
POST /portfolio/historyfor backfills and reconciliation; setinclude_positionsexplicitly when you want lighter payloads. - Always handle per-account statuses in portfolio responses. Do not infer authorization from the client list alone.
- Include
x-request-idfrom failed responses when contacting IceBlock support.