Upload to Slack

Your app owns posting. Build the complete envelope before the first post attempt:

import type { ConversionResult } from "slackmark";
import type { NodeConverter } from "slackmark-node";

interface PreparedSlackPost {
  readonly channel: string;
  readonly threadTs?: string;
  readonly blocks: ConversionResult["blocks"];
  readonly text: string;
  readonly receipts: ConversionResult["receipts"];
}

declare const converter: NodeConverter;
declare const markdown: string;
declare const channel: string;
declare const threadTs: string | undefined;
declare function postPrepared(
  prepared: PreparedSlackPost,
  options: { readonly timeoutMs: number },
): Promise<void>;

const result = await converter.convert(markdown, { timeoutMs: 30_000 });
const prepared: PreparedSlackPost = {
  channel,
  ...(threadTs === undefined ? {} : { threadTs }),
  blocks: result.blocks,
  text: result.text,
  receipts: result.receipts,
};
await postPrepared(prepared, { timeoutMs: 10_000 });

On post failure, return or throw this exact envelope and retry posting that.

Persist prepared -> posted only when your app needs crash recovery. That persistence still does not make Slack posting idempotent. slackmark provides no outbox, worker, database, or posting API.

You need files:write for the default external upload flow and chat:write for your own posting. Add files:read only when waitForProcessing: true.

FetchSlackUploader defaults:

OptionDefault
Per-stage request timeout10 seconds
Processing pollingoff
Poll interval / attempts / total window250 ms / 20 / 30 seconds
channelIdabsent (private completion)
Retry / idempotency / orphan cleanupnone

An upload moves through five stages: admission, upload-URL allocation, byte upload, completion, and an optional file-info fetch. A rejection at admission carries phase requested and retry safety safe, because the uploader was never invoked.

Receipts record phase, file ID, sharing, processing, status, Retry-After, provider code, and retry safety. may-duplicate requires reconciliation; never means reuse the completed result. A polling failure after completion returns processing: "unverified" and never triggers replacement upload.

Whether to re-post a message, whether to re-upload a file (retrySafety), and whether to re-run a conversion are three separate questions, each with its own signal.

Do not log receipts or source-bearing URLs broadly. Files complete without a channelId. Confirm in your own workspace that a privately completed file renders for every viewer you expect.