Import comments from your external system
Run an initial migration or an ongoing sync of comments into an order's internal or shared general thread.
Before you start: complete the authentication and TypeScript client setup in Commenting before running these examples.
How comment importing works
You choose which comments to import into Anduin and whether each conversation is visible only to your fund team or also to the investor and collaborators.
You can use the same workflow for:
- An initial import of existing comments.
- Ongoing imports of new comments created in your external system.
Anduin does not automatically update or delete an imported comment between imports. Your integration is responsible for choosing what to import, keeping a checkpoint, and preventing duplicate imports.
Notice: before continuing, review Core concepts for the fund and order model, thread visibility, anchors, status, attribution, mentions, and notifications.
General thread
This guide works with an order's general threads — the conversations about the subscription as a whole, which are the usual destination for comments arriving from an external system. An order has at most one general thread per visibility: one internal thread that only your fund team can see, and one shared thread that the investor and collaborators can also see. If the general thread you need does not exist yet, you do not have to wait for someone to start the conversation in the Anduin application — CreateOrGetCommentThread creates it for you.
An order can also carry threads anchored to a specific form question or to an AML/KYC document. Those threads are created in the Anduin application and cannot be created through the API. See Reply in form-question and AML/KYC threads for how to discover them and append a reply.
Import workflow
This is the recommended import workflow, both for an initial migration and for ongoing imports of new comments.
Step 1: prepare your comments
For each source comment, prepare the following information:
| Field | Purpose |
|---|---|
orderId | The Anduin order ID or your configured custom order ID. |
visibility | internal or shared, based on the intended audience. |
body | The comment text. |
onBehalfOf | Optional display name of the author in your external system. |
externalRef | Optional source-system comment ID used for reconciliation. |
Group the comments by (orderId, visibility). All comments in one group will use the same general thread.
For example:
| Source topic | Audience | Target thread |
|---|---|---|
| AML/KYC review note | Fund team only | internal general thread |
| Request for investor confirmation | Investor and fund team | shared general thread |
The source topic does not determine visibility. Always choose visibility based on who should be able to read the comment.
Step 2: map each order to its general thread
Why this mapping is needed
Your external system will usually associate each source comment with an Anduin orderId. However, ImportComments does not accept an orderId; it accepts the threadId of the conversation that should receive the comment.
The mapping connects those two identifiers:
(orderId, visibility) → general threadId
Visibility is part of the key because the same order can have two separate general conversations:
| Source record | Mapping lookup | Result |
|---|---|---|
| Private operations note for Order A | (Order A, internal) | Order A's internal general thread |
| Question for the investor on Order A | (Order A, shared) | Order A's shared general thread |
| Private compliance note for Order B | (Order B, internal) | Order B's internal general thread |
For example, imagine that your CRM export contains 10,000 comments across 500 subscriptions. Each row identifies an order and its intended audience, but it does not know Anduin's thread ID. Build the mapping once, then use it to group each comment under the correct thread before importing. This avoids repeatedly discovering the same thread and gives your integration a stable routing table for ongoing imports.
For one or two orders, you can skip the fund-wide lookup and call CreateOrGetCommentThread directly for each required visibility. The mapping workflow is most useful for a large initial import or a continuing integration across many orders.
Endpoint sequence
Use these endpoints in this order:
- ListCommentThreads —
GET /{fundId}/comment-threads- Returns the fund's existing non-empty threads.
- Keep only threads where
anchor.subTypeisgeneral. - Read
anchorResourceIdas theorderId, then index the returnedthreadIdby(orderId, visibility).
- CreateOrGetCommentThread —
POST /orders/{orderId}/comment-threads- Call this only for each required
(orderId, visibility)that is still missing. - It safely returns the existing general thread or creates it when needed.
- Call this only for each required
- ImportComments —
POST /{fundId}/comment-threads/{threadId}/comments- Look up the destination
threadIdin the completed mapping and import that group's comments.
- Look up the destination
Save the completed mapping for later import runs. Add mappings when you encounter new orders or a visibility that you have not used before.
List existing non-empty threads
const fundId = "YOUR_FUND_ID";
const { data: page, error } = await client.GET("/api/v1/fundsub/{fund-id}/comment-threads", {
params: { path: { "fund-id": fundId }, query: { limit: 200 } },
});Results are cursor-paginated. Follow nextCursor until hasMore is false.
If you only need threads for a small number of orders, use the optional anchorResourceId query parameter.
Notice: a general thread can exist before it contains a comment. It already has a threadId and anchor metadata, but it remains hidden in the Anduin application and is not returned by ListCommentThreads until its first comment is imported. CreateOrGetCommentThread safely returns that existing thread or creates it when no thread exists.
Create or get a missing general thread
const orderId = "YOUR_ORDER_ID";
const { data: thread, error } = await client.POST("/api/v1/fundsub/orders/{order-id}/comment-threads", {
params: { path: { "order-id": orderId } },
body: { visibility: "internal" },
});Example response:
{
"threadId": "txnv1wn29qyenqjq.offis00.isujqyy46",
"created": false
}created: truemeans Anduin created a new general thread.created: falsemeans the thread already existed.- In both cases, save the returned
threadId.
The successful response is always HTTP 200. Check created rather than the HTTP status to determine whether the thread was new.
Sample code: build the thread mapping
The following example implements the endpoint sequence above with the typed client from Commenting. Set required to the distinct (orderId, visibility) pairs present in your prepared source data.
const fundId = "YOUR_FUND_ID";
const required: Array<[string, "internal" | "shared"]> = [["YOUR_ORDER_ID", "internal"]];
const keyOf = (orderId: string, visibility: string) => `${orderId}|${visibility}`;
const requiredKeys = new Set(required.map(([orderId, visibility]) => keyOf(orderId, visibility)));
const threadMap = new Map<string, string>();
let cursor: string | undefined;
do {
const { data: page, error } = await client.GET("/api/v1/fundsub/{fund-id}/comment-threads", {
params: {
path: { "fund-id": fundId },
query: { limit: 200, ...(cursor ? { cursor } : {}) },
},
});
if (error) throw new Error(JSON.stringify(error));
for (const thread of page.threads) {
const key = keyOf(thread.anchorResourceId, thread.visibility);
if (thread.anchor.subType === "general" && requiredKeys.has(key)) {
threadMap.set(key, thread.threadId);
}
}
cursor = page.hasMore ? page.nextCursor : undefined;
} while (cursor);
for (const [orderId, visibility] of required) {
const key = keyOf(orderId, visibility);
if (threadMap.has(key)) continue;
const { data: thread, error } = await client.POST("/api/v1/fundsub/orders/{order-id}/comment-threads", {
params: { path: { "order-id": orderId } },
body: { visibility },
});
if (error) throw new Error(JSON.stringify(error));
threadMap.set(key, thread.threadId);
}Step 3: import comments
Import between 1 and 100 comments into one thread. Comments are appended in the order provided.
Notice: comment importing is atomic per thread. Either every comment in the request is appended to that thread, or none of them are. In a bulk job, different threads can still succeed or fail independently.
const fundId = "YOUR_FUND_ID";
const threadId = "YOUR_THREAD_ID";
const { data: result, error } = await client.POST(
"/api/v1/fundsub/{fund-id}/comment-threads/{thread-id}/comments",
{
params: { path: { "fund-id": fundId, "thread-id": threadId } },
body: {
notifyMode: "none",
comments: [
{
body: "AML/KYC review note from the external system: beneficial ownership documents were received and queued for review.",
onBehalfOf: "Mia Chen (External CRM)",
externalRef: "crm-comment-1042",
},
],
},
},
);Example response:
{
"threadId": "txnv1wn29qyenqjq.offis00.isujqyy46",
"commentIds": [
"txnv1wn29qyenqjq.offis00.isujqyy46.dcmd779w3qoqy"
]
}Save the returned commentIds in your integration records.
Step 4: verify the import
Verify the import with ListThreadComments:
const fundId = "YOUR_FUND_ID";
const threadId = "YOUR_THREAD_ID";
const { data: page, error } = await client.GET(
"/api/v1/fundsub/{fund-id}/comment-threads/{thread-id}/comments",
{
params: { path: { "fund-id": fundId, "thread-id": threadId }, query: { limit: 200 } },
},
);The imported comment includes importedMetadata:
{
"commentId": "txnv1wn29qyenqjq.offis00.isujqyy46.dcmd779w3qoqy",
"threadId": "txnv1wn29qyenqjq.offis00.isujqyy46",
"body": "AML/KYC review note from the external system: beneficial ownership documents were received and queued for review.",
"createdAt": "2026-07-20T05:30:38.396Z",
"importedMetadata": {
"onBehalfOf": "Mia Chen (External CRM)",
"externalRef": "crm-comment-1042"
}
}Comments are returned in creation order. The first comment imported into an empty thread becomes the opening comment automatically.
Step 5: continue importing safely
Comment import is not idempotent. A repeated request creates additional comments even when externalRef is unchanged.
For each import batch:
- Record the source comments you intend to send.
- Save the returned
commentIds. - If the request times out or its outcome is otherwise unknown, call ListThreadComments and compare
importedMetadata.externalRefbefore retrying. - Retry only comments that you have confirmed are missing.
- Export and reconcile comments regularly when co-hosting them in both systems. See Export comments for reporting, archival, and reconciliation.
When exporting the fund-wide comment feed, delivery is at-least-once. Deduplicate the feed using commentId.
Import comments across many threads
Use BulkImportComments when one job needs to import comments into several threads.
The request below is a real job against a demo fund. It imports two comments into the order's internal general thread, one comment into its shared general thread — and deliberately includes a third item whose threadId does not exist in the fund, to show how per-item failures are reported.
POST /api/v1/fundsub/{fundId}/async/bulk/comments
{
"notifyMode": "none",
"items": [
{
"threadId": "txnoo7ed9y1mzrwy.offis00.isuyd6r57",
"comments": [
{
"body": "Migration batch 2 — compliance checklist imported from the CRM.",
"onBehalfOf": "Mia Chen (External CRM)",
"externalRef": "crm-comment-2044"
},
{
"body": "Wire instructions were verified by operations before this import.",
"onBehalfOf": "Mia Chen (External CRM)",
"externalRef": "crm-comment-2045"
}
]
},
{
"threadId": "txnoo7ed9y1mzrwy.offis00.isumg3083",
"comments": [
{
"body": "Hi Virginia, your updated subscription details were received — nothing further is needed at this stage.",
"onBehalfOf": "Jordan Lee (External CRM)",
"externalRef": "crm-comment-2046"
}
]
},
{
"threadId": "txnoo7ed9y1mzrwy.offis00.isu0000000",
"comments": [
{
"body": "This item targets a thread that does not exist in the fund.",
"onBehalfOf": "Mia Chen (External CRM)",
"externalRef": "crm-comment-2047"
}
]
}
]
}The endpoint validates only the payload shape and responds immediately:
{ "requestId": "91e3236c-15c6-4112-99d0-eca4dd9af99f" }Poll the job status
Poll GetRequestStatus — GET /api/v1/fundsub/requests/{requestId}/status — until the job reaches a terminal status:
let status;
do {
await new Promise((resolve) => setTimeout(resolve, 2000));
const { data, error } = await client.GET("/api/v1/fundsub/requests/{request-id}/status", {
params: { path: { "request-id": requestId } },
});
if (error) throw new Error(JSON.stringify(error));
status = data;
} while (status.status === 0 || status.status === 3); // Pending or In Processing
// `result` is a typed union — narrow it by its responseType before reading the items
if (status.result?.responseType === "BulkImportCommentsResponse") {
for (const item of status.result.items ?? []) {
console.log(item.threadId, item.status, item.commentIds, item.error);
}
}The status field is numeric — at the job level and on every item:
| Value | Meaning |
|---|---|
0 | Pending — received, execution not yet started. |
1 | Completed. |
2 | Failed. |
3 | In processing. |
This is the actual final status response for the job above:
{
"status": 1,
"result": {
"responseType": "BulkImportCommentsResponse",
"items": [
{
"threadId": "txnoo7ed9y1mzrwy.offis00.isuyd6r57",
"status": 1,
"commentIds": [
"txnoo7ed9y1mzrwy.offis00.isuyd6r57.dcmn9n700d40d",
"txnoo7ed9y1mzrwy.offis00.isuyd6r57.dcmxnyoyjg77r"
],
"error": null
},
{
"threadId": "txnoo7ed9y1mzrwy.offis00.isumg3083",
"status": 1,
"commentIds": [
"txnoo7ed9y1mzrwy.offis00.isumg3083.dcmkxvx7ywpzk"
],
"error": null
},
{
"threadId": "txnoo7ed9y1mzrwy.offis00.isu0000000",
"status": 2,
"commentIds": [],
"error": "Invalid thread ID"
}
]
},
"error": null
}Notice: always check the items, not just the job. The job-level status is 1 (Completed) here even though the third item failed — job-level completion only means every item has finished. Check each item's own status: a failed item (2) carries an error. The commentIds on completed items are the authoritative record of what landed. There is no idempotency key: re-sending an item creates new comments, so read the thread back and re-import only the comments that are actually missing.
The two comments that landed in the internal general thread are the last two entries in the fund team's Internal tab for that order:
Every comment carries the onBehalfOf name and an Imported tag, and the thread footer confirms that internal comments are only visible to fund team members.
Please keep the following behavior in mind:
- A job can contain up to 100 items, each with 1–100 comments.
- Items are processed one at a time, in payload order; items that target the same thread are combined and share one outcome.
notifyModeapplies to the whole job.
Limits and pagination
- List endpoints return 50 records by default and support a maximum
limitof 200. - Follow
nextCursoruntilhasMoreisfalse. - Keep the same
anchorResourceIdfilter when following a cursor from a filtered request. - A synchronous import accepts 1–100 comments.
- A comment can contain up to 10,000 stored characters.
- A thread can contain up to 200 comments.
- A bulk job accepts up to 100 items, with 1–100 comments per item.
Treat cursors, thread IDs, and comment IDs as opaque values.
Troubleshooting
No threads are returned
The fund may have no comments yet, or its general threads may still be empty. Call CreateOrGetCommentThread for each required (orderId, visibility) mapping.
The API returns 403
Confirm that the API service account can post to the thread's visibility tier. Internal and shared threads can require different permissions.
The API returns 404 for a thread
Confirm that the threadId belongs to the fund in the request URL. Thread IDs cannot be used across funds.
An import may have timed out
Do not send the same request again immediately. Call ListThreadComments and compare externalRef values to determine which comments already arrived.
The API says comment import is not available
Contact your Anduin customer success representative to confirm that the Comment API is enabled for the fund.
Endpoints used
All paths below are relative to /api/v1/fundsub.
| Operation | Method and path | Purpose |
|---|---|---|
| ListCommentThreads | GET /{fundId}/comment-threads | List non-empty comment threads in a fund. |
| CreateOrGetCommentThread | POST /orders/{orderId}/comment-threads | Create an order's general thread or return its existing ID. |
| ImportComments | POST /{fundId}/comment-threads/{threadId}/comments | Import comments into one thread. |
| BulkImportComments | POST /{fundId}/async/bulk/comments | Import comments into several threads asynchronously. |
| ListThreadComments | GET /{fundId}/comment-threads/{threadId}/comments | Verify imported comments in one thread. |
| GetRequestStatus | GET /requests/{requestId}/status | Check the status of an asynchronous import. |
Updated 14 days ago