Issuer Action Endpoints

Register assets and keep their metadata current. Every write is signed with the asset's issuer key and chained to the previous one. Legacy assets (every mainnet asset today) return 404 on issuer actions until they're migrated.

NetworkBase URL
Liquidhttps://assets.blockstream.info/v2
Liquid Testnethttps://assets-testnet.blockstream.info/v2

Examples use the Liquid (mainnet) base URL. For testnet, swap in the testnet base URL. Write examples come from a local instance with throwaway keys. Use your own asset ID and key, and never send writes to production just to test.

Endpoints

MethodEndpointPath
POSTRegister an asset/v2/assets
POSTRegister with a pubkey-bound proof/v2/assets
GETGet latest action hash/v2/assets/:asset_id/actions/latest
POSTReplace category tags/v2/assets/:asset_id/actions
POSTReplace trading venues/v2/assets/:asset_id/actions
POSTSet a custom field/v2/assets/:asset_id/actions
POSTDelete a custom field/v2/assets/:asset_id/actions
POSTReplace all custom fields/v2/assets/:asset_id/actions
POSTRotate the issuer key/v2/assets/:asset_id/actions
POSTDeregister an asset/v2/assets/:asset_id/actions
POSTPropose an icon/v2/assets/:asset_id/icon-proposals
POSTList my icon proposals/v2/assets/:asset_id/icon-proposals/search

Registration

Register an asset

POST /v2/assets

Publishes the immutable contract of an asset that's already issued. The registry first checks that the issuance commits to the contract hash, then checks the domain proof. You can include initial issuer metadata.

Returns an asset object.

Parameters

NameIn · typeDescription
Asset-Registry-Signatureheader · Base64Only for the pubkey-bound proof.
asset_id requiredbody · hex (64)The issued asset's ID.
contract requiredbody · objectExactly the contract whose hash you committed at issuance. Version 2: entity.domain, initial_issuer_pubkey, name, precision (0–8), ticker (3–24), version (must be 2). Rules.
domain_verification_methodbody · "http" | "dns"Where the proof is published. Default http.
mutablebody · objectInitial category_tags, trading_venues, custom.
🚧

Registration is not idempotent and is rate-limited per IP (30 per minute by default, then 429). If a request times out, look the asset up before retrying.

📘

asset_conflict means the asset ID is already active or another active asset on the same domain uses this ticker; the response doesn't say which. Check with Get an asset and Search (domain + ticker).

🚧

If your domain also publishes a pubkey-bound DNS record, a per-asset proof isn't enough: the same key needs the signature header (401 without it), and a different key fails with 400 on DNS.

Example request

curl -X POST "https://assets.blockstream.info/v2/assets" \
  -H "Content-Type: application/json" \
  -d '{"asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","contract":{"entity":{"domain":"example-issuer.com"},"initial_issuer_pubkey":"03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e","name":"Example Dollar","precision":8,"ticker":"EXUSD","version":2},"domain_verification_method":"dns","mutable":{"category_tags":["stablecoin"],"trading_venues":[{"venue":"sideswap","url":"https://sideswap.io/"}],"custom":{"website":"https://example-issuer.com"}}}'
// Node.js 18+ (ES module: save as .mjs or set "type": "module")
const BASE_URL = "https://assets.blockstream.info";
const payload = {
  asset_id: "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
  contract: { entity: { domain: "example-issuer.com" }, initial_issuer_pubkey: "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e", name: "Example Dollar", precision: 8, ticker: "EXUSD", version: 2 },
  domain_verification_method: "dns",
  mutable: { category_tags: ["stablecoin"], trading_venues: [{ venue: "sideswap", url: "https://sideswap.io/" }], custom: { website: "https://example-issuer.com" } },
};

const res = await fetch(`${BASE_URL}/v2/assets`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(JSON.stringify(await res.json(), null, 2)); // full output: plain console.log cuts long strings and deep objects
# pip install requests
import requests

BASE_URL = "https://assets.blockstream.info"
payload = {
    "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
    "contract": {"entity": {"domain": "example-issuer.com"}, "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e", "name": "Example Dollar", "precision": 8, "ticker": "EXUSD", "version": 2},
    "domain_verification_method": "dns",
    "mutable": {"category_tags": ["stablecoin"], "trading_venues": [{"venue": "sideswap", "url": "https://sideswap.io/"}], "custom": {"website": "https://example-issuer.com"}},
}

res = requests.post(f"{BASE_URL}/v2/assets", json=payload, timeout=30)
if not res.ok:
    raise RuntimeError(f"{res.status_code} {res.text}")
print(res.json())
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

func main() {
	baseURL := "https://assets.blockstream.info"
	endpoint := baseURL + "/v2/assets"

	payload, err := json.Marshal(map[string]any{"asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d", "contract": map[string]any{"entity": map[string]any{"domain": "example-issuer.com"}, "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e", "name": "Example Dollar", "precision": 8, "ticker": "EXUSD", "version": 2}, "domain_verification_method": "dns", "mutable": map[string]any{"category_tags": []any{"stablecoin"}, "trading_venues": []any{map[string]any{"venue": "sideswap", "url": "https://sideswap.io/"}}, "custom": map[string]any{"website": "https://example-issuer.com"}}})
	if err != nil {
		panic(err)
	}
	req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusCreated {
		panic(fmt.Sprintf("%d %s", res.StatusCode, body))
	}
	fmt.Println(string(body))
}
Response: 201 (local instance · application/json)
{
  "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
  "contract": {
    "entity": {
      "domain": "example-issuer.com"
    },
    "name": "Example Dollar",
    "precision": 8,
    "ticker": "EXUSD",
    "version": 2,
    "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e"
  },
  "issuance_txin": null,
  "issuance_prevout": null,
  "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
  "initial_issuer_pubkey_source": "contract",
  "current_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
  "issuer_pubkey_history": [
    {
      "pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
      "valid_from_audit_id": 1,
      "valid_until_audit_id": null
    }
  ],
  "mutable": {
    "trading_venues": [
      {
        "venue": "sideswap",
        "url": "https://sideswap.io/"
      }
    ],
    "category_tags": [
      "stablecoin"
    ],
    "custom": {
      "website": "https://example-issuer.com"
    }
  },
  "admin": {
    "asset_type": null,
    "featured": false,
    "malicious": false,
    "delisted": false,
    "admin_notes": null,
    "last_admin_action": null
  },
  "icon": null,
  "status": "active",
  "created_at": "2026-11-03T22:47:34.213879Z",
  "updated_at": "2026-11-03T22:47:34.213879Z"
}
Error: already registered: 409 (local instance · application/json)
{
  "error": "asset_conflict",
  "message": "asset is already registered or conflicts with an active namespace",
  "details": {
    "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
  }
}
Error: contract without initial_issuer_pubkey: 422 (production · application/json)
{
  "detail": [
    {
      "type": "missing",
      "loc": [
        "body",
        "contract",
        "initial_issuer_pubkey"
      ],
      "msg": "Field required",
      "input": {
        "entity": {
          "domain": "example-issuer.com"
        },
        "name": "Example Dollar",
        "precision": 8,
        "ticker": "EXUSD",
        "version": 2
      }
    }
  ]
}
Error: issuer_pubkey in a version 2 contract: 422 (production · application/json)
{
  "detail": [
    {
      "type": "extra_forbidden",
      "loc": [
        "body",
        "contract",
        "issuer_pubkey"
      ],
      "msg": "Extra inputs are not permitted",
      "input": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc"
    }
  ]
}
Error: version 0 contract (use the legacy endpoint): 422 (production · application/json)
{
  "detail": [
    {
      "type": "value_error",
      "loc": [
        "body",
        "contract",
        "version"
      ],
      "msg": "Value error, v2 registration does not support contract.version = 0; use the legacy registration endpoint",
      "input": 0,
      "ctx": {
        "error": {}
      }
    },
    {
      "type": "missing",
      "loc": [
        "body",
        "contract",
        "initial_issuer_pubkey"
      ],
      "msg": "Field required",
      "input": {
        "entity": {
          "domain": "example-issuer.com"
        },
        "issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
        "name": "Example Token",
        "precision": 0,
        "ticker": "EXTKN",
        "version": 0
      }
    },
    {
      "type": "extra_forbidden",
      "loc": [
        "body",
        "contract",
        "issuer_pubkey"
      ],
      "msg": "Extra inputs are not permitted",
      "input": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e"
    }
  ]
}
Error: schema validation: 422 (production · application/json)
{
  "detail": [
    {
      "type": "string_too_short",
      "loc": [
        "body",
        "asset_id"
      ],
      "msg": "String should have at least 64 characters",
      "input": "zz",
      "ctx": {
        "min_length": 64
      }
    },
    {
      "type": "missing",
      "loc": [
        "body",
        "contract"
      ],
      "msg": "Field required",
      "input": {
        "asset_id": "zz"
      }
    }
  ]
}

Register with a pubkey-bound proof

POST /v2/assets SIGNED

Same endpoint, authorized by a signature instead of a per-asset proof. Publish context=liquid-asset-registry-v2,pubkey=<issuer key> once on your domain, then sign each registration's normalized contract with that key. Details.

Parameters

NameIn · typeDescription
Asset-Registry-Signature requiredheader · Base64Signature of the canonical JSON of contract, not of the whole body.
asset_id requiredbody · hex (64)The issued asset's ID.
contract requiredbody · objectIts initial_issuer_pubkey must equal the proof's pubkey.
domain_verification_methodbody · "http" | "dns"Where the pubkey-bound proof is published.
🚧

Signing the whole body gives 401 domain proof signature must cover the normalized contract JSON, not the registration request body.

Example request

# Domain-proof signature over the normalized contract JSON (see Domain verification)
BODY='{"asset_id":"6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e","contract":{"entity":{"domain":"example-issuer.com"},"initial_issuer_pubkey":"03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e","name":"Example Bond 2030","precision":2,"ticker":"EXB30","version":2},"domain_verification_method":"dns"}'
SIG='qzd8g2QYNUbZnSg7lWntn1axTvk/VkpCONkmakSSSsQU2+s+eAWiEPAM17j7ytGfXGs6QfWpJdAdQ4w5hz8SQw=='

curl -X POST "https://assets.blockstream.info/v2/assets" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Signature: $SIG" \
  --data-raw "$BODY"
// Node.js 18+ (ES module: save as .mjs or set "type": "module")
import { createECDH, createHash, createPrivateKey, sign } from "node:crypto";

const BASE_URL = "https://assets.blockstream.info";
const PRIVATE_KEY = process.env.ISSUER_PRIVATE_KEY; // 32-byte hex: the asset's current issuer key

const registration = {
  asset_id: "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e",
  contract: { entity: { domain: "example-issuer.com" }, initial_issuer_pubkey: "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e", name: "Example Bond 2030", precision: 2, ticker: "EXB30", version: 2 },
  domain_verification_method: "dns",
};
// Domain-proof signature over the normalized contract (not the whole body)
const signature = signMessage(canonicalJson(registration.contract), PRIVATE_KEY);

const res = await fetch(`${BASE_URL}/v2/assets`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Signature": signature },
  body: JSON.stringify(registration),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(JSON.stringify(await res.json(), null, 2)); // full output: plain console.log cuts long strings and deep objects

// --- signing helpers (see Signing requests) ---
// Canonical JSON: keys sorted recursively, no whitespace. Use integers or strings for numbers
// (non-integer numbers are formatted differently by JavaScript and the registry).
function canonicalJson(v) {
  if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
  if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
  return JSON.stringify(v);
}

// Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message.
function signMessage(message, privateKeyHex) {
  const varint = (n) => (n < 0xfd ? Buffer.from([n]) : Buffer.from([0xfd, n & 0xff, n >> 8]));
  const msg = Buffer.from(message, "utf8");
  const magic = Buffer.from("Bitcoin Signed Message:\n");
  const firstHash = createHash("sha256").update(Buffer.concat([varint(magic.length), magic, varint(msg.length), msg])).digest();
  const d = Buffer.from(privateKeyHex, "hex");
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(d);
  const pub = ecdh.getPublicKey();
  const key = createPrivateKey({ format: "jwk", key: { kty: "EC", crv: "secp256k1", d: d.toString("base64url"),
    x: pub.subarray(1, 33).toString("base64url"), y: pub.subarray(33).toString("base64url") } });
  const sig = sign("sha256", firstHash, { key, dsaEncoding: "ieee-p1363" }); // ECDSA-SHA256 adds the second hash
  const n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
  const s = BigInt("0x" + sig.subarray(32).toString("hex"));
  if (s > n / 2n) sig.write((n - s).toString(16).padStart(64, "0"), 32, "hex"); // low-S form
  return sig.toString("base64");
}

// Compressed public key (66 hex) for a private key.
function publicKeyOf(privateKeyHex) {
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(Buffer.from(privateKeyHex, "hex"));
  return ecdh.getPublicKey("hex", "compressed");
}
# pip install requests cryptography==50.0.2
import base64
import hashlib
import json
import os

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature

BASE_URL = "https://assets.blockstream.info"
PRIVATE_KEY = os.environ["ISSUER_PRIVATE_KEY"]  # 32-byte hex: the asset's current issuer key

def canonical_json(value):
    """Canonical JSON: keys sorted recursively, no whitespace. Prefer integers or strings for numbers."""
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def sign_message(message, private_key_hex):
    """Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message."""
    msg = message.encode("utf-8")
    varint = lambda n: bytes([n]) if n < 0xFD else b"\xfd" + n.to_bytes(2, "little")
    payload = varint(24) + b"Bitcoin Signed Message:\n" + varint(len(msg)) + msg
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    first_hash = hashlib.sha256(payload).digest()  # ECDSA-SHA256 adds the second hash
    r, s = decode_dss_signature(key.sign(first_hash, ec.ECDSA(hashes.SHA256())))
    n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
    s = min(s, n - s)  # low-S form
    return base64.b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big")).decode()

def public_key_of(private_key_hex):
    """Compressed public key (66 hex) for a private key."""
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint).hex()

registration = {
    "asset_id": "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e",
    "contract": {"entity": {"domain": "example-issuer.com"}, "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e", "name": "Example Bond 2030", "precision": 2, "ticker": "EXB30", "version": 2},
    "domain_verification_method": "dns",
}
# Domain-proof signature over the normalized contract (not the whole body)
signature = sign_message(canonical_json(registration["contract"]), PRIVATE_KEY)
res = requests.post(f"{BASE_URL}/v2/assets", json=registration, headers={"Content-Type": "application/json", "Asset-Registry-Signature": signature}, timeout=30)
if not res.ok:
    raise RuntimeError(f"{res.status_code} {res.text}")
print(res.json())
// go mod init example && go get github.com/decred/dcrd/dcrec/secp256k1/[email protected]
package main

import (
	"bytes"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"

	"github.com/decred/dcrd/dcrec/secp256k1/v4"
	"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)

func main() {
	baseURL := "https://assets.blockstream.info"
	endpoint := baseURL + "/v2/assets"
	privateKey := os.Getenv("ISSUER_PRIVATE_KEY") // 32-byte hex: the asset's current issuer key

	registration := map[string]any{}
	registration["asset_id"] = "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e"
	registration["contract"] = map[string]any{"entity": map[string]any{"domain": "example-issuer.com"}, "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e", "name": "Example Bond 2030", "precision": 2, "ticker": "EXB30", "version": 2}
	registration["domain_verification_method"] = "dns"
	// Domain-proof signature over the normalized contract (not the whole body)
	sig := signMessage(canonicalJSON(registration["contract"]), privateKey)
	payload, err := json.Marshal(registration)
	if err != nil {
		panic(err)
	}
	req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Asset-Registry-Signature", sig)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusCreated {
		panic(fmt.Sprintf("%d %s", res.StatusCode, body))
	}
	fmt.Println(string(body))
}

// canonicalJSON: keys sorted (Go sorts map keys), no whitespace, no HTML escaping.
func canonicalJSON(v any) []byte {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		panic(err)
	}
	return bytes.TrimRight(buf.Bytes(), "\n")
}

