| name | hedera-consensus-service |
| description | How to create topics, submit messages, and subscribe to real-time message streams on Hedera using the Hiero JavaScript SDK (@hiero-ledger/sdk). Use this skill whenever the user wants to work with Hedera Consensus Service (HCS), including topic creation, message submission, pub/sub messaging, mirror node subscriptions, chunked large messages, topic fees, or any consensus-related operation in JavaScript or TypeScript. Also trigger when users mention @hashgraph/sdk topic operations, event logging on Hedera, decentralized messaging, audit trails, or ordered message streams. |
Hedera Consensus Service (HCS) — JavaScript SDK
HCS provides a decentralized, ordered message log with consensus timestamps. It works like a pub/sub system: you create topics, submit messages to them, and subscribe to receive messages in real time via mirror nodes. Messages are immutable, ordered, and timestamped by network consensus — useful for audit trails, event logs, supply chain tracking, and decentralized communication.
Setup
All imports come from @hiero-ledger/sdk. Two setup patterns:
Client + setOperator (direct)
import { Client, AccountId, PrivateKey } from "@hiero-ledger/sdk";
const client = Client.forName(process.env.HEDERA_NETWORK)
.setOperator(
AccountId.fromString(process.env.OPERATOR_ID),
PrivateKey.fromStringECDSA(process.env.OPERATOR_KEY),
);
Wallet + LocalProvider (signer-based)
import { Wallet, LocalProvider } from "@hiero-ledger/sdk";
const provider = new LocalProvider();
const wallet = new Wallet(process.env.OPERATOR_ID, process.env.OPERATOR_KEY, provider);
With the signer pattern, use freezeWithSigner(wallet), signWithSigner(wallet), executeWithSigner(wallet), and getReceiptWithSigner(wallet).
Creating a Topic
import { TopicCreateTransaction } from "@hiero-ledger/sdk";
const { topicId } = await (
await new TopicCreateTransaction()
.setTopicMemo("My event log")
.setAdminKey(operatorKey)
.setSubmitKey(operatorKey)
.execute(client)
).getReceipt(client);
console.log(`Topic created: ${topicId.toString()}`);
Key behaviors:
- Without an
adminKey, the topic cannot be updated or deleted (only expiration can be extended).
- Without a
submitKey, anyone can submit messages.
- Default auto-renew period is 90 days.
Topic with Custom Fees
Topics can charge per-message fees (Hbar or token-denominated):
import { TopicCreateTransaction, CustomFixedFee, Hbar } from "@hiero-ledger/sdk";
const fee = new CustomFixedFee()
.setAmount(new Hbar(1).toTinybars())
.setFeeCollectorAccountId(collectorId);
const { topicId } = await (
await new TopicCreateTransaction()
.setAdminKey(operatorKey)
.setSubmitKey(operatorKey)
.setFeeScheduleKey(operatorKey)
.setCustomFees([fee])
.addFeeExemptKey(trustedKey)
.execute(client)
).getReceipt(client);
When paying custom fees, submitters can set a maximum they're willing to pay:
import { CustomFeeLimit, CustomFixedFee, Hbar, HbarUnit } from "@hiero-ledger/sdk";
const limit = new CustomFeeLimit()
.setAccountId(payerId)
.setFees([
new CustomFixedFee().setAmount(Hbar.from(2, HbarUnit.Hbar).toTinybars())
]);
await new TopicMessageSubmitTransaction()
.setTopicId(topicId)
.setMessage("Hello")
.setCustomFeeLimits([limit])
.execute(client);
Submitting Messages
import { TopicMessageSubmitTransaction } from "@hiero-ledger/sdk";
const response = await new TopicMessageSubmitTransaction()
.setTopicId(topicId)
.setMessage("Hello, Hedera!")
.execute(client);
const receipt = await response.getReceipt(client);
console.log(`Sequence: ${receipt.topicSequenceNumber}`);
Messages can be string or Uint8Array. The receipt contains topicSequenceNumber (incremented per message) and topicRunningHash.
When a submit key exists
If the topic has a submit key, messages must be signed by it:
await (
await new TopicMessageSubmitTransaction()
.setTopicId(topicId)
.setMessage("Authorized message")
.freezeWith(client)
.sign(submitKey)
).execute(client);
Subscribing to Messages
The TopicMessageQuery creates a real-time subscription via the mirror node. Messages arrive as they reach consensus.
import { TopicMessageQuery } from "@hiero-ledger/sdk";
const handle = new TopicMessageQuery()
.setTopicId(topicId)
.setStartTime(0)
.subscribe(
client,
(message, error) => console.error("Error:", error),
(message) => {
console.log(
`[${message.consensusTimestamp}] #${message.sequenceNumber}: ` +
Buffer.from(message.contents).toString("utf8")
);
},
);
handle.unsubscribe();
Important: After creating a topic, wait a few seconds before subscribing — the mirror node needs time to sync the new topic.
Subscription Options
new TopicMessageQuery()
.setTopicId(topicId)
.setStartTime(startTimestamp)
.setEndTime(endTimestamp)
.setLimit(100)
.setMaxAttempts(20)
.setMaxBackoff(8000)
.setErrorHandler((msg, err) => {})
.setCompletionHandler(() => {})
.subscribe(client, errorHandler, messageHandler);
TopicMessage Properties
Each received TopicMessage has:
consensusTimestamp — when the message reached consensus
contents — Uint8Array message body (automatically reassembled from chunks)
sequenceNumber — position in the topic (starts at 1)
runningHash — SHA-384 running hash of the topic at this message
chunks — individual TopicMessageChunk[] if the message was chunked
initialTransactionId — original transaction ID (for chunked messages)
Chunked Messages
Messages larger than 1024 bytes are automatically split into chunks. Each chunk is a separate transaction on the network. The SDK handles splitting on submit and reassembly on subscribe.
const largeMessage = "x".repeat(5000);
const response = await new TopicMessageSubmitTransaction()
.setTopicId(topicId)
.setMessage(largeMessage)
.execute(client);
const responses = await new TopicMessageSubmitTransaction()
.setTopicId(topicId)
.setMessage(largeMessage)
.setMaxChunks(30)
.setChunkSize(2048)
.executeAll(client);
for (const resp of responses) {
const receipt = await resp.getReceipt(client);
console.log(`Chunk seq: ${receipt.topicSequenceNumber}`);
}
Limits:
- Default chunk size: 1024 bytes
- Default max chunks: 20 (so ~20KB max message by default)
- Configurable via
setChunkSize() and setMaxChunks()
- Subscribers automatically reassemble chunks into a single
TopicMessage
Updating a Topic
import { TopicUpdateTransaction } from "@hiero-ledger/sdk";
await new TopicUpdateTransaction()
.setTopicId(topicId)
.setTopicMemo("Updated memo")
.setSubmitKey(newSubmitKey)
.execute(client);
All update operations require the admin key. Key-specific updates:
- Changing the admin key requires both old and new admin keys to sign
- Setting a new auto-renew account requires that account to sign
- You can clear keys with
clearAdminKey(), clearSubmitKey(), etc.
Updating Topic Fees
await new TopicUpdateTransaction()
.setTopicId(topicId)
.setCustomFees([newFee])
.addFeeExemptKey(anotherKey)
.execute(client);
Deleting a Topic
import { TopicDeleteTransaction } from "@hiero-ledger/sdk";
await new TopicDeleteTransaction()
.setTopicId(topicId)
.execute(client);
Requires the admin key. After deletion, no operations on the topic will succeed.
Querying Topic Info
import { TopicInfoQuery } from "@hiero-ledger/sdk";
const info = await new TopicInfoQuery()
.setTopicId(topicId)
.execute(client);
console.log(`Memo: ${info.topicMemo}`);
console.log(`Sequence: ${info.sequenceNumber}`);
console.log(`Admin key: ${info.adminKey}`);
console.log(`Submit key: ${info.submitKey}`);
See references/api-reference.md for the full TopicInfo property list.
Key Roles
| Key | Purpose |
|---|
adminKey | Update/delete the topic; rotate other keys |
submitKey | Authorize message submission (if absent, open to all) |
feeScheduleKey | Update custom fee schedule |
Common Patterns
Event Log / Audit Trail
Create a topic per entity or event type. Submit structured JSON messages. Subscribe from a service to build a read model.
const event = JSON.stringify({
type: "ORDER_PLACED",
orderId: "12345",
timestamp: Date.now(),
data: { items: 3, total: 99.99 },
});
await new TopicMessageSubmitTransaction()
.setTopicId(ordersTopic)
.setMessage(event)
.execute(client);
Pub/Sub with Multiple Subscribers
Multiple services can subscribe to the same topic independently. Each maintains its own cursor via setStartTime.
new TopicMessageQuery()
.setTopicId(topicId)
.setStartTime(0)
.subscribe(client, null, processMessage);
new TopicMessageQuery()
.setTopicId(topicId)
.subscribe(client, null, processMessage);
Common Gotchas
-
Mirror node sync delay: After creating a topic, wait 3-5 seconds before subscribing. The mirror node needs time to index the new topic.
-
Chunk reassembly is automatic: When subscribing, you receive complete messages even if they were submitted as multiple chunks. The SDK handles reassembly.
-
No execute() for subscriptions: TopicMessageQuery uses .subscribe(), not .execute(). It returns a SubscriptionHandle, not a TransactionResponse.
-
Messages are immutable: Once submitted, messages cannot be edited or deleted. Design your message schema with this in mind.
-
Sequence numbers start at 1: The first message on a topic gets sequence number 1, not 0.
-
Submit key means access control: If you set a submit key, only holders of that key can post. Omit it for open topics.
-
String vs Uint8Array: setMessage() accepts both. Use Buffer.from(message.contents).toString("utf8") to decode on the subscriber side.
-
Cleanup: Always call handle.unsubscribe() when done, and client.close() when shutting down.
Reference Files
references/api-reference.md — Complete list of all HCS classes with their methods and properties