Reference
Conventions
- Asset IDs, hashes and keys are lower-case hex; upper case is accepted and normalized.
- Keys are compressed secp256k1 public keys (66 hex chars,
02/03prefix). - Times are RFC 3339 UTC strings with microseconds, e.g.
2026-10-16T08:08:16.571212Z. - Errors are JSON:
{"error","message","details"}, or422 {"detail":[…]}for schema validation. Errors. - Send
Content-Type: application/jsononly with a body. A GET with that header and no body gets 400invalid_json. - Unknown query parameters are rejected (400).
- Responses are gzip-compressed when you send
Accept-Encoding: gzip. - From servers and CLIs, send
X-Request-IDto correlate your logs with the registry's; it's echoed back. - Browsers can read every endpoint but can't send the signature headers or
X-Request-ID(CORS), so signed writes must come from a server or CLI.
Errors
Registry errors are JSON with a stable error code:
{
"error": "prev_action_hash_mismatch",
"message": "prev_action_hash does not match latest accepted action",
"details": {
"asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
"expected_prev_action_hash": "2bc27ebf045fec3c123e5ceba8ebf759911762f53693501d675a434c6de56565",
"submitted_prev_action_hash": "0000000000000000000000000000000000000000000000000000000000000000"
}
}Schema validation failures use FastAPI's shape, status 422:
{
"detail": [
{
"type": "string_too_short",
"loc": [
"path",
"asset_id"
],
"msg": "String should have at least 64 characters",
"input": "xyz",
"ctx": {
"min_length": 64
}
}
]
}On POST …/actions a body is checked against every operation type, so one bad field can produce a long detail list. Read the entries whose loc names your operation (e.g. ["body","SetCustomFieldAction","custom_key"]).
| Code | Status | Meaning |
|---|---|---|
validation_error | 400 | Business-rule failure, unknown query parameter, contract hash mismatch |
invalid_json | 400 | Body isn't valid UTF-8 JSON, or a GET sent Content-Type: application/json without a body |
non_canonical_payload | 400 | Signed body isn't canonical JSON |
invalid_signature | 401 | Signature missing, malformed or not verifying |
forbidden | 403 | Admin lacks the permission |
asset_not_found | 404 | Unknown, deregistered, or legacy and not migrated |
icon_not_found icon_proposal_not_found | 404 | No published icon / no such proposal |
asset_conflict | 409 | Already registered, or ticker taken on that domain |
prev_action_hash_mismatch nonce_conflict | 409 | See action chain |
icon_pending_conflict icon_hash_mismatch icon_proposal_already_decided | 409 | Icon workflow conflicts |
admin_conflict admin_not_found last_root_admin | 409 / 404 | Admin lifecycle |
stale_timestamp no_op_action mutable_schema_version_mismatch asset_id_mismatch | 400 | Envelope problems |
invalid_icon | 400 | Not a 500×500 RGBA PNG under 75,000 bytes, or bad Base64 |
chain_verification_failed domain_verification_failed | 400 | Issuance or domain proof doesn't match |
chain_verifier_unreachable domain_verifier_unreachable | 503 | Dependency outage; retry later |
rate_limited | 429 | Too many registrations or migrations from your IP (30 per 60 s by default). No Retry-After header; failed attempts count too, so wait out the window |
request_body_too_large | 413 | Body over 1 MiB |
Asset object
| Field | Description |
|---|---|
asset_id | Liquid asset ID |
contract | Immutable contract as registered. Version 0: issuer_pubkey; version 2: initial_issuer_pubkey (version 1 appears only on a few early testnet assets). Legacy extra fields are kept. |
issuance_txin, issuance_prevout | Where the asset was issued: {txid, vin} of the issuance input and {txid, vout} of the output it spends. null when the registry doesn't know them |
initial_issuer_pubkey | Key at registration; never changes |
initial_issuer_pubkey_source | contract (v2), registry_registration (legacy, not migrated), migrated_legacy_record |
current_issuer_pubkey | Key that signs issuer actions now |
issuer_pubkey_history[] | pubkey, valid_from_audit_id, valid_until_audit_id (null = current). A key covers entries with audit_id greater than valid_from_audit_id (or equal, for the registration entry) up to and including valid_until_audit_id; a rotation entry is signed by the old key |
mutable | category_tags[], trading_venues[] (venue, url), custom (object) |
admin | asset_type, featured, malicious, delisted, admin_notes, last_admin_action |
icon | {href}: registry-relative PNG URL, or null |
status | active (deregistered assets aren't returned) |
created_at, updated_at | RFC 3339 |
Audit entry
| Field | Description |
|---|---|
audit_id | Global, strictly increasing |
server_received_at | When the registry accepted it (not signed) |
actor | system (registration, migration), issuer, admin |
verified_pubkey | Key whose signature was verified |
admin_id | Admin identity for admin actions |
action | The accepted payload (normalized). Legacy registrations and imports: {request, contract_hash}; legacy deregistrations: {asset_id, message} |
action_hash | sha256(canonicalJson(action)) for issuer-chain entries; null otherwise |
signature | As submitted; null for system entries |
Operations: register, legacy_register, migrate_contract_metadata, replace_category_tags, replace_trading_venues, replace_custom, set_custom_field, delete_custom_field, rotate_issuer_pubkey, propose_icon, deregister, legacy_deregister, and admin operations.
Legacy entries (legacy_register, legacy_deregister, imports) have no operation field. Filter for them with ?operation=, or tell them apart by the shape of action.
For AI agents
Use the rules below as a system-prompt snippet for AI agents and code assistants.
Rules of thumb
- Base URLs: https://assets.blockstream.info (Liquid), https://assets-testnet.blockstream.info (testnet). Legacy at /, v2 under /v2. OpenAPI at /openapi.json.
- Reads are public (CORS *). Writes are signed: Bitcoin Signed Message (secp256k1) over the exact canonical-JSON body, Base64 64-byte R||S.
- Headers: Asset-Registry-Signature (issuer key), Asset-Registry-Admin-Signature (admin key). No API keys. Browsers can't send them (CORS): sign from a server or CLI.
- Formats: asset IDs, hashes and keys are lower-case hex; keys are compressed secp256k1 (66 hex, 02/03). Times are RFC 3339 UTC with microseconds.
- Canonical JSON: keys sorted recursively, no whitespace, UTF-8. Send the signed bytes unchanged.
- Issuer actions: POST /v2/assets/{id}/actions with signing_context "liquid-asset-registry-action-v1", operation, prev_action_hash (GET .../actions/latest), nonce, timestamp (RFC 3339 with microseconds).
- Mutable via issuer actions: mutable.category_tags, mutable.trading_venues, mutable.custom; also key rotation and icon proposals. The contract (name, ticker, precision, entity.domain, keys) never changes: the asset ID commits to it.
- category_tags values come from an admin-defined list (stablecoin, bond, fixed-income, tokenized today) that can change. Don't hard-code it; surface a 422 for an unknown tag.
- Retries: same signed body = idempotent_retry. 409 prev_action_hash_mismatch = refetch latest, rebuild, re-sign.
- Icons: issuers propose (500x500 PNG with alpha, < 75,000 bytes; sign only the "action" member; microsecond timestamp), admins approve. icon.href (/v2/assets/{id}/icon/{sha256}.png) never changes: cache it forever; a new icon gets a new URL. /v2/assets/{id}/icon redirects to the current one.
- Legacy (version 0) assets must be migrated (admin today) before v2 issuer actions; GET .../actions/latest returns 404 until then.
- Search: GET /v2/assets?page&page_size(<=500)&sort(<field>_asc|<field>_desc; field: asset_id, name, ticker, domain, created_at, updated_at; default asset_id_asc)&ticker&name&asset_id (prefixes)&domain (exact)&category_tag&trading_venue&asset_type&created_after&updated_after. Unknown params -> 400.
- Audit: GET /v2/assets/{id}/audit?since_audit_id&limit(<=1000)ℴ page with order=asc and next_since_audit_id.
- Don't send Content-Type on GET without a body (400 invalid_json). Registration is not idempotent; rate-limited (429).
- Moderation flags (admin.delisted, admin.malicious) don't hide assets: they stay in every listing and lookup. Only v2 responses carry them (legacy shows none), so check them before displaying an asset.
- Caching: send If-Modified-Since with the last Last-Modified on any asset read (v2 or legacy); 304 = no asset changed. Last-Modified is registry-wide, removals and icons included.
- Sync: bootstrap from /v2/assets/all.json; poll sort=updated_at_desc&updated_after=<since-1s> (ties, duplicates); removals only via GET /v2/audit?operation=deregister|legacy_deregister&since_audit_id=<cursor>.
- Numbers in signed bodies: integers or strings only (non-integers must match Python float formatting).
- v2 contracts (POST /v2/assets): version must be 2, initial_issuer_pubkey required, precision 0–8, ticker 3–24; issuer_pubkey and a body-level initial_issuer_pubkey are rejected (422). Version 0 contracts go to POST /. Domain proof: HTTP file, DNS TXT, or pubkey-bound; a pubkey-bound proof also needs Asset-Registry-Signature over the normalized contract (canonical JSON, nulls removed).
- Current controlling key: current_issuer_pubkey (v2). Legacy issuer_pubkey and contract.initial_issuer_pubkey never change. issuance_txin and issuance_prevout can be null for older v2 assets.
- Key rotation: derive new_issuer_pubkey from its private key in code. The registry doesn't check it, and a wrong key locks the asset permanently (no admin reset).Updated 41 minutes ago