// signMessage returns a Bitcoin Signed Message signature (Base64, 64-byte R||S).
func signMessage(message []byte, privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	var payload bytes.Buffer
	writeVarint(&payload, 24)
	payload.WriteString("Bitcoin Signed Message:\n")
	writeVarint(&payload, len(message))
	payload.Write(message)
	first := sha256.Sum256(payload.Bytes())
	digest := sha256.Sum256(first[:])
	sig := ecdsa.SignCompact(secp256k1.PrivKeyFromBytes(key), digest[:], true) // recovery byte + R||S, low-S
	return base64.StdEncoding.EncodeToString(sig[1:])
}

func writeVarint(b *bytes.Buffer, n int) {
	if n < 0xfd {
		b.WriteByte(byte(n))
		return
	}
	b.WriteByte(0xfd)
	b.WriteByte(byte(n))
	b.WriteByte(byte(n >> 8))
}

// publicKeyOf returns the compressed public key (66 hex) for a private key.
func publicKeyOf(privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	return hex.EncodeToString(secp256k1.PrivKeyFromBytes(key).PubKey().SerializeCompressed())
}
Response: 201 (local instance · application/json)
{
  "asset_id": "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e",
  "contract": {
    "entity": {
      "domain": "example-issuer.com"
    },
    "name": "Example Bond 2030",
    "precision": 2,
    "ticker": "EXB30",
    "version": 2,
    "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e"
  },
  "issuance_txin": null,
  "issuance_prevout": null,
  "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
  "initial_issuer_pubkey_source": "contract",
  "current_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
  "issuer_pubkey_history": [
    {
      "pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
      "valid_from_audit_id": 2,
      "valid_until_audit_id": null
    }
  ],
  "mutable": {
    "trading_venues": [],
    "category_tags": [],
    "custom": {}
  },
  "admin": {
    "asset_type": null,
    "featured": false,
    "malicious": false,
    "delisted": false,
    "admin_notes": null,
    "last_admin_action": null
  },
  "icon": null,
  "status": "active",
  "created_at": "2026-11-03T22:47:34.287665Z",
  "updated_at": "2026-11-03T22:47:34.287665Z"
}

Issuer actions

Get latest action hash

GET /v2/assets/:asset_id/actions/latest

The hash every new issuer action must carry as prev_action_hash. Fetch it right before you sign.

Fields: asset_id, action_hash, audit_id, operation, server_received_at.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
🚧

Assets registered through the legacy API must be migrated before they accept v2 issuer actions. Until then this returns 404 legacy assets must be migrated before v2 issuer actions. On mainnet that is every asset today.

Example request

curl "https://assets.blockstream.info/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/actions/latest"
// Node.js 18+ (ES module: save as .mjs or set "type": "module")
const BASE_URL = "https://assets.blockstream.info";
const ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d";

const res = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions/latest`);
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(JSON.stringify(await res.json(), null, 2)); // full output: plain console.log cuts long strings and deep objects
# pip install requests
import requests

BASE_URL = "https://assets.blockstream.info"
ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"

res = requests.get(f"{BASE_URL}/v2/assets/{ASSET_ID}/actions/latest", timeout=30)
if not res.ok:
    raise RuntimeError(f"{res.status_code} {res.text}")
print(res.json())
package main

import (
	"fmt"
	"io"
	"net/http"
)

func main() {
	baseURL := "https://assets.blockstream.info"
	assetID := "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
	endpoint := baseURL + "/v2/assets/" + assetID + "/actions/latest"

	res, err := http.Get(endpoint)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, body))
	}
	fmt.Println(string(body))
}
Response: 200 (local instance · application/json)
{
  "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
  "action_hash": "3c19a3bef43921f4ce0dfa865da2c08f153e9285fcef31db4f54b97455f89547",
  "audit_id": 1,
  "operation": "register",
  "server_received_at": "2026-11-03T22:47:34.213879Z"
}
Error: legacy asset not migrated (production): 404 (production · application/json)
{
  "error": "asset_not_found",
  "message": "asset not found; legacy assets must be migrated before v2 issuer actions",
  "details": {
    "asset_id": "aa909f1b77451e409fe95fe1d3638ad017ab3325c6d4f00301af6d582d0f2034"
  }
}

Replace category tags

POST /v2/assets/:asset_id/actions SIGNED · operation replace_category_tags

Replaces the whole category_tags list.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Signature requiredheader · Base64Signature of the exact request body with the asset's current issuer key. How to sign.
signing_context requiredbody · string"liquid-asset-registry-action-v1"
asset_id requiredbody · hex (64)Must match the URL.
operation requiredbody · string"replace_category_tags"
mutable_schema_version requiredbody · integer1.
category_tags requiredbody · arrayUnique values from stablecoin, bond, fixed-income, tokenized today (an unknown value gets 422). [] clears. Registry admins define this list and can add, change or remove values, so don't hard-code it.
prev_action_hash requiredbody · hex (64)action_hash from Get latest action hash.
nonce requiredbody · string (8–128)Unique per asset. A UUID works.
timestamp requiredbody · RFC 3339Within ±5 min of server time and not older than your previous action. Use microseconds.
📘

The response has status (applied or idempotent_retry), the new audit_entry, and the updated asset (null on a retry). Retries and errors.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp, prev_action_hash). See Signing requests.
BODY='{"asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","category_tags":["stablecoin","tokenized"],"mutable_schema_version":1,"nonce":"391bad7c-f582-436e-a026-29eea333bff9","operation":"replace_category_tags","prev_action_hash":"3c19a3bef43921f4ce0dfa865da2c08f153e9285fcef31db4f54b97455f89547","signing_context":"liquid-asset-registry-action-v1","timestamp":"2026-11-03T22:47:34.323000Z"}'
SIG='Fa1R9CBMVbDLBcltLo1IRkF87XT4pUDgE68SzhcJ6XJoJzv4zyt0yE2Ka+PYXFR83Ab6bX6Frv9aEMDeXDjksg=='

curl -X POST "https://assets.blockstream.info/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Signature: $SIG" \
  --data-raw "$BODY"
// Node.js 18+ (ES module: save as .mjs or set "type": "module")
import { createECDH, createHash, createPrivateKey, randomUUID, sign } from "node:crypto";

const BASE_URL = "https://assets.blockstream.info";
const ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d";
const PRIVATE_KEY = process.env.ISSUER_PRIVATE_KEY; // 32-byte hex: the asset's current issuer key

// The next action must reference the hash of the latest accepted one
const latest = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions/latest`);
if (!latest.ok) throw new Error(`${latest.status} ${await latest.text()}`);
const { action_hash: prevActionHash } = await latest.json();

const action = {
  asset_id: ASSET_ID,
  category_tags: ["stablecoin", "tokenized"],
  mutable_schema_version: 1,
  nonce: randomUUID(),
  operation: "replace_category_tags",
  prev_action_hash: prevActionHash,
  signing_context: "liquid-asset-registry-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Signature": signMessage(body, PRIVATE_KEY) },
  body: body,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(JSON.stringify(await res.json(), null, 2)); // full output: plain console.log cuts long strings and deep objects

// --- signing helpers (see Signing requests) ---
// Canonical JSON: keys sorted recursively, no whitespace. Use integers or strings for numbers
// (non-integer numbers are formatted differently by JavaScript and the registry).
function canonicalJson(v) {
  if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
  if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
  return JSON.stringify(v);
}

// Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message.
function signMessage(message, privateKeyHex) {
  const varint = (n) => (n < 0xfd ? Buffer.from([n]) : Buffer.from([0xfd, n & 0xff, n >> 8]));
  const msg = Buffer.from(message, "utf8");
  const magic = Buffer.from("Bitcoin Signed Message:\n");
  const firstHash = createHash("sha256").update(Buffer.concat([varint(magic.length), magic, varint(msg.length), msg])).digest();
  const d = Buffer.from(privateKeyHex, "hex");
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(d);
  const pub = ecdh.getPublicKey();
  const key = createPrivateKey({ format: "jwk", key: { kty: "EC", crv: "secp256k1", d: d.toString("base64url"),
    x: pub.subarray(1, 33).toString("base64url"), y: pub.subarray(33).toString("base64url") } });
  const sig = sign("sha256", firstHash, { key, dsaEncoding: "ieee-p1363" }); // ECDSA-SHA256 adds the second hash
  const n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
  const s = BigInt("0x" + sig.subarray(32).toString("hex"));
  if (s > n / 2n) sig.write((n - s).toString(16).padStart(64, "0"), 32, "hex"); // low-S form
  return sig.toString("base64");
}

// Compressed public key (66 hex) for a private key.
function publicKeyOf(privateKeyHex) {
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(Buffer.from(privateKeyHex, "hex"));
  return ecdh.getPublicKey("hex", "compressed");
}
# pip install requests cryptography==50.0.2
import base64
import hashlib
import json
import os
import uuid
from datetime import datetime, timezone

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature

BASE_URL = "https://assets.blockstream.info"
ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
PRIVATE_KEY = os.environ["ISSUER_PRIVATE_KEY"]  # 32-byte hex: the asset's current issuer key

def canonical_json(value):
    """Canonical JSON: keys sorted recursively, no whitespace. Prefer integers or strings for numbers."""
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def sign_message(message, private_key_hex):
    """Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message."""
    msg = message.encode("utf-8")
    varint = lambda n: bytes([n]) if n < 0xFD else b"\xfd" + n.to_bytes(2, "little")
    payload = varint(24) + b"Bitcoin Signed Message:\n" + varint(len(msg)) + msg
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    first_hash = hashlib.sha256(payload).digest()  # ECDSA-SHA256 adds the second hash
    r, s = decode_dss_signature(key.sign(first_hash, ec.ECDSA(hashes.SHA256())))
    n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
    s = min(s, n - s)  # low-S form
    return base64.b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big")).decode()

def public_key_of(private_key_hex):
    """Compressed public key (66 hex) for a private key."""
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint).hex()

# The next action must reference the hash of the latest accepted one
latest = requests.get(f"{BASE_URL}/v2/assets/{ASSET_ID}/actions/latest", timeout=30)
if not latest.ok:
    raise RuntimeError(f"{latest.status_code} {latest.text}")
prev_action_hash = latest.json()["action_hash"]

action = {
    "asset_id": ASSET_ID,
    "category_tags": ["stablecoin", "tokenized"],
    "mutable_schema_version": 1,
    "nonce": str(uuid.uuid4()),
    "operation": "replace_category_tags",
    "prev_action_hash": prev_action_hash,
    "signing_context": "liquid-asset-registry-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/assets/{ASSET_ID}/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Signature": sign_message(body, PRIVATE_KEY)},
    timeout=30,
)
if not res.ok:
    raise RuntimeError(f"{res.status_code} {res.text}")
print(res.json())
// go mod init example && go get github.com/decred/dcrd/dcrec/secp256k1/[email protected]
package main

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/decred/dcrd/dcrec/secp256k1/v4"
	"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)

func main() {
	baseURL := "https://assets.blockstream.info"
	assetID := "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
	endpoint := baseURL + "/v2/assets/" + assetID + "/actions"
	privateKey := os.Getenv("ISSUER_PRIVATE_KEY") // 32-byte hex: the asset's current issuer key

	// The next action must reference the hash of the latest accepted one
	var latest struct {
		ActionHash string `json:"action_hash"`
	}
	getJSON(baseURL+"/v2/assets/"+assetID+"/actions/latest", &latest)

	action := map[string]any{}
	action["asset_id"] = assetID
	action["category_tags"] = []any{"stablecoin", "tokenized"}
	action["mutable_schema_version"] = 1
	action["nonce"] = newNonce()
	action["operation"] = "replace_category_tags"
	action["prev_action_hash"] = latest.ActionHash
	action["signing_context"] = "liquid-asset-registry-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	payload := canonicalJSON(action) // send exactly the signed bytes
	sig := signMessage(payload, privateKey)
	req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Asset-Registry-Signature", sig)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, body))
	}
	fmt.Println(string(body))
}

// canonicalJSON: keys sorted (Go sorts map keys), no whitespace, no HTML escaping.
func canonicalJSON(v any) []byte {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		panic(err)
	}
	return bytes.TrimRight(buf.Bytes(), "\n")
}

// signMessage returns a Bitcoin Signed Message signature (Base64, 64-byte R||S).
func signMessage(message []byte, privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	var payload bytes.Buffer
	writeVarint(&payload, 24)
	payload.WriteString("Bitcoin Signed Message:\n")
	writeVarint(&payload, len(message))
	payload.Write(message)
	first := sha256.Sum256(payload.Bytes())
	digest := sha256.Sum256(first[:])
	sig := ecdsa.SignCompact(secp256k1.PrivKeyFromBytes(key), digest[:], true) // recovery byte + R||S, low-S
	return base64.StdEncoding.EncodeToString(sig[1:])
}

func writeVarint(b *bytes.Buffer, n int) {
	if n < 0xfd {
		b.WriteByte(byte(n))
		return
	}
	b.WriteByte(0xfd)
	b.WriteByte(byte(n))
	b.WriteByte(byte(n >> 8))
}

// publicKeyOf returns the compressed public key (66 hex) for a private key.
func publicKeyOf(privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	return hex.EncodeToString(secp256k1.PrivKeyFromBytes(key).PubKey().SerializeCompressed())
}

func getJSON(endpoint string, v any) {
	res, err := http.Get(endpoint)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	out, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, out))
	}
	if err := json.Unmarshal(out, v); err != nil {
		panic(err)
	}
}

func newNonce() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}
Response: 200 (local instance · application/json)
{
  "status": "applied",
  "audit_entry": {
    "audit_id": 3,
    "server_received_at": "2026-11-03T22:47:34.326597Z",
    "actor": "issuer",
    "verified_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "admin_id": null,
    "action": {
      "nonce": "391bad7c-f582-436e-a026-29eea333bff9",
      "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
      "operation": "replace_category_tags",
      "timestamp": "2026-11-03T22:47:34.323000Z",
      "category_tags": [
        "stablecoin",
        "tokenized"
      ],
      "signing_context": "liquid-asset-registry-action-v1",
      "prev_action_hash": "3c19a3bef43921f4ce0dfa865da2c08f153e9285fcef31db4f54b97455f89547",
      "mutable_schema_version": 1
    },
    "action_hash": "84a664d89014aedb77fab1f145dcc60d98d2f1632d31e8c0bdbe46be9c1a4d7f",
    "signature": "Fa1R9CBMVbDLBcltLo1IRkF87XT4pUDgE68SzhcJ6XJoJzv4zyt0yE2Ka+PYXFR83Ab6bX6Frv9aEMDeXDjksg=="
  },
  "asset": {
    "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
    "contract": {
      "entity": {
        "domain": "example-issuer.com"
      },
      "name": "Example Dollar",
      "precision": 8,
      "ticker": "EXUSD",
      "version": 2,
      "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e"
    },
    "issuance_txin": null,
    "issuance_prevout": null,
    "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "initial_issuer_pubkey_source": "contract",
    "current_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "issuer_pubkey_history": [
      {
        "pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
        "valid_from_audit_id": 1,
        "valid_until_audit_id": null
      }
    ],
    "mutable": {
      "trading_venues": [
        {
          "venue": "sideswap",
          "url": "https://sideswap.io/"
        }
      ],
      "category_tags": [
        "stablecoin",
        "tokenized"
      ],
      "custom": {
        "website": "https://example-issuer.com"
      }
    },
    "admin": {
      "asset_type": null,
      "featured": false,
      "malicious": false,
      "delisted": false,
      "admin_notes": null,
      "last_admin_action": null
    },
    "icon": null,
    "status": "active",
    "created_at": "2026-11-03T22:47:34.213879Z",
    "updated_at": "2026-11-03T22:47:34.336188Z"
  }
}
Error: nothing would change: 400 (local instance · application/json)
{
  "error": "no_op_action",
  "message": "action would not change registry state"
}

Replace trading venues

POST /v2/assets/:asset_id/actions SIGNED · operation replace_trading_venues

Replaces the whole trading_venues list.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Signature requiredheader · Base64Signature of the exact request body with the asset's current issuer key. How to sign.
signing_context requiredbody · string"liquid-asset-registry-action-v1"
asset_id requiredbody · hex (64)Must match the URL.
operation requiredbody · string"replace_trading_venues"
mutable_schema_version requiredbody · integer1.
trading_venues requiredbody · array{venue, url} items; venue is sideswap or bitfinex, url is http(s), max 2048 chars.
prev_action_hash requiredbody · hex (64)action_hash from Get latest action hash.
nonce requiredbody · string (8–128)Unique per asset. A UUID works.
timestamp requiredbody · RFC 3339Within ±5 min of server time and not older than your previous action. Use microseconds.
📘

