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

  1. 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.
  2. Sign every request with HMAC-SHA256 (see Authentication).
  3. Call GET /clients to discover linked account_uid values.
  4. Call POST /portfolio/latest with those ids for the current snapshot.
  5. Use POST /portfolio/history for 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

EnvironmentBase URL
Staginghttps://www.staging.app.ice-block.net/api/v1/b2b
Productionhttps://app.iceblockinvestments.com/api/v1/b2b

All requests must use HTTPS. Paths relative to the base URL:

  • GET /clients
  • POST /portfolio/latest
  • POST /portfolio/history

Authentication

Every request is authenticated with an HMAC-SHA256 signature over the method, path, canonical query string, body hash, and timestamp.

HeaderRequiredDescription
x-iceblock-keyYesPublic API key identifier issued to your team.
x-timestampYesUnix timestamp in seconds (UTC). Used for replay protection.
x-signatureYessha256=<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. For GET, hash the empty string.
  • TIMESTAMP: the same Unix timestamp sent in x-timestamp.

Canonical query rules

  1. Parse every query parameter from the URL.
  2. Sort parameters by key ascending.
  3. If a key appears multiple times, sort by value ascending.
  4. Percent-encode keys and values with encodeURIComponent.
  5. Join each pair as <key>=<value> and concatenate them with &.
  6. Use the empty string when there are no query parameters.

Replay protection

  • Allowed clock skew is ±300 seconds.
  • Requests outside this window return 403 with timestamp_out_of_window.
  • Missing or malformed timestamps return 400 with malformed_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 with body = ''.
  • 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_uid that exists but is not linked to your team returns not_authorized.
  • account_uids accepts 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 = true identifies stablecoin positions.
  • In history responses, each daily entry is the latest snapshot on or before the end of that UTC day. is_carry_forward = true means an earlier snapshot was reused.
  • Successful and error responses set cache-control: no-store and include x-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 parameterTypeRequiredNotes
updated_sincestring (ISO8601)NoReturns only clients whose exposed metadata changed on or after the timestamp.
includestringNoComma-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

FieldTypeDescription
generated_atstring (ISO8601)Server generation timestamp.
account_uidstring (uuid)IceBlock client account identifier.
full_namestringClient display name. Present when include contains contact.
emailstringClient email address. Present when include contains contact.
linked_atstring (ISO8601)Timestamp when the client was linked to your team.
wallets[].addressstringOn-chain wallet address. Present when include contains wallets.
wallets[].blockchain_namespacestringCAIP-2 namespace, for example eip155:1.
cefi_accounts[].platformstringCeFi 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
}
FieldTypeRequiredNotes
account_uidsstring[]YesUp to 200 UUIDs.
include_positionsbooleanNoDefaults 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

StatusMeaning
okSnapshot returned.
not_foundaccount_uid does not exist.
not_authorizedaccount_uid exists but is not linked to your team.
no_snapshotaccount_uid is linked but no snapshot is available yet.

Snapshot fields

FieldTypeDescription
snapshot_atstring (ISO8601)Timestamp of the underlying snapshot.
total_mandate_value_usdstringTotal portfolio value at the snapshot timestamp.
total_crypto_value_usdstringNon-stablecoin portion of the portfolio value.
total_stablecoin_value_usdstringStablecoin portion of the portfolio value.
positionsarrayNormalized holdings and DeFi exposures. Present when include_positions = true.

Position object

FieldTypeDescription
asset_symbolstringAsset ticker, for example BTC, ETH, USDC.
asset_namestringHuman-readable asset name.
asset_sectorstring | nullAsset sector when available.
is_stablecoinbooleantrue when the asset is a stablecoin.
position_sourcestringwallet, cefi, or defi.
position_kindstringspot, supply, or borrow.
quantitystringPosition quantity.
price_usdstring | nullSnapshot valuation price in USD when available.
value_usdstringSnapshot valuation in USD. Negative for borrow positions.
wallet_addressstringPresent for wallet and DeFi positions.
blockchain_namespacestringPresent for wallet and DeFi positions.
platformstringPresent for CeFi positions.
protocolstringPresent 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
}
FieldTypeRequiredNotes
account_uidsstring[]YesUp to 200 UUIDs.
fromstring (YYYY-MM-DD)YesInclusive start date (UTC).
tostring (YYYY-MM-DD)YesInclusive end date (UTC). Must be greater than or equal to from.
include_positionsbooleanNoDefaults 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 status values match POST /portfolio/latest.
  • When include_positions = true, positions uses the same object shape as POST /portfolio/latest.

Errors

StatusTypical codesReason
400 Bad Requestmalformed_headers, malformed_timestamp, malformed_signature, malformed_account_uid, invalid_json, invalid_body, invalid_queryMissing headers, malformed JSON, invalid UUID, invalid timestamp, invalid date format, or invalid query parameter.
401 Unauthorizedinvalid_key, invalid_signatureUnknown API key or signature mismatch.
403 Forbiddentimestamp_out_of_window, credential_disabled, credential_revoked, team_feature_disabled, global_feature_disabledReplay window exceeded, disabled/revoked credentials, or feature gate off.
422 Unprocessable Entityinvalid_date_range, too_many_account_uids, history_range_too_large, body_too_largeSemantically invalid request (for example from > to, more than 200 account_uids, range over the history cap, or body over 64 KiB).
429 Too Many Requestsrate_limit_exceeded, daily_limit_exceeded, rate_limit_unavailablePer-key rate limit exceeded, daily cap exceeded, or the rate limiter is temporarily unavailable. Honor Retry-After.
500 Internal Server Errorinternal_error, vault_secret_unavailableUnexpected 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 429 with a Retry-After header.
  • 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; Never and 0 mean 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 /clients to refresh available account_uid values and custody metadata.
  • Use POST /portfolio/latest for normal daily synchronization.
  • Use POST /portfolio/history for backfills and reconciliation; set include_positions explicitly 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-id from failed responses when contacting IceBlock support.