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.
- Every serialized response is limited to 4,000,000 bytes. The server never splits a client or portfolio result across pages.
- 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.
Pagination and compatibility
Pagination is opt-in. Existing requests without page_size keep the original full-response shape when the response fits within 4,000,000 bytes. If it does not fit, the API returns 422 pagination_required; retry with page_size.
When page_size is present, the response also includes:
{
"pagination": {
"page_size": 100,
"returned_count": 100,
"has_more": true,
"next_cursor": "opaque-cursor"
}
}
Submit next_cursor as cursor with the same route and request filters to fetch the next page. A cursor is opaque and bound to the credential, route, and normalized request scope. Changing the account list, filters, date range, include_positions, or page_size invalidates it. Cursor requests must include page_size.
Sign every page as a new HMAC request using its exact query string or raw JSON body. Each page consumes one quota unit and updates usage. Credential rotation revokes the old key immediately, so cursors issued to that credential cannot be continued with the replacement key; restart pagination with the new credential.
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. |
page_size | integer | No | Enables pagination. From 1 to 500 clients per page. |
cursor | string | No | Opaque next_cursor from the preceding page. Requires page_size. |
Example request
GET /api/v1/b2b/clients?include=contact,wallets&page_size=100 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"
}
]
}
],
"pagination": {
"page_size": 100,
"returned_count": 1,
"has_more": false,
"next_cursor": null
}
}
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. |
page_size | integer | No | Enables pagination. From 1 to 200 account results per page. |
cursor | string | No | Opaque next_cursor from the preceding page. Requires page_size. |
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. |
page_size | integer | No | Enables pagination. From 1 to 200 account results per page. |
cursor | string | No | Opaque next_cursor from the preceding page. Requires page_size. |
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, invalid_cursor | Missing headers, malformed JSON, invalid UUID, invalid timestamp/date/query, or a cursor that is malformed or does not match the signed request scope. |
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, pagination_required, response_item_too_large | Semantically invalid request, an unpaginated response over 4,000,000 bytes, or one atomic result that cannot fit within that limit. |
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. - Each page is one authenticated request and consumes one minute and, when configured, daily quota unit.
- 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. - For large result sets, set
page_size, follownext_cursoruntilhas_moreisfalse, and sign every page independently without changing the original request scope. - Process each returned client or account result atomically. If the API returns
response_item_too_large, retry without positions or with a shorter history range. - 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.