The response has status (applied or idempotent_retry), the new audit_entry, and the updated asset (null on a retry). Retries and errors.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp, prev_action_hash). See Signing requests.
BODY='{"asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","mutable_schema_version":1,"nonce":"533ba854-99fa-445f-8db6-bd32a0850839","operation":"replace_trading_venues","prev_action_hash":"84a664d89014aedb77fab1f145dcc60d98d2f1632d31e8c0bdbe46be9c1a4d7f","signing_context":"liquid-asset-registry-action-v1","timestamp":"2026-11-03T22:47:34.359000Z","trading_venues":[{"url":"https://sideswap.io/","venue":"sideswap"},{"url":"https://trading.bitfinex.com/t/EXUSD:USD","venue":"bitfinex"}]}'
SIG='O453jjZDsHqq0U+qYLYgpVRItgZzqYQ2b75pSWMAL0xGhKPQSfP+lnYzPVlMGaF1bQd/sib85TfZh6+dcEGY+Q=='

curl -X POST "https://assets.blockstream.info/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Signature: $SIG" \
  --data-raw "$BODY"
// Node.js 18+ (ES module: save as .mjs or set "type": "module")
import { createECDH, createHash, createPrivateKey, randomUUID, sign } from "node:crypto";

const BASE_URL = "https://assets.blockstream.info";
const ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d";
const PRIVATE_KEY = process.env.ISSUER_PRIVATE_KEY; // 32-byte hex: the asset's current issuer key

// The next action must reference the hash of the latest accepted one
const latest = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions/latest`);
if (!latest.ok) throw new Error(`${latest.status} ${await latest.text()}`);
const { action_hash: prevActionHash } = await latest.json();

const action = {
  asset_id: ASSET_ID,
  mutable_schema_version: 1,
  nonce: randomUUID(),
  operation: "replace_trading_venues",
  prev_action_hash: prevActionHash,
  signing_context: "liquid-asset-registry-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
  trading_venues: [{ url: "https://sideswap.io/", venue: "sideswap" }, { url: "https://trading.bitfinex.com/t/EXUSD:USD", venue: "bitfinex" }],
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Signature": signMessage(body, PRIVATE_KEY) },
  body: body,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(JSON.stringify(await res.json(), null, 2)); // full output: plain console.log cuts long strings and deep objects

// --- signing helpers (see Signing requests) ---
// Canonical JSON: keys sorted recursively, no whitespace. Use integers or strings for numbers
// (non-integer numbers are formatted differently by JavaScript and the registry).
function canonicalJson(v) {
  if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
  if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
  return JSON.stringify(v);
}

// Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message.
function signMessage(message, privateKeyHex) {
  const varint = (n) => (n < 0xfd ? Buffer.from([n]) : Buffer.from([0xfd, n & 0xff, n >> 8]));
  const msg = Buffer.from(message, "utf8");
  const magic = Buffer.from("Bitcoin Signed Message:\n");
  const firstHash = createHash("sha256").update(Buffer.concat([varint(magic.length), magic, varint(msg.length), msg])).digest();
  const d = Buffer.from(privateKeyHex, "hex");
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(d);
  const pub = ecdh.getPublicKey();
  const key = createPrivateKey({ format: "jwk", key: { kty: "EC", crv: "secp256k1", d: d.toString("base64url"),
    x: pub.subarray(1, 33).toString("base64url"), y: pub.subarray(33).toString("base64url") } });
  const sig = sign("sha256", firstHash, { key, dsaEncoding: "ieee-p1363" }); // ECDSA-SHA256 adds the second hash
  const n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
  const s = BigInt("0x" + sig.subarray(32).toString("hex"));
  if (s > n / 2n) sig.write((n - s).toString(16).padStart(64, "0"), 32, "hex"); // low-S form
  return sig.toString("base64");
}

// Compressed public key (66 hex) for a private key.
function publicKeyOf(privateKeyHex) {
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(Buffer.from(privateKeyHex, "hex"));
  return ecdh.getPublicKey("hex", "compressed");
}
# pip install requests cryptography==50.0.2
import base64
import hashlib
import json
import os
import uuid
from datetime import datetime, timezone

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature

BASE_URL = "https://assets.blockstream.info"
ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
PRIVATE_KEY = os.environ["ISSUER_PRIVATE_KEY"]  # 32-byte hex: the asset's current issuer key

def canonical_json(value):
    """Canonical JSON: keys sorted recursively, no whitespace. Prefer integers or strings for numbers."""
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def sign_message(message, private_key_hex):
    """Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message."""
    msg = message.encode("utf-8")
    varint = lambda n: bytes([n]) if n < 0xFD else b"\xfd" + n.to_bytes(2, "little")
    payload = varint(24) + b"Bitcoin Signed Message:\n" + varint(len(msg)) + msg
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    first_hash = hashlib.sha256(payload).digest()  # ECDSA-SHA256 adds the second hash
    r, s = decode_dss_signature(key.sign(first_hash, ec.ECDSA(hashes.SHA256())))
    n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
    s = min(s, n - s)  # low-S form
    return base64.b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big")).decode()

def public_key_of(private_key_hex):
    """Compressed public key (66 hex) for a private key."""
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint).hex()

# The next action must reference the hash of the latest accepted one
latest = requests.get(f"{BASE_URL}/v2/assets/{ASSET_ID}/actions/latest", timeout=30)
if not latest.ok:
    raise RuntimeError(f"{latest.status_code} {latest.text}")
prev_action_hash = latest.json()["action_hash"]

action = {
    "asset_id": ASSET_ID,
    "mutable_schema_version": 1,
    "nonce": str(uuid.uuid4()),
    "operation": "replace_trading_venues",
    "prev_action_hash": prev_action_hash,
    "signing_context": "liquid-asset-registry-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
    "trading_venues": [{"url": "https://sideswap.io/", "venue": "sideswap"}, {"url": "https://trading.bitfinex.com/t/EXUSD:USD", "venue": "bitfinex"}],
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/assets/{ASSET_ID}/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Signature": sign_message(body, PRIVATE_KEY)},
    timeout=30,
)
if not res.ok:
    raise RuntimeError(f"{res.status_code} {res.text}")
print(res.json())
// go mod init example && go get github.com/decred/dcrd/dcrec/secp256k1/[email protected]
package main

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/decred/dcrd/dcrec/secp256k1/v4"
	"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)

func main() {
	baseURL := "https://assets.blockstream.info"
	assetID := "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
	endpoint := baseURL + "/v2/assets/" + assetID + "/actions"
	privateKey := os.Getenv("ISSUER_PRIVATE_KEY") // 32-byte hex: the asset's current issuer key

	// The next action must reference the hash of the latest accepted one
	var latest struct {
		ActionHash string `json:"action_hash"`
	}
	getJSON(baseURL+"/v2/assets/"+assetID+"/actions/latest", &latest)

	action := map[string]any{}
	action["asset_id"] = assetID
	action["mutable_schema_version"] = 1
	action["nonce"] = newNonce()
	action["operation"] = "replace_trading_venues"
	action["prev_action_hash"] = latest.ActionHash
	action["signing_context"] = "liquid-asset-registry-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	action["trading_venues"] = []any{map[string]any{"url": "https://sideswap.io/", "venue": "sideswap"}, map[string]any{"url": "https://trading.bitfinex.com/t/EXUSD:USD", "venue": "bitfinex"}}
	payload := canonicalJSON(action) // send exactly the signed bytes
	sig := signMessage(payload, privateKey)
	req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Asset-Registry-Signature", sig)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, body))
	}
	fmt.Println(string(body))
}

// canonicalJSON: keys sorted (Go sorts map keys), no whitespace, no HTML escaping.
func canonicalJSON(v any) []byte {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		panic(err)
	}
	return bytes.TrimRight(buf.Bytes(), "\n")
}

// signMessage returns a Bitcoin Signed Message signature (Base64, 64-byte R||S).
func signMessage(message []byte, privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	var payload bytes.Buffer
	writeVarint(&payload, 24)
	payload.WriteString("Bitcoin Signed Message:\n")
	writeVarint(&payload, len(message))
	payload.Write(message)
	first := sha256.Sum256(payload.Bytes())
	digest := sha256.Sum256(first[:])
	sig := ecdsa.SignCompact(secp256k1.PrivKeyFromBytes(key), digest[:], true) // recovery byte + R||S, low-S
	return base64.StdEncoding.EncodeToString(sig[1:])
}

func writeVarint(b *bytes.Buffer, n int) {
	if n < 0xfd {
		b.WriteByte(byte(n))
		return
	}
	b.WriteByte(0xfd)
	b.WriteByte(byte(n))
	b.WriteByte(byte(n >> 8))
}

// publicKeyOf returns the compressed public key (66 hex) for a private key.
func publicKeyOf(privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	return hex.EncodeToString(secp256k1.PrivKeyFromBytes(key).PubKey().SerializeCompressed())
}

func getJSON(endpoint string, v any) {
	res, err := http.Get(endpoint)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	out, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, out))
	}
	if err := json.Unmarshal(out, v); err != nil {
		panic(err)
	}
}

func newNonce() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}
Response: 200 (local instance · application/json)
{
  "status": "applied",
  "audit_entry": {
    "audit_id": 4,
    "server_received_at": "2026-11-03T22:47:34.360514Z",
    "actor": "issuer",
    "verified_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "admin_id": null,
    "action": {
      "nonce": "533ba854-99fa-445f-8db6-bd32a0850839",
      "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
      "operation": "replace_trading_venues",
      "timestamp": "2026-11-03T22:47:34.359000Z",
      "trading_venues": [
        {
          "url": "https://sideswap.io/",
          "venue": "sideswap"
        },
        {
          "url": "https://trading.bitfinex.com/t/EXUSD:USD",
          "venue": "bitfinex"
        }
      ],
      "signing_context": "liquid-asset-registry-action-v1",
      "prev_action_hash": "84a664d89014aedb77fab1f145dcc60d98d2f1632d31e8c0bdbe46be9c1a4d7f",
      "mutable_schema_version": 1
    },
    "action_hash": "e0f86da7419b1594bdfdae4bb264468e423d063ed59b47a39ec35536c3984c5b",
    "signature": "O453jjZDsHqq0U+qYLYgpVRItgZzqYQ2b75pSWMAL0xGhKPQSfP+lnYzPVlMGaF1bQd/sib85TfZh6+dcEGY+Q=="
  },
  "asset": {
    "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
    "contract": {
      "entity": {
        "domain": "example-issuer.com"
      },
      "name": "Example Dollar",
      "precision": 8,
      "ticker": "EXUSD",
      "version": 2,
      "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e"
    },
    "issuance_txin": null,
    "issuance_prevout": null,
    "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "initial_issuer_pubkey_source": "contract",
    "current_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "issuer_pubkey_history": [
      {
        "pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
        "valid_from_audit_id": 1,
        "valid_until_audit_id": null
      }
    ],
    "mutable": {
      "trading_venues": [
        {
          "venue": "sideswap",
          "url": "https://sideswap.io/"
        },
        {
          "venue": "bitfinex",
          "url": "https://trading.bitfinex.com/t/EXUSD:USD"
        }
      ],
      "category_tags": [
        "stablecoin",
        "tokenized"
      ],
      "custom": {
        "website": "https://example-issuer.com"
      }
    },
    "admin": {
      "asset_type": null,
      "featured": false,
      "malicious": false,
      "delisted": false,
      "admin_notes": null,
      "last_admin_action": null
    },
    "icon": null,
    "status": "active",
    "created_at": "2026-11-03T22:47:34.213879Z",
    "updated_at": "2026-11-03T22:47:34.366179Z"
  }
}

Set a custom field

POST /v2/assets/:asset_id/actions SIGNED · operation set_custom_field

Adds or overwrites one top-level key in custom. Values can be any JSON.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Signature requiredheader · Base64Signature of the exact request body with the asset's current issuer key. How to sign.
signing_context requiredbody · string"liquid-asset-registry-action-v1"
asset_id requiredbody · hex (64)Must match the URL.
operation requiredbody · string"set_custom_field"
mutable_schema_version requiredbody · integer1.
custom_key requiredbody · string (1–64)No /.
value requiredbody · any JSON≤ 2,048 bytes serialized.
prev_action_hash requiredbody · hex (64)action_hash from Get latest action hash.
nonce requiredbody · string (8–128)Unique per asset. A UUID works.
timestamp requiredbody · RFC 3339Within ±5 min of server time and not older than your previous action. Use microseconds.
📘

The response has status (applied or idempotent_retry), the new audit_entry, and the updated asset (null on a retry). Retries and errors.

📘

Custom metadata limits: 32 keys, 2,048 bytes per value, 8,192 bytes in total.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp, prev_action_hash). See Signing requests.
BODY='{"asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","custom_key":"isin","mutable_schema_version":1,"nonce":"c48b8e4c-7bc3-4d4c-b859-0450c28e9b8a","operation":"set_custom_field","prev_action_hash":"e0f86da7419b1594bdfdae4bb264468e423d063ed59b47a39ec35536c3984c5b","signing_context":"liquid-asset-registry-action-v1","timestamp":"2026-11-03T22:47:34.385000Z","value":"US0000000001"}'
SIG='t00oLGIy+xeNcwQfuA13UoY4JLFO+DocEsGcL3Xx+71hULlYf9w3h63S3Iis5lXUIOWjaC2kF1JZ97BC3c9Uzg=='

curl -X POST "https://assets.blockstream.info/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Signature: $SIG" \
  --data-raw "$BODY"
// Node.js 18+ (ES module: save as .mjs or set "type": "module")
import { createECDH, createHash, createPrivateKey, randomUUID, sign } from "node:crypto";

const BASE_URL = "https://assets.blockstream.info";
const ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d";
const PRIVATE_KEY = process.env.ISSUER_PRIVATE_KEY; // 32-byte hex: the asset's current issuer key

// The next action must reference the hash of the latest accepted one
const latest = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions/latest`);
if (!latest.ok) throw new Error(`${latest.status} ${await latest.text()}`);
const { action_hash: prevActionHash } = await latest.json();

const action = {
  asset_id: ASSET_ID,
  custom_key: "isin",
  mutable_schema_version: 1,
  nonce: randomUUID(),
  operation: "set_custom_field",
  prev_action_hash: prevActionHash,
  signing_context: "liquid-asset-registry-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
  value: "US0000000001",
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Signature": signMessage(body, PRIVATE_KEY) },
  body: body,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(JSON.stringify(await res.json(), null, 2)); // full output: plain console.log cuts long strings and deep objects

// --- signing helpers (see Signing requests) ---
// Canonical JSON: keys sorted recursively, no whitespace. Use integers or strings for numbers
// (non-integer numbers are formatted differently by JavaScript and the registry).
function canonicalJson(v) {
  if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
  if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
  return JSON.stringify(v);
}

// Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message.
function signMessage(message, privateKeyHex) {
  const varint = (n) => (n < 0xfd ? Buffer.from([n]) : Buffer.from([0xfd, n & 0xff, n >> 8]));
  const msg = Buffer.from(message, "utf8");
  const magic = Buffer.from("Bitcoin Signed Message:\n");
  const firstHash = createHash("sha256").update(Buffer.concat([varint(magic.length), magic, varint(msg.length), msg])).digest();
  const d = Buffer.from(privateKeyHex, "hex");
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(d);
  const pub = ecdh.getPublicKey();
  const key = createPrivateKey({ format: "jwk", key: { kty: "EC", crv: "secp256k1", d: d.toString("base64url"),
    x: pub.subarray(1, 33).toString("base64url"), y: pub.subarray(33).toString("base64url") } });
  const sig = sign("sha256", firstHash, { key, dsaEncoding: "ieee-p1363" }); // ECDSA-SHA256 adds the second hash
  const n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
  const s = BigInt("0x" + sig.subarray(32).toString("hex"));
  if (s > n / 2n) sig.write((n - s).toString(16).padStart(64, "0"), 32, "hex"); // low-S form
  return sig.toString("base64");
}

// Compressed public key (66 hex) for a private key.
function publicKeyOf(privateKeyHex) {
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(Buffer.from(privateKeyHex, "hex"));
  return ecdh.getPublicKey("hex", "compressed");
}
# pip install requests cryptography==50.0.2
import base64
import hashlib
import json
import os
import uuid
from datetime import datetime, timezone

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature

BASE_URL = "https://assets.blockstream.info"
ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
PRIVATE_KEY = os.environ["ISSUER_PRIVATE_KEY"]  # 32-byte hex: the asset's current issuer key

def canonical_json(value):
    """Canonical JSON: keys sorted recursively, no whitespace. Prefer integers or strings for numbers."""
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def sign_message(message, private_key_hex):
    """Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message."""
    msg = message.encode("utf-8")
    varint = lambda n: bytes([n]) if n < 0xFD else b"\xfd" + n.to_bytes(2, "little")
    payload = varint(24) + b"Bitcoin Signed Message:\n" + varint(len(msg)) + msg
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    first_hash = hashlib.sha256(payload).digest()  # ECDSA-SHA256 adds the second hash
    r, s = decode_dss_signature(key.sign(first_hash, ec.ECDSA(hashes.SHA256())))
    n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
    s = min(s, n - s)  # low-S form
    return base64.b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big")).decode()

def public_key_of(private_key_hex):
    """Compressed public key (66 hex) for a private key."""
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint).hex()

