# Hedera Consensus Service

> 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.

- Skill: `hedera-dev/hedera-consensus-service` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add hedera-dev/hedera-consensus-service`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hedera-dev/hedera-consensus-service/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: hedera-dev (https://skillmd.com/u/hedera-dev)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/hedera-dev/hedera-consensus-service

---


# 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)
```js
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)
```js
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

```js
import { TopicCreateTransaction } from "@hiero-ledger/sdk";

const { topicId } = await (
    await new TopicCreateTransaction()
        .setTopicMemo("My event log")
        .setAdminKey(operatorKey)       // allows update/delete
        .setSubmitKey(operatorKey)      // restricts who can post
        .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):

```js
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)  // this key skips fees
        .execute(client)
).getReceipt(client);
```

When paying custom fees, submitters can set a maximum they're willing to pay:

```js
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

```js
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:

```js
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.

```js
import { TopicMessageQuery } from "@hiero-ledger/sdk";

const handle = new TopicMessageQuery()
    .setTopicId(topicId)
    .setStartTime(0)  // from the beginning
    .subscribe(
        client,
        (message, error) => console.error("Error:", error),
        (message) => {
            console.log(
                `[${message.consensusTimestamp}] #${message.sequenceNumber}: ` +
                Buffer.from(message.contents).toString("utf8")
            );
        },
    );

// Later, to stop receiving:
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

```js
new TopicMessageQuery()
    .setTopicId(topicId)
    .setStartTime(startTimestamp)        // receive from this time forward
    .setEndTime(endTimestamp)            // stop after this time
    .setLimit(100)                       // max messages to receive
    .setMaxAttempts(20)                  // retry attempts (default: 20)
    .setMaxBackoff(8000)                 // max retry delay ms (default: 8000)
    .setErrorHandler((msg, err) => {})   // error callback
    .setCompletionHandler(() => {})      // fires when limit/endTime reached
    .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.

```js
const largeMessage = "x".repeat(5000); // 5KB message

// Option 1: execute() returns first chunk's response
const response = await new TopicMessageSubmitTransaction()
    .setTopicId(topicId)
    .setMessage(largeMessage)
    .execute(client);

// Option 2: executeAll() returns all chunk responses
const responses = await new TopicMessageSubmitTransaction()
    .setTopicId(topicId)
    .setMessage(largeMessage)
    .setMaxChunks(30)       // default: 20 (max ~20KB at 1024/chunk)
    .setChunkSize(2048)     // override chunk size (default: 1024)
    .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

```js
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

```js
await new TopicUpdateTransaction()
    .setTopicId(topicId)
    .setCustomFees([newFee])
    .addFeeExemptKey(anotherKey)
    .execute(client);
```

## Deleting a Topic

```js
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

```js
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.

```js
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`.

```js
// Service A: process all messages from the beginning
new TopicMessageQuery()
    .setTopicId(topicId)
    .setStartTime(0)
    .subscribe(client, null, processMessage);

// Service B: only new messages from now
new TopicMessageQuery()
    .setTopicId(topicId)
    .subscribe(client, null, processMessage);
```

## Common Gotchas

1. **Mirror node sync delay**: After creating a topic, wait 3-5 seconds before subscribing. The mirror node needs time to index the new topic.

2. **Chunk reassembly is automatic**: When subscribing, you receive complete messages even if they were submitted as multiple chunks. The SDK handles reassembly.

3. **No `execute()` for subscriptions**: `TopicMessageQuery` uses `.subscribe()`, not `.execute()`. It returns a `SubscriptionHandle`, not a `TransactionResponse`.

4. **Messages are immutable**: Once submitted, messages cannot be edited or deleted. Design your message schema with this in mind.

5. **Sequence numbers start at 1**: The first message on a topic gets sequence number 1, not 0.

6. **Submit key means access control**: If you set a submit key, only holders of that key can post. Omit it for open topics.

7. **String vs Uint8Array**: `setMessage()` accepts both. Use `Buffer.from(message.contents).toString("utf8")` to decode on the subscriber side.

8. **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