# The next action must reference the hash of the latest accepted one
latest = requests.get(f"{BASE_URL}/v2/assets/{ASSET_ID}/actions/latest", timeout=30)
if not latest.ok:
    raise RuntimeError(f"{latest.status_code} {latest.text}")
prev_action_hash = latest.json()["action_hash"]

action = {
    "asset_id": ASSET_ID,
    "custom_key": "isin",
    "mutable_schema_version": 1,
    "nonce": str(uuid.uuid4()),
    "operation": "set_custom_field",
    "prev_action_hash": prev_action_hash,
    "signing_context": "liquid-asset-registry-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
    "value": "US0000000001",
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/assets/{ASSET_ID}/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Signature": sign_message(body, PRIVATE_KEY)},
    timeout=30,
)
if not res.ok:
    raise RuntimeError(f"{res.status_code} {res.text}")
print(res.json())
// go mod init example && go get github.com/decred/dcrd/dcrec/secp256k1/[email protected]
package main

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/decred/dcrd/dcrec/secp256k1/v4"
	"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)

func main() {
	baseURL := "https://assets.blockstream.info"
	assetID := "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
	endpoint := baseURL + "/v2/assets/" + assetID + "/actions"
	privateKey := os.Getenv("ISSUER_PRIVATE_KEY") // 32-byte hex: the asset's current issuer key

	// The next action must reference the hash of the latest accepted one
	var latest struct {
		ActionHash string `json:"action_hash"`
	}
	getJSON(baseURL+"/v2/assets/"+assetID+"/actions/latest", &latest)

	action := map[string]any{}
	action["asset_id"] = assetID
	action["custom_key"] = "isin"
	action["mutable_schema_version"] = 1
	action["nonce"] = newNonce()
	action["operation"] = "set_custom_field"
	action["prev_action_hash"] = latest.ActionHash
	action["signing_context"] = "liquid-asset-registry-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	action["value"] = "US0000000001"
	payload := canonicalJSON(action) // send exactly the signed bytes
	sig := signMessage(payload, privateKey)
	req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Asset-Registry-Signature", sig)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, body))
	}
	fmt.Println(string(body))
}

// canonicalJSON: keys sorted (Go sorts map keys), no whitespace, no HTML escaping.
func canonicalJSON(v any) []byte {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		panic(err)
	}
	return bytes.TrimRight(buf.Bytes(), "\n")
}

// signMessage returns a Bitcoin Signed Message signature (Base64, 64-byte R||S).
func signMessage(message []byte, privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	var payload bytes.Buffer
	writeVarint(&payload, 24)
	payload.WriteString("Bitcoin Signed Message:\n")
	writeVarint(&payload, len(message))
	payload.Write(message)
	first := sha256.Sum256(payload.Bytes())
	digest := sha256.Sum256(first[:])
	sig := ecdsa.SignCompact(secp256k1.PrivKeyFromBytes(key), digest[:], true) // recovery byte + R||S, low-S
	return base64.StdEncoding.EncodeToString(sig[1:])
}

func writeVarint(b *bytes.Buffer, n int) {
	if n < 0xfd {
		b.WriteByte(byte(n))
		return
	}
	b.WriteByte(0xfd)
	b.WriteByte(byte(n))
	b.WriteByte(byte(n >> 8))
}

// publicKeyOf returns the compressed public key (66 hex) for a private key.
func publicKeyOf(privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	return hex.EncodeToString(secp256k1.PrivKeyFromBytes(key).PubKey().SerializeCompressed())
}

func getJSON(endpoint string, v any) {
	res, err := http.Get(endpoint)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	out, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, out))
	}
	if err := json.Unmarshal(out, v); err != nil {
		panic(err)
	}
}

func newNonce() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}
Response: 200 (local instance · application/json)
{
  "status": "applied",
  "audit_entry": {
    "audit_id": 5,
    "server_received_at": "2026-11-03T22:47:34.386019Z",
    "actor": "issuer",
    "verified_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "admin_id": null,
    "action": {
      "nonce": "c48b8e4c-7bc3-4d4c-b859-0450c28e9b8a",
      "value": "US0000000001",
      "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
      "operation": "set_custom_field",
      "timestamp": "2026-11-03T22:47:34.385000Z",
      "custom_key": "isin",
      "signing_context": "liquid-asset-registry-action-v1",
      "prev_action_hash": "e0f86da7419b1594bdfdae4bb264468e423d063ed59b47a39ec35536c3984c5b",
      "mutable_schema_version": 1
    },
    "action_hash": "ecfe0032cb155e21c31e36ba7046b2d110fac9cba6dc47ddccf23edc3d271e3c",
    "signature": "t00oLGIy+xeNcwQfuA13UoY4JLFO+DocEsGcL3Xx+71hULlYf9w3h63S3Iis5lXUIOWjaC2kF1JZ97BC3c9Uzg=="
  },
  "asset": {
    "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
    "contract": {
      "entity": {
        "domain": "example-issuer.com"
      },
      "name": "Example Dollar",
      "precision": 8,
      "ticker": "EXUSD",
      "version": 2,
      "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e"
    },
    "issuance_txin": null,
    "issuance_prevout": null,
    "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "initial_issuer_pubkey_source": "contract",
    "current_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "issuer_pubkey_history": [
      {
        "pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
        "valid_from_audit_id": 1,
        "valid_until_audit_id": null
      }
    ],
    "mutable": {
      "trading_venues": [
        {
          "venue": "sideswap",
          "url": "https://sideswap.io/"
        },
        {
          "venue": "bitfinex",
          "url": "https://trading.bitfinex.com/t/EXUSD:USD"
        }
      ],
      "category_tags": [
        "stablecoin",
        "tokenized"
      ],
      "custom": {
        "isin": "US0000000001",
        "website": "https://example-issuer.com"
      }
    },
    "admin": {
      "asset_type": null,
      "featured": false,
      "malicious": false,
      "delisted": false,
      "admin_notes": null,
      "last_admin_action": null
    },
    "icon": null,
    "status": "active",
    "created_at": "2026-11-03T22:47:34.213879Z",
    "updated_at": "2026-11-03T22:47:34.391113Z"
  }
}
Re-sending the identical signed body: 200 (local instance · application/json)
{
  "status": "idempotent_retry",
  "audit_entry": {
    "audit_id": 8,
    "server_received_at": "2026-11-03T22:47:34.498616Z",
    "actor": "issuer",
    "verified_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "admin_id": null,
    "action": {
      "nonce": "5866c408-2f0d-4b6a-9daf-f90e4bb1e8e8",
      "value": "A",
      "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
      "operation": "set_custom_field",
      "timestamp": "2026-11-03T22:47:34.498000Z",
      "custom_key": "series",
      "signing_context": "liquid-asset-registry-action-v1",
      "prev_action_hash": "2bc27ebf045fec3c123e5ceba8ebf759911762f53693501d675a434c6de56565",
      "mutable_schema_version": 1
    },
    "action_hash": "18b1889c3415a9ff78560054a2e5d1ff6d6c2470e8a6933d9d62e7f511e18d9d",
    "signature": "L307FxAgJz5W6L1Kxz+xzK7+epljXuuriE8kOc+FI0V8YRooT6aDGGMsi2Vg1d3aevd9DHMOyTxts/zzgzwYig=="
  },
  "asset": null
}
Error: nonce reused with different content: 409 (local instance · application/json)
{
  "error": "nonce_conflict",
  "message": "nonce has already been used for a different issuer action"
}

Delete a custom field

POST /v2/assets/:asset_id/actions SIGNED · operation delete_custom_field

Removes one top-level key from custom.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Signature requiredheader · Base64Signature of the exact request body with the asset's current issuer key. How to sign.
signing_context requiredbody · string"liquid-asset-registry-action-v1"
asset_id requiredbody · hex (64)Must match the URL.
operation requiredbody · string"delete_custom_field"
mutable_schema_version requiredbody · integer1.
custom_key requiredbody · stringKey to remove. Deleting a missing key is a no_op_action.
prev_action_hash requiredbody · hex (64)action_hash from Get latest action hash.
nonce requiredbody · string (8–128)Unique per asset. A UUID works.
timestamp requiredbody · RFC 3339Within ±5 min of server time and not older than your previous action. Use microseconds.
📘

The response has status (applied or idempotent_retry), the new audit_entry, and the updated asset (null on a retry). Retries and errors.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp, prev_action_hash). See Signing requests.
BODY='{"asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","custom_key":"isin","mutable_schema_version":1,"nonce":"47efd89d-876e-4faf-a29c-86c80bfcd845","operation":"delete_custom_field","prev_action_hash":"ecfe0032cb155e21c31e36ba7046b2d110fac9cba6dc47ddccf23edc3d271e3c","signing_context":"liquid-asset-registry-action-v1","timestamp":"2026-11-03T22:47:34.407000Z"}'
SIG='nb5g13VpBboasWp0XpSnO4XKEjADe6NPgLkS1YKHVDJGXcer5QQQrYV4hPW0Tv/t8CDOhItoakbWAQfXxZyMzg=='

curl -X POST "https://assets.blockstream.info/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Signature: $SIG" \
  --data-raw "$BODY"
// Node.js 18+ (ES module: save as .mjs or set "type": "module")
import { createECDH, createHash, createPrivateKey, randomUUID, sign } from "node:crypto";

const BASE_URL = "https://assets.blockstream.info";
const ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d";
const PRIVATE_KEY = process.env.ISSUER_PRIVATE_KEY; // 32-byte hex: the asset's current issuer key

// The next action must reference the hash of the latest accepted one
const latest = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions/latest`);
if (!latest.ok) throw new Error(`${latest.status} ${await latest.text()}`);
const { action_hash: prevActionHash } = await latest.json();

const action = {
  asset_id: ASSET_ID,
  custom_key: "isin",
  mutable_schema_version: 1,
  nonce: randomUUID(),
  operation: "delete_custom_field",
  prev_action_hash: prevActionHash,
  signing_context: "liquid-asset-registry-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Signature": signMessage(body, PRIVATE_KEY) },
  body: body,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(JSON.stringify(await res.json(), null, 2)); // full output: plain console.log cuts long strings and deep objects

// --- signing helpers (see Signing requests) ---
// Canonical JSON: keys sorted recursively, no whitespace. Use integers or strings for numbers
// (non-integer numbers are formatted differently by JavaScript and the registry).
function canonicalJson(v) {
  if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
  if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
  return JSON.stringify(v);
}

// Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message.
function signMessage(message, privateKeyHex) {
  const varint = (n) => (n < 0xfd ? Buffer.from([n]) : Buffer.from([0xfd, n & 0xff, n >> 8]));
  const msg = Buffer.from(message, "utf8");
  const magic = Buffer.from("Bitcoin Signed Message:\n");
  const firstHash = createHash("sha256").update(Buffer.concat([varint(magic.length), magic, varint(msg.length), msg])).digest();
  const d = Buffer.from(privateKeyHex, "hex");
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(d);
  const pub = ecdh.getPublicKey();
  const key = createPrivateKey({ format: "jwk", key: { kty: "EC", crv: "secp256k1", d: d.toString("base64url"),
    x: pub.subarray(1, 33).toString("base64url"), y: pub.subarray(33).toString("base64url") } });
  const sig = sign("sha256", firstHash, { key, dsaEncoding: "ieee-p1363" }); // ECDSA-SHA256 adds the second hash
  const n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
  const s = BigInt("0x" + sig.subarray(32).toString("hex"));
  if (s > n / 2n) sig.write((n - s).toString(16).padStart(64, "0"), 32, "hex"); // low-S form
  return sig.toString("base64");
}

// Compressed public key (66 hex) for a private key.
function publicKeyOf(privateKeyHex) {
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(Buffer.from(privateKeyHex, "hex"));
  return ecdh.getPublicKey("hex", "compressed");
}
# pip install requests cryptography==50.0.2
import base64
import hashlib
import json
import os
import uuid
from datetime import datetime, timezone

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature

BASE_URL = "https://assets.blockstream.info"
ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
PRIVATE_KEY = os.environ["ISSUER_PRIVATE_KEY"]  # 32-byte hex: the asset's current issuer key

def canonical_json(value):
    """Canonical JSON: keys sorted recursively, no whitespace. Prefer integers or strings for numbers."""
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def sign_message(message, private_key_hex):
    """Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message."""
    msg = message.encode("utf-8")
    varint = lambda n: bytes([n]) if n < 0xFD else b"\xfd" + n.to_bytes(2, "little")
    payload = varint(24) + b"Bitcoin Signed Message:\n" + varint(len(msg)) + msg
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    first_hash = hashlib.sha256(payload).digest()  # ECDSA-SHA256 adds the second hash
    r, s = decode_dss_signature(key.sign(first_hash, ec.ECDSA(hashes.SHA256())))
    n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
    s = min(s, n - s)  # low-S form
    return base64.b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big")).decode()

def public_key_of(private_key_hex):
    """Compressed public key (66 hex) for a private key."""
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint).hex()

# The next action must reference the hash of the latest accepted one
latest = requests.get(f"{BASE_URL}/v2/assets/{ASSET_ID}/actions/latest", timeout=30)
if not latest.ok:
    raise RuntimeError(f"{latest.status_code} {latest.text}")
prev_action_hash = latest.json()["action_hash"]

action = {
    "asset_id": ASSET_ID,
    "custom_key": "isin",
    "mutable_schema_version": 1,
    "nonce": str(uuid.uuid4()),
    "operation": "delete_custom_field",
    "prev_action_hash": prev_action_hash,
    "signing_context": "liquid-asset-registry-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/assets/{ASSET_ID}/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Signature": sign_message(body, PRIVATE_KEY)},
    timeout=30,
)
if not res.ok:
    raise RuntimeError(f"{res.status_code} {res.text}")
print(res.json())
// go mod init example && go get github.com/decred/dcrd/dcrec/secp256k1/[email protected]
package main

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/decred/dcrd/dcrec/secp256k1/v4"
	"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)

func main() {
	baseURL := "https://assets.blockstream.info"
	assetID := "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
	endpoint := baseURL + "/v2/assets/" + assetID + "/actions"
	privateKey := os.Getenv("ISSUER_PRIVATE_KEY") // 32-byte hex: the asset's current issuer key

	// The next action must reference the hash of the latest accepted one
	var latest struct {
		ActionHash string `json:"action_hash"`
	}
	getJSON(baseURL+"/v2/assets/"+assetID+"/actions/latest", &latest)

	action := map[string]any{}
	action["asset_id"] = assetID
	action["custom_key"] = "isin"
	action["mutable_schema_version"] = 1
	action["nonce"] = newNonce()
	action["operation"] = "delete_custom_field"
	action["prev_action_hash"] = latest.ActionHash
	action["signing_context"] = "liquid-asset-registry-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	payload := canonicalJSON(action) // send exactly the signed bytes
	sig := signMessage(payload, privateKey)
	req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Asset-Registry-Signature", sig)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, body))
	}
	fmt.Println(string(body))
}

// canonicalJSON: keys sorted (Go sorts map keys), no whitespace, no HTML escaping.
func canonicalJSON(v any) []byte {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		panic(err)
	}
	return bytes.TrimRight(buf.Bytes(), "\n")
}

// signMessage returns a Bitcoin Signed Message signature (Base64, 64-byte R||S).
func signMessage(message []byte, privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	var payload bytes.Buffer
	writeVarint(&payload, 24)
	payload.WriteString("Bitcoin Signed Message:\n")
	writeVarint(&payload, len(message))
	payload.Write(message)
	first := sha256.Sum256(payload.Bytes())
	digest := sha256.Sum256(first[:])
	sig := ecdsa.SignCompact(secp256k1.PrivKeyFromBytes(key), digest[:], true) // recovery byte + R||S, low-S
	return base64.StdEncoding.EncodeToString(sig[1:])
}

func writeVarint(b *bytes.Buffer, n int) {
	if n < 0xfd {
		b.WriteByte(byte(n))
		return
	}
	b.WriteByte(0xfd)
	b.WriteByte(byte(n))
	b.WriteByte(byte(n >> 8))
}

// publicKeyOf returns the compressed public key (66 hex) for a private key.
func publicKeyOf(privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	return hex.EncodeToString(secp256k1.PrivKeyFromBytes(key).PubKey().SerializeCompressed())
}

func getJSON(endpoint string, v any) {
	res, err := http.Get(endpoint)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	out, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, out))
	}
	if err := json.Unmarshal(out, v); err != nil {
		panic(err)
	}
}

func newNonce() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}
Response: 200 (local instance · application/json)
{
  "status": "applied",
  "audit_entry": {
    "audit_id": 6,
    "server_received_at": "2026-11-03T22:47:34.408498Z",
    "actor": "issuer",
    "verified_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "admin_id": null,
    "action": {
      "nonce": "47efd89d-876e-4faf-a29c-86c80bfcd845",
      "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
      "operation": "delete_custom_field",
      "timestamp": "2026-11-03T22:47:34.407000Z",
      "custom_key": "isin",
      "signing_context": "liquid-asset-registry-action-v1",
      "prev_action_hash": "ecfe0032cb155e21c31e36ba7046b2d110fac9cba6dc47ddccf23edc3d271e3c",
      "mutable_schema_version": 1
    },
    "action_hash": "8ca164efaa2ee303ec08c2b9e2e4f40e9abff654f0b0b7f011b93bf142d18e89",
    "signature": "nb5g13VpBboasWp0XpSnO4XKEjADe6NPgLkS1YKHVDJGXcer5QQQrYV4hPW0Tv/t8CDOhItoakbWAQfXxZyMzg=="
  },
  "asset": {
    "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
    "contract": {
      "entity": {
        "domain": "example-issuer.com"
      },
      "name": "Example Dollar",
      "precision": 8,
      "ticker": "EXUSD",
      "version": 2,
      "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e"
    },
    "issuance_txin": null,
    "issuance_prevout": null,
    "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "initial_issuer_pubkey_source": "contract",
    "current_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "issuer_pubkey_history": [
      {
        "pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
        "valid_from_audit_id": 1,
        "valid_until_audit_id": null
      }
    ],
    "mutable": {
      "trading_venues": [
        {
          "venue": "sideswap",
          "url": "https://sideswap.io/"
        },
        {
          "venue": "bitfinex",
          "url": "https://trading.bitfinex.com/t/EXUSD:USD"
        }
      ],
      "category_tags": [
        "stablecoin",
        "tokenized"
      ],
      "custom": {
        "website": "https://example-issuer.com"
      }
    },
    "admin": {
      "asset_type": null,
      "featured": false,
      "malicious": false,
      "delisted": false,
      "admin_notes": null,
      "last_admin_action": null
    },
    "icon": null,
    "status": "active",
    "created_at": "2026-11-03T22:47:34.213879Z",
    "updated_at": "2026-11-03T22:47:34.413294Z"
  }
}
Error: wrong mutable_schema_version: 400 (local instance · application/json)
{
  "error": "mutable_schema_version_mismatch",
  "message": "mutable schema version does not match current asset state",
  "details": {
    "current": 1,
    "submitted": 2
  }
}

Replace all custom fields

POST /v2/assets/:asset_id/actions SIGNED · operation replace_custom

Replaces the whole custom object.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Signature requiredheader · Base64Signature of the exact request body with the asset's current issuer key. How to sign.
signing_context requiredbody · string"liquid-asset-registry-action-v1"
asset_id requiredbody · hex (64)Must match the URL.
operation requiredbody · string"replace_custom"
mutable_schema_version requiredbody · integer1.
custom requiredbody · objectNew object. Same limits as Set a custom field.
prev_action_hash requiredbody · hex (64)action_hash from Get latest action hash.
nonce requiredbody · string (8–128)Unique per asset. A UUID works.
timestamp requiredbody · RFC 3339Within ±5 min of server time and not older than your previous action. Use microseconds.
📘

The response has status (applied or idempotent_retry), the new audit_entry, and the updated asset (null on a retry). Retries and errors.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp, prev_action_hash). See Signing requests.
BODY='{"asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","custom":{"attestation":"https://example-issuer.com/reserves","website":"https://example-issuer.com"},"mutable_schema_version":1,"nonce":"7b886aab-bf1a-4e6b-b827-626c70f6b06f","operation":"replace_custom","prev_action_hash":"8ca164efaa2ee303ec08c2b9e2e4f40e9abff654f0b0b7f011b93bf142d18e89","signing_context":"liquid-asset-registry-action-v1","timestamp":"2026-11-03T22:47:34.427000Z"}'
SIG='fccFd+2bs8e9ZvQzkqYGplEOBsM7VDOWwyA5966q6gFgQaZ4tqnimHeDxQsaxo/5VhKwMIQbM8loN5hskRZcxA=='

curl -X POST "https://assets.blockstream.info/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Signature: $SIG" \
  --data-raw "$BODY"
// Node.js 18+ (ES module: save as .mjs or set "type": "module")
import { createECDH, createHash, createPrivateKey, randomUUID, sign } from "node:crypto";

const BASE_URL = "https://assets.blockstream.info";
const ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d";
const PRIVATE_KEY = process.env.ISSUER_PRIVATE_KEY; // 32-byte hex: the asset's current issuer key

// The next action must reference the hash of the latest accepted one
const latest = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions/latest`);
if (!latest.ok) throw new Error(`${latest.status} ${await latest.text()}`);
const { action_hash: prevActionHash } = await latest.json();

const action = {
  asset_id: ASSET_ID,
  custom: { attestation: "https://example-issuer.com/reserves", website: "https://example-issuer.com" },
  mutable_schema_version: 1,
  nonce: randomUUID(),
  operation: "replace_custom",
  prev_action_hash: prevActionHash,
  signing_context: "liquid-asset-registry-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Signature": signMessage(body, PRIVATE_KEY) },
  body: body,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(JSON.stringify(await res.json(), null, 2)); // full output: plain console.log cuts long strings and deep objects

// --- signing helpers (see Signing requests) ---
// Canonical JSON: keys sorted recursively, no whitespace. Use integers or strings for numbers
// (non-integer numbers are formatted differently by JavaScript and the registry).
function canonicalJson(v) {
  if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
  if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
  return JSON.stringify(v);
}

// Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message.
function signMessage(message, privateKeyHex) {
  const varint = (n) => (n < 0xfd ? Buffer.from([n]) : Buffer.from([0xfd, n & 0xff, n >> 8]));
  const msg = Buffer.from(message, "utf8");
  const magic = Buffer.from("Bitcoin Signed Message:\n");
  const firstHash = createHash("sha256").update(Buffer.concat([varint(magic.length), magic, varint(msg.length), msg])).digest();
  const d = Buffer.from(privateKeyHex, "hex");
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(d);
  const pub = ecdh.getPublicKey();
  const key = createPrivateKey({ format: "jwk", key: { kty: "EC", crv: "secp256k1", d: d.toString("base64url"),
    x: pub.subarray(1, 33).toString("base64url"), y: pub.subarray(33).toString("base64url") } });
  const sig = sign("sha256", firstHash, { key, dsaEncoding: "ieee-p1363" }); // ECDSA-SHA256 adds the second hash
  const n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
  const s = BigInt("0x" + sig.subarray(32).toString("hex"));
  if (s > n / 2n) sig.write((n - s).toString(16).padStart(64, "0"), 32, "hex"); // low-S form
  return sig.toString("base64");
}

// Compressed public key (66 hex) for a private key.
function publicKeyOf(privateKeyHex) {
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(Buffer.from(privateKeyHex, "hex"));
  return ecdh.getPublicKey("hex", "compressed");
}
# pip install requests cryptography==50.0.2
import base64
import hashlib
import json
import os
import uuid
from datetime import datetime, timezone

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature

BASE_URL = "https://assets.blockstream.info"
ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
PRIVATE_KEY = os.environ["ISSUER_PRIVATE_KEY"]  # 32-byte hex: the asset's current issuer key

def canonical_json(value):
    """Canonical JSON: keys sorted recursively, no whitespace. Prefer integers or strings for numbers."""
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def sign_message(message, private_key_hex):
    """Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message."""
    msg = message.encode("utf-8")
    varint = lambda n: bytes([n]) if n < 0xFD else b"\xfd" + n.to_bytes(2, "little")
    payload = varint(24) + b"Bitcoin Signed Message:\n" + varint(len(msg)) + msg
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    first_hash = hashlib.sha256(payload).digest()  # ECDSA-SHA256 adds the second hash
    r, s = decode_dss_signature(key.sign(first_hash, ec.ECDSA(hashes.SHA256())))
    n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
    s = min(s, n - s)  # low-S form
    return base64.b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big")).decode()

def public_key_of(private_key_hex):
    """Compressed public key (66 hex) for a private key."""
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint).hex()

# The next action must reference the hash of the latest accepted one
latest = requests.get(f"{BASE_URL}/v2/assets/{ASSET_ID}/actions/latest", timeout=30)
if not latest.ok:
    raise RuntimeError(f"{latest.status_code} {latest.text}")
prev_action_hash = latest.json()["action_hash"]

action = {
    "asset_id": ASSET_ID,
    "custom": {"attestation": "https://example-issuer.com/reserves", "website": "https://example-issuer.com"},
    "mutable_schema_version": 1,
    "nonce": str(uuid.uuid4()),
    "operation": "replace_custom",
    "prev_action_hash": prev_action_hash,
    "signing_context": "liquid-asset-registry-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/assets/{ASSET_ID}/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Signature": sign_message(body, PRIVATE_KEY)},
    timeout=30,
)
if not res.ok:
    raise RuntimeError(f"{res.status_code} {res.text}")
print(res.json())
// go mod init example && go get github.com/decred/dcrd/dcrec/secp256k1/[email protected]
package main

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/decred/dcrd/dcrec/secp256k1/v4"
	"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)

func main() {
	baseURL := "https://assets.blockstream.info"
	assetID := "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
	endpoint := baseURL + "/v2/assets/" + assetID + "/actions"
	privateKey := os.Getenv("ISSUER_PRIVATE_KEY") // 32-byte hex: the asset's current issuer key

	// The next action must reference the hash of the latest accepted one
	var latest struct {
		ActionHash string `json:"action_hash"`
	}
	getJSON(baseURL+"/v2/assets/"+assetID+"/actions/latest", &latest)

	action := map[string]any{}
	action["asset_id"] = assetID
	action["custom"] = map[string]any{"attestation": "https://example-issuer.com/reserves", "website": "https://example-issuer.com"}
	action["mutable_schema_version"] = 1
	action["nonce"] = newNonce()
	action["operation"] = "replace_custom"
	action["prev_action_hash"] = latest.ActionHash
	action["signing_context"] = "liquid-asset-registry-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	payload := canonicalJSON(action) // send exactly the signed bytes
	sig := signMessage(payload, privateKey)
	req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Asset-Registry-Signature", sig)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, body))
	}
	fmt.Println(string(body))
}

// canonicalJSON: keys sorted (Go sorts map keys), no whitespace, no HTML escaping.
func canonicalJSON(v any) []byte {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		panic(err)
	}
	return bytes.TrimRight(buf.Bytes(), "\n")
}

// signMessage returns a Bitcoin Signed Message signature (Base64, 64-byte R||S).
func signMessage(message []byte, privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	var payload bytes.Buffer
	writeVarint(&payload, 24)
	payload.WriteString("Bitcoin Signed Message:\n")
	writeVarint(&payload, len(message))
	payload.Write(message)
	first := sha256.Sum256(payload.Bytes())
	digest := sha256.Sum256(first[:])
	sig := ecdsa.SignCompact(secp256k1.PrivKeyFromBytes(key), digest[:], true) // recovery byte + R||S, low-S
	return base64.StdEncoding.EncodeToString(sig[1:])
}

func writeVarint(b *bytes.Buffer, n int) {
	if n < 0xfd {
		b.WriteByte(byte(n))
		return
	}
	b.WriteByte(0xfd)
	b.WriteByte(byte(n))
	b.WriteByte(byte(n >> 8))
}

// publicKeyOf returns the compressed public key (66 hex) for a private key.
func publicKeyOf(privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	return hex.EncodeToString(secp256k1.PrivKeyFromBytes(key).PubKey().SerializeCompressed())
}

func getJSON(endpoint string, v any) {
	res, err := http.Get(endpoint)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	out, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, out))
	}
	if err := json.Unmarshal(out, v); err != nil {
		panic(err)
	}
}

func newNonce() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}
Response: 200 (local instance · application/json)
{
  "status": "applied",
  "audit_entry": {
    "audit_id": 7,
    "server_received_at": "2026-11-03T22:47:34.428642Z",
    "actor": "issuer",
    "verified_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "admin_id": null,
    "action": {
      "nonce": "7b886aab-bf1a-4e6b-b827-626c70f6b06f",
      "custom": {
        "website": "https://example-issuer.com",
        "attestation": "https://example-issuer.com/reserves"
      },
      "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
      "operation": "replace_custom",
      "timestamp": "2026-11-03T22:47:34.427000Z",
      "signing_context": "liquid-asset-registry-action-v1",
      "prev_action_hash": "8ca164efaa2ee303ec08c2b9e2e4f40e9abff654f0b0b7f011b93bf142d18e89",
      "mutable_schema_version": 1
    },
    "action_hash": "2bc27ebf045fec3c123e5ceba8ebf759911762f53693501d675a434c6de56565",
    "signature": "fccFd+2bs8e9ZvQzkqYGplEOBsM7VDOWwyA5966q6gFgQaZ4tqnimHeDxQsaxo/5VhKwMIQbM8loN5hskRZcxA=="
  },
  "asset": {
    "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
    "contract": {
      "entity": {
        "domain": "example-issuer.com"
      },
      "name": "Example Dollar",
      "precision": 8,
      "ticker": "EXUSD",
      "version": 2,
      "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e"
    },
    "issuance_txin": null,
    "issuance_prevout": null,
    "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "initial_issuer_pubkey_source": "contract",
    "current_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "issuer_pubkey_history": [
      {
        "pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
        "valid_from_audit_id": 1,
        "valid_until_audit_id": null
      }
    ],
    "mutable": {
      "trading_venues": [
        {
          "venue": "sideswap",
          "url": "https://sideswap.io/"
        },
        {
          "venue": "bitfinex",
          "url": "https://trading.bitfinex.com/t/EXUSD:USD"
        }
      ],
      "category_tags": [
        "stablecoin",
        "tokenized"
      ],
      "custom": {
        "attestation": "https://example-issuer.com/reserves",
        "website": "https://example-issuer.com"
      }
    },
    "admin": {
      "asset_type": null,
      "featured": false,
      "malicious": false,
      "delisted": false,
      "admin_notes": null,
      "last_admin_action": null
    },
    "icon": null,
    "status": "active",
    "created_at": "2026-11-03T22:47:34.213879Z",
    "updated_at": "2026-11-03T22:47:34.433577Z"
  }
}

Rotate the issuer key

POST /v2/assets/:asset_id/actions SIGNED · operation rotate_issuer_pubkey

Hands control of the asset to a new key. Sign it with the current key; every later action must be signed with the new one. The asset's issuer_pubkey_history records both.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Signature requiredheader · Base64Signature of the exact request body with the asset's current issuer key. How to sign.
signing_context requiredbody · string"liquid-asset-registry-action-v1"
asset_id requiredbody · hex (64)Must match the URL.
operation requiredbody · string"rotate_issuer_pubkey"
new_issuer_pubkey requiredbody · pubkeyCompressed secp256k1 key (66 hex).
new_issuer_pubkey_signaturebody · stringAccepted but not verified.
prev_action_hash requiredbody · hex (64)action_hash from Get latest action hash.
nonce requiredbody · string (8–128)Unique per asset. A UUID works.
timestamp requiredbody · RFC 3339Within ±5 min of server time and not older than your previous action. Use microseconds.
🚧

Double-check the new key: a wrong one locks you out of the asset permanently. The registry doesn't check that you hold the new key, or even that it's a valid key, and there's no recovery: afterwards the old key can't sign anything for this asset (including deregistration and the legacy DELETE), and admins can't reset it. Derive new_issuer_pubkey from the new private key in code rather than pasting it, and sign a test message with that key first.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp, prev_action_hash). See Signing requests.
# new_issuer_pubkey must be derived from the new private key in code, never pasted.
BODY='{"asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","new_issuer_pubkey":"03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc","nonce":"e9b4ca6a-9102-4c75-8331-3d59b687e602","operation":"rotate_issuer_pubkey","prev_action_hash":"99036ac1bb6a51f59f16bfac64165fe81ce376ee199b066d7fc757c9c370019f","signing_context":"liquid-asset-registry-action-v1","timestamp":"2026-11-03T22:47:34.872000Z"}'
SIG='mJOyrXCassAF7IVSVhVk6fXrIzuI4f7Kre79wvWNzt9y6NhdSAFjQnUn/YsmXq2qASbRUXaf3QkV2JbIeUEklg=='

curl -X POST "https://assets.blockstream.info/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Signature: $SIG" \
  --data-raw "$BODY"
// Node.js 18+ (ES module: save as .mjs or set "type": "module")
import { createECDH, createHash, createPrivateKey, randomUUID, sign } from "node:crypto";

const BASE_URL = "https://assets.blockstream.info";
const ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d";
const PRIVATE_KEY = process.env.ISSUER_PRIVATE_KEY; // 32-byte hex: the asset's current issuer key
const NEW_PRIVATE_KEY = process.env.NEW_ISSUER_PRIVATE_KEY; // 32-byte hex: the new issuer key. Store it safely before rotating

// The next action must reference the hash of the latest accepted one
const latest = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions/latest`);
if (!latest.ok) throw new Error(`${latest.status} ${await latest.text()}`);
const { action_hash: prevActionHash } = await latest.json();

const action = {
  asset_id: ASSET_ID,
  new_issuer_pubkey: publicKeyOf(NEW_PRIVATE_KEY), // derived from the new key, never pasted
  nonce: randomUUID(),
  operation: "rotate_issuer_pubkey",
  prev_action_hash: prevActionHash,
  signing_context: "liquid-asset-registry-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Signature": signMessage(body, PRIVATE_KEY) },
  body: body,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(JSON.stringify(await res.json(), null, 2)); // full output: plain console.log cuts long strings and deep objects

// --- signing helpers (see Signing requests) ---
// Canonical JSON: keys sorted recursively, no whitespace. Use integers or strings for numbers
// (non-integer numbers are formatted differently by JavaScript and the registry).
function canonicalJson(v) {
  if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
  if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
  return JSON.stringify(v);
}

// Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message.
function signMessage(message, privateKeyHex) {
  const varint = (n) => (n < 0xfd ? Buffer.from([n]) : Buffer.from([0xfd, n & 0xff, n >> 8]));
  const msg = Buffer.from(message, "utf8");
  const magic = Buffer.from("Bitcoin Signed Message:\n");
  const firstHash = createHash("sha256").update(Buffer.concat([varint(magic.length), magic, varint(msg.length), msg])).digest();
  const d = Buffer.from(privateKeyHex, "hex");
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(d);
  const pub = ecdh.getPublicKey();
  const key = createPrivateKey({ format: "jwk", key: { kty: "EC", crv: "secp256k1", d: d.toString("base64url"),
    x: pub.subarray(1, 33).toString("base64url"), y: pub.subarray(33).toString("base64url") } });
  const sig = sign("sha256", firstHash, { key, dsaEncoding: "ieee-p1363" }); // ECDSA-SHA256 adds the second hash
  const n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
  const s = BigInt("0x" + sig.subarray(32).toString("hex"));
  if (s > n / 2n) sig.write((n - s).toString(16).padStart(64, "0"), 32, "hex"); // low-S form
  return sig.toString("base64");
}

// Compressed public key (66 hex) for a private key.
function publicKeyOf(privateKeyHex) {
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(Buffer.from(privateKeyHex, "hex"));
  return ecdh.getPublicKey("hex", "compressed");
}
# pip install requests cryptography==50.0.2
import base64
import hashlib
import json
import os
import uuid
from datetime import datetime, timezone

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature

BASE_URL = "https://assets.blockstream.info"
ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
PRIVATE_KEY = os.environ["ISSUER_PRIVATE_KEY"]  # 32-byte hex: the asset's current issuer key
NEW_PRIVATE_KEY = os.environ["NEW_ISSUER_PRIVATE_KEY"]  # 32-byte hex: the new issuer key. Store it safely before rotating

def canonical_json(value):
    """Canonical JSON: keys sorted recursively, no whitespace. Prefer integers or strings for numbers."""
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def sign_message(message, private_key_hex):
    """Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message."""
    msg = message.encode("utf-8")
    varint = lambda n: bytes([n]) if n < 0xFD else b"\xfd" + n.to_bytes(2, "little")
    payload = varint(24) + b"Bitcoin Signed Message:\n" + varint(len(msg)) + msg
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    first_hash = hashlib.sha256(payload).digest()  # ECDSA-SHA256 adds the second hash
    r, s = decode_dss_signature(key.sign(first_hash, ec.ECDSA(hashes.SHA256())))
    n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
    s = min(s, n - s)  # low-S form
    return base64.b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big")).decode()

def public_key_of(private_key_hex):
    """Compressed public key (66 hex) for a private key."""
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint).hex()

# The next action must reference the hash of the latest accepted one
latest = requests.get(f"{BASE_URL}/v2/assets/{ASSET_ID}/actions/latest", timeout=30)
if not latest.ok:
    raise RuntimeError(f"{latest.status_code} {latest.text}")
prev_action_hash = latest.json()["action_hash"]

action = {
    "asset_id": ASSET_ID,
    "new_issuer_pubkey": public_key_of(NEW_PRIVATE_KEY),
    "nonce": str(uuid.uuid4()),
    "operation": "rotate_issuer_pubkey",
    "prev_action_hash": prev_action_hash,
    "signing_context": "liquid-asset-registry-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/assets/{ASSET_ID}/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Signature": sign_message(body, PRIVATE_KEY)},
    timeout=30,
)
if not res.ok:
    raise RuntimeError(f"{res.status_code} {res.text}")
print(res.json())
// go mod init example && go get github.com/decred/dcrd/dcrec/secp256k1/[email protected]
package main

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/decred/dcrd/dcrec/secp256k1/v4"
	"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)

func main() {
	baseURL := "https://assets.blockstream.info"
	assetID := "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
	endpoint := baseURL + "/v2/assets/" + assetID + "/actions"
	privateKey := os.Getenv("ISSUER_PRIVATE_KEY") // 32-byte hex: the asset's current issuer key
	// 32-byte hex: the new issuer key. Store it safely before rotating
	newPrivateKey := os.Getenv("NEW_ISSUER_PRIVATE_KEY")

	// The next action must reference the hash of the latest accepted one
	var latest struct {
		ActionHash string `json:"action_hash"`
	}
	getJSON(baseURL+"/v2/assets/"+assetID+"/actions/latest", &latest)

	action := map[string]any{}
	action["asset_id"] = assetID
	action["new_issuer_pubkey"] = publicKeyOf(newPrivateKey)
	action["nonce"] = newNonce()
	action["operation"] = "rotate_issuer_pubkey"
	action["prev_action_hash"] = latest.ActionHash
	action["signing_context"] = "liquid-asset-registry-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	payload := canonicalJSON(action) // send exactly the signed bytes
	sig := signMessage(payload, privateKey)
	req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Asset-Registry-Signature", sig)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, body))
	}
	fmt.Println(string(body))
}

// canonicalJSON: keys sorted (Go sorts map keys), no whitespace, no HTML escaping.
func canonicalJSON(v any) []byte {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		panic(err)
	}
	return bytes.TrimRight(buf.Bytes(), "\n")
}

// signMessage returns a Bitcoin Signed Message signature (Base64, 64-byte R||S).
func signMessage(message []byte, privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	var payload bytes.Buffer
	writeVarint(&payload, 24)
	payload.WriteString("Bitcoin Signed Message:\n")
	writeVarint(&payload, len(message))
	payload.Write(message)
	first := sha256.Sum256(payload.Bytes())
	digest := sha256.Sum256(first[:])
	sig := ecdsa.SignCompact(secp256k1.PrivKeyFromBytes(key), digest[:], true) // recovery byte + R||S, low-S
	return base64.StdEncoding.EncodeToString(sig[1:])
}

func writeVarint(b *bytes.Buffer, n int) {
	if n < 0xfd {
		b.WriteByte(byte(n))
		return
	}
	b.WriteByte(0xfd)
	b.WriteByte(byte(n))
	b.WriteByte(byte(n >> 8))
}

// publicKeyOf returns the compressed public key (66 hex) for a private key.
func publicKeyOf(privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	return hex.EncodeToString(secp256k1.PrivKeyFromBytes(key).PubKey().SerializeCompressed())
}

func getJSON(endpoint string, v any) {
	res, err := http.Get(endpoint)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	out, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, out))
	}
	if err := json.Unmarshal(out, v); err != nil {
		panic(err)
	}
}

func newNonce() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}
Response: 200 (local instance · application/json)
{
  "status": "applied",
  "audit_entry": {
    "audit_id": 21,
    "server_received_at": "2026-11-03T22:47:34.873155Z",
    "actor": "issuer",
    "verified_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "admin_id": null,
    "action": {
      "nonce": "e9b4ca6a-9102-4c75-8331-3d59b687e602",
      "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
      "operation": "rotate_issuer_pubkey",
      "timestamp": "2026-11-03T22:47:34.872000Z",
      "signing_context": "liquid-asset-registry-action-v1",
      "prev_action_hash": "99036ac1bb6a51f59f16bfac64165fe81ce376ee199b066d7fc757c9c370019f",
      "new_issuer_pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
      "new_issuer_pubkey_signature": null
    },
    "action_hash": "0fd8f57ab582dd4e391d9341aad2e556180c58d839c2d295694d9878bc639c7d",
    "signature": "mJOyrXCassAF7IVSVhVk6fXrIzuI4f7Kre79wvWNzt9y6NhdSAFjQnUn/YsmXq2qASbRUXaf3QkV2JbIeUEklg=="
  },
  "asset": {
    "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
    "contract": {
      "entity": {
        "domain": "example-issuer.com"
      },
      "name": "Example Dollar",
      "precision": 8,
      "ticker": "EXUSD",
      "version": 2,
      "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e"
    },
    "issuance_txin": null,
    "issuance_prevout": null,
    "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "initial_issuer_pubkey_source": "contract",
    "current_issuer_pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
    "issuer_pubkey_history": [
      {
        "pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
        "valid_from_audit_id": 1,
        "valid_until_audit_id": 21
      },
      {
        "pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
        "valid_from_audit_id": 21,
        "valid_until_audit_id": null
      }
    ],
    "mutable": {
      "trading_venues": [
        {
          "venue": "sideswap",
          "url": "https://sideswap.io/"
        },
        {
          "venue": "bitfinex",
          "url": "https://trading.bitfinex.com/t/EXUSD:USD"
        }
      ],
      "category_tags": [
        "stablecoin",
        "tokenized"
      ],
      "custom": {
        "attestation": "https://example-issuer.com/reserves",
        "series": "A",
        "website": "https://example-issuer.com"
      }
    },
    "admin": {
      "asset_type": "stablecoin",
      "featured": true,
      "malicious": false,
      "delisted": false,
      "admin_notes": "Reviewed",
      "last_admin_action": {
        "action": "update_admin_annotations",
        "field": null,
        "admin_id": "ddb0f797-d0f5-458f-a59b-50cae555adb1",
        "timestamp": "2026-11-03T22:47:34.814033Z"
      }
    },
    "icon": {
      "href": "/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/icon/4575a54494982204ffa6991b391259e63cf800d954cafbf5eea80984cb73b225.png"
    },
    "status": "active",
    "created_at": "2026-11-03T22:47:34.213879Z",
    "updated_at": "2026-11-03T22:47:34.877256Z"
  }
}
Next action, signed with the new key: 200 (local instance · application/json)
{
  "status": "applied",
  "audit_entry": {
    "audit_id": 22,
    "server_received_at": "2026-11-03T22:47:34.894617Z",
    "actor": "issuer",
    "verified_pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
    "admin_id": null,
    "action": {
      "nonce": "b4224b8f-c597-47a9-ab4f-525f16d86b72",
      "value": "B",
      "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
      "operation": "set_custom_field",
      "timestamp": "2026-11-03T22:47:34.893000Z",
      "custom_key": "series",
      "signing_context": "liquid-asset-registry-action-v1",
      "prev_action_hash": "0fd8f57ab582dd4e391d9341aad2e556180c58d839c2d295694d9878bc639c7d",
      "mutable_schema_version": 1
    },
    "action_hash": "cff6f072e254fff556d0acc846ab6ddc2c1c5ceda92063b0c4ef37f27fb5e7fb",
    "signature": "yLl7zJ2UXYlq+a/1pG8umk9XwuGShMWucxp9MlJC5sdR+JqV/p3wS/OaQtCy9vR/K6aNFeYFgck+bibK7stlKQ=="
  },
  "asset": {
    "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
    "contract": {
      "entity": {
        "domain": "example-issuer.com"
      },
      "name": "Example Dollar",
      "precision": 8,
      "ticker": "EXUSD",
      "version": 2,
      "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e"
    },
    "issuance_txin": null,
    "issuance_prevout": null,
    "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "initial_issuer_pubkey_source": "contract",
    "current_issuer_pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
    "issuer_pubkey_history": [
      {
        "pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
        "valid_from_audit_id": 1,
        "valid_until_audit_id": 21
      },
      {
        "pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
        "valid_from_audit_id": 21,
        "valid_until_audit_id": null
      }
    ],
    "mutable": {
      "trading_venues": [
        {
          "venue": "sideswap",
          "url": "https://sideswap.io/"
        },
        {
          "venue": "bitfinex",
          "url": "https://trading.bitfinex.com/t/EXUSD:USD"
        }
      ],
      "category_tags": [
        "stablecoin",
        "tokenized"
      ],
      "custom": {
        "attestation": "https://example-issuer.com/reserves",
        "series": "B",
        "website": "https://example-issuer.com"
      }
    },
    "admin": {
      "asset_type": "stablecoin",
      "featured": true,
      "malicious": false,
      "delisted": false,
      "admin_notes": "Reviewed",
      "last_admin_action": {
        "action": "update_admin_annotations",
        "field": null,
        "admin_id": "ddb0f797-d0f5-458f-a59b-50cae555adb1",
        "timestamp": "2026-11-03T22:47:34.814033Z"
      }
    },
    "icon": {
      "href": "/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/icon/4575a54494982204ffa6991b391259e63cf800d954cafbf5eea80984cb73b225.png"
    },
    "status": "active",
    "created_at": "2026-11-03T22:47:34.213879Z",
    "updated_at": "2026-11-03T22:47:34.899301Z"
  }
}

Deregister an asset

POST /v2/assets/:asset_id/actions SIGNED · operation deregister

Removes the asset from every listing (v2 and legacy). Its audit log stays available and any pending icon proposal becomes obsolete.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Signature requiredheader · Base64Signature of the exact request body with the asset's current issuer key. How to sign.
signing_context requiredbody · string"liquid-asset-registry-action-v1"
asset_id requiredbody · hex (64)Must match the URL.
operation requiredbody · string"deregister"
prev_action_hash requiredbody · hex (64)action_hash from Get latest action hash.
nonce requiredbody · string (8–128)Unique per asset. A UUID works.
timestamp requiredbody · RFC 3339Within ±5 min of server time and not older than your previous action. Use microseconds.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp, prev_action_hash). See Signing requests.
BODY='{"asset_id":"6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e","nonce":"637a3427-9d70-4fd8-95b8-5c0102371fd5","operation":"deregister","prev_action_hash":"271a6bc5899ec48237098e7a35b15fc20362b465328e92d817bf16d65fbfcf29","signing_context":"liquid-asset-registry-action-v1","timestamp":"2026-11-03T22:47:34.986000Z"}'
SIG='SCLgV6OjBcJz8JOnHV5yzrVgdeQRcjCbEliJb6fBV5YRCxOZQ8dRv9TzJTS+gqzWiw8egZHoccajV+qWd2/b1g=='

curl -X POST "https://assets.blockstream.info/v2/assets/6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Signature: $SIG" \
  --data-raw "$BODY"
// Node.js 18+ (ES module: save as .mjs or set "type": "module")
import { createECDH, createHash, createPrivateKey, randomUUID, sign } from "node:crypto";

const BASE_URL = "https://assets.blockstream.info";
const ASSET_ID = "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e";
const PRIVATE_KEY = process.env.ISSUER_PRIVATE_KEY; // 32-byte hex: the asset's current issuer key

// The next action must reference the hash of the latest accepted one
const latest = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions/latest`);
if (!latest.ok) throw new Error(`${latest.status} ${await latest.text()}`);
const { action_hash: prevActionHash } = await latest.json();

const action = {
  asset_id: ASSET_ID,
  nonce: randomUUID(),
  operation: "deregister",
  prev_action_hash: prevActionHash,
  signing_context: "liquid-asset-registry-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Signature": signMessage(body, PRIVATE_KEY) },
  body: body,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(JSON.stringify(await res.json(), null, 2)); // full output: plain console.log cuts long strings and deep objects

// --- signing helpers (see Signing requests) ---
// Canonical JSON: keys sorted recursively, no whitespace. Use integers or strings for numbers
// (non-integer numbers are formatted differently by JavaScript and the registry).
function canonicalJson(v) {
  if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
  if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
  return JSON.stringify(v);
}

// Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message.
function signMessage(message, privateKeyHex) {
  const varint = (n) => (n < 0xfd ? Buffer.from([n]) : Buffer.from([0xfd, n & 0xff, n >> 8]));
  const msg = Buffer.from(message, "utf8");
  const magic = Buffer.from("Bitcoin Signed Message:\n");
  const firstHash = createHash("sha256").update(Buffer.concat([varint(magic.length), magic, varint(msg.length), msg])).digest();
  const d = Buffer.from(privateKeyHex, "hex");
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(d);
  const pub = ecdh.getPublicKey();
  const key = createPrivateKey({ format: "jwk", key: { kty: "EC", crv: "secp256k1", d: d.toString("base64url"),
    x: pub.subarray(1, 33).toString("base64url"), y: pub.subarray(33).toString("base64url") } });
  const sig = sign("sha256", firstHash, { key, dsaEncoding: "ieee-p1363" }); // ECDSA-SHA256 adds the second hash
  const n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
  const s = BigInt("0x" + sig.subarray(32).toString("hex"));
  if (s > n / 2n) sig.write((n - s).toString(16).padStart(64, "0"), 32, "hex"); // low-S form
  return sig.toString("base64");
}

// Compressed public key (66 hex) for a private key.
function publicKeyOf(privateKeyHex) {
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(Buffer.from(privateKeyHex, "hex"));
  return ecdh.getPublicKey("hex", "compressed");
}
# pip install requests cryptography==50.0.2
import base64
import hashlib
import json
import os
import uuid
from datetime import datetime, timezone

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature

BASE_URL = "https://assets.blockstream.info"
ASSET_ID = "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e"
PRIVATE_KEY = os.environ["ISSUER_PRIVATE_KEY"]  # 32-byte hex: the asset's current issuer key

def canonical_json(value):
    """Canonical JSON: keys sorted recursively, no whitespace. Prefer integers or strings for numbers."""
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def sign_message(message, private_key_hex):
    """Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message."""
    msg = message.encode("utf-8")
    varint = lambda n: bytes([n]) if n < 0xFD else b"\xfd" + n.to_bytes(2, "little")
    payload = varint(24) + b"Bitcoin Signed Message:\n" + varint(len(msg)) + msg
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    first_hash = hashlib.sha256(payload).digest()  # ECDSA-SHA256 adds the second hash
    r, s = decode_dss_signature(key.sign(first_hash, ec.ECDSA(hashes.SHA256())))
    n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
    s = min(s, n - s)  # low-S form
    return base64.b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big")).decode()

def public_key_of(private_key_hex):
    """Compressed public key (66 hex) for a private key."""
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint).hex()

# The next action must reference the hash of the latest accepted one
latest = requests.get(f"{BASE_URL}/v2/assets/{ASSET_ID}/actions/latest", timeout=30)
if not latest.ok:
    raise RuntimeError(f"{latest.status_code} {latest.text}")
prev_action_hash = latest.json()["action_hash"]

action = {
    "asset_id": ASSET_ID,
    "nonce": str(uuid.uuid4()),
    "operation": "deregister",
    "prev_action_hash": prev_action_hash,
    "signing_context": "liquid-asset-registry-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/assets/{ASSET_ID}/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Signature": sign_message(body, PRIVATE_KEY)},
    timeout=30,
)
if not res.ok:
    raise RuntimeError(f"{res.status_code} {res.text}")
print(res.json())
// go mod init example && go get github.com/decred/dcrd/dcrec/secp256k1/[email protected]
package main

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/decred/dcrd/dcrec/secp256k1/v4"
	"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)

func main() {
	baseURL := "https://assets.blockstream.info"
	assetID := "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e"
	endpoint := baseURL + "/v2/assets/" + assetID + "/actions"
	privateKey := os.Getenv("ISSUER_PRIVATE_KEY") // 32-byte hex: the asset's current issuer key

	// The next action must reference the hash of the latest accepted one
	var latest struct {
		ActionHash string `json:"action_hash"`
	}
	getJSON(baseURL+"/v2/assets/"+assetID+"/actions/latest", &latest)

	action := map[string]any{}
	action["asset_id"] = assetID
	action["nonce"] = newNonce()
	action["operation"] = "deregister"
	action["prev_action_hash"] = latest.ActionHash
	action["signing_context"] = "liquid-asset-registry-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	payload := canonicalJSON(action) // send exactly the signed bytes
	sig := signMessage(payload, privateKey)
	req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Asset-Registry-Signature", sig)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, body))
	}
	fmt.Println(string(body))
}

// canonicalJSON: keys sorted (Go sorts map keys), no whitespace, no HTML escaping.
func canonicalJSON(v any) []byte {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		panic(err)
	}
	return bytes.TrimRight(buf.Bytes(), "\n")
}

// signMessage returns a Bitcoin Signed Message signature (Base64, 64-byte R||S).
func signMessage(message []byte, privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	var payload bytes.Buffer
	writeVarint(&payload, 24)
	payload.WriteString("Bitcoin Signed Message:\n")
	writeVarint(&payload, len(message))
	payload.Write(message)
	first := sha256.Sum256(payload.Bytes())
	digest := sha256.Sum256(first[:])
	sig := ecdsa.SignCompact(secp256k1.PrivKeyFromBytes(key), digest[:], true) // recovery byte + R||S, low-S
	return base64.StdEncoding.EncodeToString(sig[1:])
}

func writeVarint(b *bytes.Buffer, n int) {
	if n < 0xfd {
		b.WriteByte(byte(n))
		return
	}
	b.WriteByte(0xfd)
	b.WriteByte(byte(n))
	b.WriteByte(byte(n >> 8))
}

// publicKeyOf returns the compressed public key (66 hex) for a private key.
func publicKeyOf(privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	return hex.EncodeToString(secp256k1.PrivKeyFromBytes(key).PubKey().SerializeCompressed())
}

func getJSON(endpoint string, v any) {
	res, err := http.Get(endpoint)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	out, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, out))
	}
	if err := json.Unmarshal(out, v); err != nil {
		panic(err)
	}
}

func newNonce() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}
Response: 200 (local instance · application/json)
{
  "status": "applied",
  "audit_entry": {
    "audit_id": 27,
    "server_received_at": "2026-11-03T22:47:34.987271Z",
    "actor": "issuer",
    "verified_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "admin_id": null,
    "action": {
      "nonce": "637a3427-9d70-4fd8-95b8-5c0102371fd5",
      "asset_id": "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e",
      "operation": "deregister",
      "timestamp": "2026-11-03T22:47:34.986000Z",
      "signing_context": "liquid-asset-registry-action-v1",
      "prev_action_hash": "271a6bc5899ec48237098e7a35b15fc20362b465328e92d817bf16d65fbfcf29"
    },
    "action_hash": "4e3c8b730c161918c19b3cf3fbce742b1d59a126c39de28d0bcd3848f4acc233",
    "signature": "SCLgV6OjBcJz8JOnHV5yzrVgdeQRcjCbEliJb6fBV5YRCxOZQ8dRv9TzJTS+gqzWiw8egZHoccajV+qWd2/b1g=="
  },
  "asset": {
    "asset_id": "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e",
    "contract": {
      "entity": {
        "domain": "example-issuer.com"
      },
      "name": "Example Bond 2030",
      "precision": 2,
      "ticker": "EXB30",
      "version": 2,
      "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e"
    },
    "issuance_txin": null,
    "issuance_prevout": null,
    "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "initial_issuer_pubkey_source": "contract",
    "current_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "issuer_pubkey_history": [
      {
        "pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
        "valid_from_audit_id": 2,
        "valid_until_audit_id": null
      }
    ],
    "mutable": {
      "trading_venues": [],
      "category_tags": [],
      "custom": {}
    },
    "admin": {
      "asset_type": null,
      "featured": false,
      "malicious": false,
      "delisted": false,
      "admin_notes": "Review resolved",
      "last_admin_action": {
        "action": "force_relist_asset",
        "field": null,
        "admin_id": "ddb0f797-d0f5-458f-a59b-50cae555adb1",
        "timestamp": "2026-11-03T22:47:34.852169Z"
      }
    },
    "icon": null,
    "status": "deregistered",
    "created_at": "2026-11-03T22:47:34.287665Z",
    "updated_at": "2026-11-03T22:47:34.993993Z"
  }
}

Icon proposals

Propose an icon

POST /v2/assets/:asset_id/icon-proposals SIGNED · operation propose_icon

Submits a PNG for admin review. The body has a signed action that commits to the image's SHA-256, plus the unsigned Base64 icon. The proposal is part of the issuer action chain.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Signature requiredheader · Base64Signature of the canonical JSON of action only.
action requiredbody · objectIssuer envelope with operation: "propose_icon" and icon_hash (SHA-256 hex of the PNG bytes).
icon requiredbody · Base64PNG, exactly 500×500, with an alpha channel, smaller than 75,000 bytes decoded.
🚧

Use a microsecond timestamp (…09.599000Z). This endpoint verifies the signature over the action re-serialized with microseconds, so a millisecond timestamp (…09.599Z, JavaScript's default) fails with 401.

📘

One pending proposal per asset. After an admin approves it, icon.href points at the new image and it appears in /icons.json.

Example request

# The signature covers only the canonical JSON of "action" (see Signing requests)
BODY='{"action":{"signing_context":"liquid-asset-registry-action-v1","asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","operation":"propose_icon","icon_hash":"4575a54494982204ffa6991b391259e63cf800d954cafbf5eea80984cb73b225","prev_action_hash":"18b1889c3415a9ff78560054a2e5d1ff6d6c2470e8a6933d9d62e7f511e18d9d","nonce":"a105c502-68b2-4793-a405-f63e19a9bcba","timestamp":"2026-11-03T22:47:34.575000Z"},"icon":"iVBORw0KGgoAAAANSUhEUgAAAfQAAAH0CAYA……"}'
SIG='0pOAWrVtnqgqbjtmLRs9joxt13cRtoOwmKV+ol4A8UNiGyBnbgRc+bbD680bUgMiLD3zgvU+kK/Ag6kUD//W2w=='

curl -X POST "https://assets.blockstream.info/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/icon-proposals" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Signature: $SIG" \
  --data-raw "$BODY"
// Node.js 18+ (ES module: save as .mjs or set "type": "module")
import { createECDH, createHash, createPrivateKey, randomUUID, sign } from "node:crypto";
import { readFile } from "node:fs/promises";

const BASE_URL = "https://assets.blockstream.info";
const ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d";
const PRIVATE_KEY = process.env.ISSUER_PRIVATE_KEY; // 32-byte hex: the asset's current issuer key

const icon = await readFile("icon.png"); // 500x500 RGBA PNG, < 75,000 bytes
const iconHash = createHash("sha256").update(icon).digest("hex");

// The next action must reference the hash of the latest accepted one
const latest = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions/latest`);
if (!latest.ok) throw new Error(`${latest.status} ${await latest.text()}`);
const { action_hash: prevActionHash } = await latest.json();

const action = {
  signing_context: "liquid-asset-registry-action-v1",
  asset_id: ASSET_ID,
  operation: "propose_icon",
  icon_hash: iconHash,
  prev_action_hash: prevActionHash,
  nonce: randomUUID(),
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
// Sign the canonical action; the PNG travels next to it as Base64
const body = JSON.stringify({ action, icon: icon.toString("base64") });

const res = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/icon-proposals`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Signature": signMessage(canonicalJson(action), PRIVATE_KEY) },
  body: body,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(JSON.stringify(await res.json(), null, 2)); // full output: plain console.log cuts long strings and deep objects

// --- signing helpers (see Signing requests) ---
// Canonical JSON: keys sorted recursively, no whitespace. Use integers or strings for numbers
// (non-integer numbers are formatted differently by JavaScript and the registry).
function canonicalJson(v) {
  if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
  if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
  return JSON.stringify(v);
}

// Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message.
function signMessage(message, privateKeyHex) {
  const varint = (n) => (n < 0xfd ? Buffer.from([n]) : Buffer.from([0xfd, n & 0xff, n >> 8]));
  const msg = Buffer.from(message, "utf8");
  const magic = Buffer.from("Bitcoin Signed Message:\n");
  const firstHash = createHash("sha256").update(Buffer.concat([varint(magic.length), magic, varint(msg.length), msg])).digest();
  const d = Buffer.from(privateKeyHex, "hex");
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(d);
  const pub = ecdh.getPublicKey();
  const key = createPrivateKey({ format: "jwk", key: { kty: "EC", crv: "secp256k1", d: d.toString("base64url"),
    x: pub.subarray(1, 33).toString("base64url"), y: pub.subarray(33).toString("base64url") } });
  const sig = sign("sha256", firstHash, { key, dsaEncoding: "ieee-p1363" }); // ECDSA-SHA256 adds the second hash
  const n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
  const s = BigInt("0x" + sig.subarray(32).toString("hex"));
  if (s > n / 2n) sig.write((n - s).toString(16).padStart(64, "0"), 32, "hex"); // low-S form
  return sig.toString("base64");
}

// Compressed public key (66 hex) for a private key.
function publicKeyOf(privateKeyHex) {
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(Buffer.from(privateKeyHex, "hex"));
  return ecdh.getPublicKey("hex", "compressed");
}
# pip install requests cryptography==50.0.2
import base64
import hashlib
import json
import os
import uuid
from datetime import datetime, timezone

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature

BASE_URL = "https://assets.blockstream.info"
ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
PRIVATE_KEY = os.environ["ISSUER_PRIVATE_KEY"]  # 32-byte hex: the asset's current issuer key

def canonical_json(value):
    """Canonical JSON: keys sorted recursively, no whitespace. Prefer integers or strings for numbers."""
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def sign_message(message, private_key_hex):
    """Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message."""
    msg = message.encode("utf-8")
    varint = lambda n: bytes([n]) if n < 0xFD else b"\xfd" + n.to_bytes(2, "little")
    payload = varint(24) + b"Bitcoin Signed Message:\n" + varint(len(msg)) + msg
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    first_hash = hashlib.sha256(payload).digest()  # ECDSA-SHA256 adds the second hash
    r, s = decode_dss_signature(key.sign(first_hash, ec.ECDSA(hashes.SHA256())))
    n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
    s = min(s, n - s)  # low-S form
    return base64.b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big")).decode()

def public_key_of(private_key_hex):
    """Compressed public key (66 hex) for a private key."""
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint).hex()

with open("icon.png", "rb") as f:  # 500x500 RGBA PNG, < 75,000 bytes
    icon = f.read()
icon_hash = hashlib.sha256(icon).hexdigest()

# The next action must reference the hash of the latest accepted one
latest = requests.get(f"{BASE_URL}/v2/assets/{ASSET_ID}/actions/latest", timeout=30)
if not latest.ok:
    raise RuntimeError(f"{latest.status_code} {latest.text}")
prev_action_hash = latest.json()["action_hash"]

action = {
    "signing_context": "liquid-asset-registry-action-v1",
    "asset_id": ASSET_ID,
    "operation": "propose_icon",
    "icon_hash": icon_hash,
    "prev_action_hash": prev_action_hash,
    "nonce": str(uuid.uuid4()),
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
# Sign the canonical action; the PNG travels next to it as Base64
body = json.dumps({"action": action, "icon": base64.b64encode(icon).decode()})
res = requests.post(
    f"{BASE_URL}/v2/assets/{ASSET_ID}/icon-proposals",
    data=body,
    headers={"Content-Type": "application/json", "Asset-Registry-Signature": sign_message(canonical_json(action), PRIVATE_KEY)},
    timeout=30,
)
if not res.ok:
    raise RuntimeError(f"{res.status_code} {res.text}")
print(res.json())
// go mod init example && go get github.com/decred/dcrd/dcrec/secp256k1/[email protected]
package main

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/decred/dcrd/dcrec/secp256k1/v4"
	"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)

func main() {
	baseURL := "https://assets.blockstream.info"
	assetID := "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
	endpoint := baseURL + "/v2/assets/" + assetID + "/icon-proposals"
	privateKey := os.Getenv("ISSUER_PRIVATE_KEY") // 32-byte hex: the asset's current issuer key

	icon, err := os.ReadFile("icon.png") // 500x500 RGBA PNG, < 75,000 bytes
	if err != nil {
		panic(err)
	}
	sum := sha256.Sum256(icon)
	iconHash := hex.EncodeToString(sum[:])

	// The next action must reference the hash of the latest accepted one
	var latest struct {
		ActionHash string `json:"action_hash"`
	}
	getJSON(baseURL+"/v2/assets/"+assetID+"/actions/latest", &latest)

	action := map[string]any{}
	action["signing_context"] = "liquid-asset-registry-action-v1"
	action["asset_id"] = assetID
	action["operation"] = "propose_icon"
	action["icon_hash"] = iconHash
	action["prev_action_hash"] = latest.ActionHash
	action["nonce"] = newNonce()
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	// Sign the canonical action; the PNG travels next to it as Base64
	sig := signMessage(canonicalJSON(action), privateKey)
	payload := canonicalJSON(map[string]any{"action": action, "icon": base64.StdEncoding.EncodeToString(icon)})
	req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Asset-Registry-Signature", sig)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, body))
	}
	fmt.Println(string(body))
}

// canonicalJSON: keys sorted (Go sorts map keys), no whitespace, no HTML escaping.
func canonicalJSON(v any) []byte {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		panic(err)
	}
	return bytes.TrimRight(buf.Bytes(), "\n")
}

// signMessage returns a Bitcoin Signed Message signature (Base64, 64-byte R||S).
func signMessage(message []byte, privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	var payload bytes.Buffer
	writeVarint(&payload, 24)
	payload.WriteString("Bitcoin Signed Message:\n")
	writeVarint(&payload, len(message))
	payload.Write(message)
	first := sha256.Sum256(payload.Bytes())
	digest := sha256.Sum256(first[:])
	sig := ecdsa.SignCompact(secp256k1.PrivKeyFromBytes(key), digest[:], true) // recovery byte + R||S, low-S
	return base64.StdEncoding.EncodeToString(sig[1:])
}

func writeVarint(b *bytes.Buffer, n int) {
	if n < 0xfd {
		b.WriteByte(byte(n))
		return
	}
	b.WriteByte(0xfd)
	b.WriteByte(byte(n))
	b.WriteByte(byte(n >> 8))
}

// publicKeyOf returns the compressed public key (66 hex) for a private key.
func publicKeyOf(privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	return hex.EncodeToString(secp256k1.PrivKeyFromBytes(key).PubKey().SerializeCompressed())
}

func getJSON(endpoint string, v any) {
	res, err := http.Get(endpoint)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	out, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, out))
	}
	if err := json.Unmarshal(out, v); err != nil {
		panic(err)
	}
}

func newNonce() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}
Response: 200 (local instance · application/json)
{
  "status": "applied",
  "proposal": {
    "proposal_id": "352faf04-ab7c-47d5-98db-405e7431ce57",
    "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
    "icon_hash": "4575a54494982204ffa6991b391259e63cf800d954cafbf5eea80984cb73b225",
    "status": "pending",
    "proposed_at": "2026-11-03T22:47:34.580380Z",
    "decided_at": null,
    "obsoleted_at": null
  },
  "audit_entry": {
    "audit_id": 9,
    "server_received_at": "2026-11-03T22:47:34.580380Z",
    "actor": "issuer",
    "verified_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "admin_id": null,
    "action": {
      "nonce": "a105c502-68b2-4793-a405-f63e19a9bcba",
      "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
      "icon_hash": "4575a54494982204ffa6991b391259e63cf800d954cafbf5eea80984cb73b225",
      "operation": "propose_icon",
      "timestamp": "2026-11-03T22:47:34.575000Z",
      "signing_context": "liquid-asset-registry-action-v1",
      "prev_action_hash": "18b1889c3415a9ff78560054a2e5d1ff6d6c2470e8a6933d9d62e7f511e18d9d"
    },
    "action_hash": "99036ac1bb6a51f59f16bfac64165fe81ce376ee199b066d7fc757c9c370019f",
    "signature": "0pOAWrVtnqgqbjtmLRs9joxt13cRtoOwmKV+ol4A8UNiGyBnbgRc+bbD680bUgMiLD3zgvU+kK/Ag6kUD//W2w=="
  }
}
Error: millisecond timestamp (known issue): 401 (local instance · application/json)
{
  "error": "invalid_signature",
  "message": "issuer action signature verification failed"
}
Error: a proposal is already pending: 409 (local instance · application/json)
{
  "error": "icon_pending_conflict",
  "message": "asset already has a pending icon proposal",
  "details": {
    "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
  }
}

List my icon proposals

POST /v2/assets/:asset_id/icon-proposals/search SIGNED · operation list_icon_proposals

Proposals made by the signing key for this asset, with review status. A signed query: nothing is written.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Signature requiredheader · Base64Signature of the exact request body with the asset's current issuer key. How to sign.
signing_context requiredbody · string"liquid-asset-registry-issuer-query-v1"
asset_id requiredbody · hex (64)Must match the URL.
actor_pubkey requiredbody · pubkeyThe key that signs this query.
operation requiredbody · string"list_icon_proposals"
timestamp requiredbody · RFC 3339Within ±5 min.
statusbody · enumpending, approved, rejected.
pagebody · integerDefault 1.
page_sizebody · integer1–50. Default 20.
orderbody · asc | descDefault desc.
📘

Each item has status (pending, approved, rejected), decided_at, and obsoleted_at. A proposal with obsoleted_at set is no longer live (e.g. the asset was deregistered), even if status still says pending.

🚧

The rejection reason isn't in this response. Find it in the asset's audit log: the reject_icon entry's action.reason.

🚧

Only proposals signed by the querying key are returned. After a key rotation, proposals made with the old key aren't visible to the new key.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp). See Signing requests.
BODY='{"actor_pubkey":"03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e","asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","operation":"list_icon_proposals","order":"desc","page":1,"page_size":20,"signing_context":"liquid-asset-registry-issuer-query-v1","timestamp":"2026-11-03T22:47:34.625000Z"}'
SIG='MecO0Rfo47eNR/mWTLs+wH+oD0etqiGY0njpH8PlyTMBu0Ph4/7V7x2f9zjY8zEgiMxFdetf/PvgPRKIf9f6zg=='

curl -X POST "https://assets.blockstream.info/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/icon-proposals/search" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Signature: $SIG" \
  --data-raw "$BODY"
// Node.js 18+ (ES module: save as .mjs or set "type": "module")
import { createECDH, createHash, createPrivateKey, sign } from "node:crypto";

const BASE_URL = "https://assets.blockstream.info";
const ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d";
const PRIVATE_KEY = process.env.ISSUER_PRIVATE_KEY; // 32-byte hex: the asset's current issuer key

const query = {
  actor_pubkey: publicKeyOf(PRIVATE_KEY),
  asset_id: ASSET_ID,
  operation: "list_icon_proposals",
  order: "desc",
  page: 1,
  page_size: 20,
  signing_context: "liquid-asset-registry-issuer-query-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(query); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/icon-proposals/search`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Signature": signMessage(body, PRIVATE_KEY) },
  body: body,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(JSON.stringify(await res.json(), null, 2)); // full output: plain console.log cuts long strings and deep objects

// --- signing helpers (see Signing requests) ---
// Canonical JSON: keys sorted recursively, no whitespace. Use integers or strings for numbers
// (non-integer numbers are formatted differently by JavaScript and the registry).
function canonicalJson(v) {
  if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
  if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
  return JSON.stringify(v);
}

// Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message.
function signMessage(message, privateKeyHex) {
  const varint = (n) => (n < 0xfd ? Buffer.from([n]) : Buffer.from([0xfd, n & 0xff, n >> 8]));
  const msg = Buffer.from(message, "utf8");
  const magic = Buffer.from("Bitcoin Signed Message:\n");
  const firstHash = createHash("sha256").update(Buffer.concat([varint(magic.length), magic, varint(msg.length), msg])).digest();
  const d = Buffer.from(privateKeyHex, "hex");
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(d);
  const pub = ecdh.getPublicKey();
  const key = createPrivateKey({ format: "jwk", key: { kty: "EC", crv: "secp256k1", d: d.toString("base64url"),
    x: pub.subarray(1, 33).toString("base64url"), y: pub.subarray(33).toString("base64url") } });
  const sig = sign("sha256", firstHash, { key, dsaEncoding: "ieee-p1363" }); // ECDSA-SHA256 adds the second hash
  const n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
  const s = BigInt("0x" + sig.subarray(32).toString("hex"));
  if (s > n / 2n) sig.write((n - s).toString(16).padStart(64, "0"), 32, "hex"); // low-S form
  return sig.toString("base64");
}

// Compressed public key (66 hex) for a private key.
function publicKeyOf(privateKeyHex) {
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(Buffer.from(privateKeyHex, "hex"));
  return ecdh.getPublicKey("hex", "compressed");
}
# pip install requests cryptography==50.0.2
import base64
import hashlib
import json
import os
from datetime import datetime, timezone

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature

BASE_URL = "https://assets.blockstream.info"
ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
PRIVATE_KEY = os.environ["ISSUER_PRIVATE_KEY"]  # 32-byte hex: the asset's current issuer key

def canonical_json(value):
    """Canonical JSON: keys sorted recursively, no whitespace. Prefer integers or strings for numbers."""
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def sign_message(message, private_key_hex):
    """Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message."""
    msg = message.encode("utf-8")
    varint = lambda n: bytes([n]) if n < 0xFD else b"\xfd" + n.to_bytes(2, "little")
    payload = varint(24) + b"Bitcoin Signed Message:\n" + varint(len(msg)) + msg
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    first_hash = hashlib.sha256(payload).digest()  # ECDSA-SHA256 adds the second hash
    r, s = decode_dss_signature(key.sign(first_hash, ec.ECDSA(hashes.SHA256())))
    n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
    s = min(s, n - s)  # low-S form
    return base64.b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big")).decode()

def public_key_of(private_key_hex):
    """Compressed public key (66 hex) for a private key."""
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint).hex()

query = {
    "actor_pubkey": public_key_of(PRIVATE_KEY),
    "asset_id": ASSET_ID,
    "operation": "list_icon_proposals",
    "order": "desc",
    "page": 1,
    "page_size": 20,
    "signing_context": "liquid-asset-registry-issuer-query-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(query)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/assets/{ASSET_ID}/icon-proposals/search",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Signature": sign_message(body, PRIVATE_KEY)},
    timeout=30,
)
if not res.ok:
    raise RuntimeError(f"{res.status_code} {res.text}")
print(res.json())
// go mod init example && go get github.com/decred/dcrd/dcrec/secp256k1/[email protected]
package main

import (
	"bytes"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/decred/dcrd/dcrec/secp256k1/v4"
	"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)

func main() {
	baseURL := "https://assets.blockstream.info"
	assetID := "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
	endpoint := baseURL + "/v2/assets/" + assetID + "/icon-proposals/search"
	privateKey := os.Getenv("ISSUER_PRIVATE_KEY") // 32-byte hex: the asset's current issuer key

	query := map[string]any{}
	query["actor_pubkey"] = publicKeyOf(privateKey)
	query["asset_id"] = assetID
	query["operation"] = "list_icon_proposals"
	query["order"] = "desc"
	query["page"] = 1
	query["page_size"] = 20
	query["signing_context"] = "liquid-asset-registry-issuer-query-v1"
	query["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	payload := canonicalJSON(query) // send exactly the signed bytes
	sig := signMessage(payload, privateKey)
	req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Asset-Registry-Signature", sig)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, body))
	}
	fmt.Println(string(body))
}

// canonicalJSON: keys sorted (Go sorts map keys), no whitespace, no HTML escaping.
func canonicalJSON(v any) []byte {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		panic(err)
	}
	return bytes.TrimRight(buf.Bytes(), "\n")
}

// signMessage returns a Bitcoin Signed Message signature (Base64, 64-byte R||S).
func signMessage(message []byte, privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	var payload bytes.Buffer
	writeVarint(&payload, 24)
	payload.WriteString("Bitcoin Signed Message:\n")
	writeVarint(&payload, len(message))
	payload.Write(message)
	first := sha256.Sum256(payload.Bytes())
	digest := sha256.Sum256(first[:])
	sig := ecdsa.SignCompact(secp256k1.PrivKeyFromBytes(key), digest[:], true) // recovery byte + R||S, low-S
	return base64.StdEncoding.EncodeToString(sig[1:])
}

func writeVarint(b *bytes.Buffer, n int) {
	if n < 0xfd {
		b.WriteByte(byte(n))
		return
	}
	b.WriteByte(0xfd)
	b.WriteByte(byte(n))
	b.WriteByte(byte(n >> 8))
}

// publicKeyOf returns the compressed public key (66 hex) for a private key.
func publicKeyOf(privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	return hex.EncodeToString(secp256k1.PrivKeyFromBytes(key).PubKey().SerializeCompressed())
}
Response: 200 (local instance · application/json)
{
  "items": [
    {
      "proposal_id": "352faf04-ab7c-47d5-98db-405e7431ce57",
      "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
      "icon_hash": "4575a54494982204ffa6991b391259e63cf800d954cafbf5eea80984cb73b225",
      "status": "pending",
      "proposed_at": "2026-11-03T22:47:34.580380Z",
      "decided_at": null,
      "obsoleted_at": null,
      "icon": "iVBORw0KGgoAAAANSUhEUgAAAfQAAAH0CAYA…"
    }
  ],
  "page": 1,
  "page_size": 20,
  "total_count": 1,
  "total_pages": 1
}

The Liquid Network is a production Bitcoin sidechain built with Elements, supporting confidential transactions, asset issuance and programmable smart contracts.

© 2026 Liquid Network
All rights reserved.

Feedback and Requests

We'd be happy to hear your suggestions on how to improve this site.

Simplicity Forum

Discuss SimplicityHL, tooling and applications on Liquid at community.simplicity-lang.org.

Liquid Dev Community

Community Telegram group where most of the Liquid developers hang out. Go to t.me/liquid_devel to join.