@sume-com/sdk
v0.2.0
Published
TypeScript client for the Sume HTTP API, with run polling and webhook verification helpers.
Readme
@sume-com/sdk
TypeScript client for the Sume HTTP API. Every operation in the public OpenAPI schema, plus the helpers every partner otherwise writes by hand: subscribeFormatRun, uploadFile, waitForRun, and verifyWebhook.
npm install @sume-com/sdkRequires a runtime with fetch and WebCrypto — Node 18+, Bun, Deno, Cloudflare Workers, or a browser. The package has no runtime dependencies.
Published on npm as @sume-com/sdk, MIT licensed.
Full docs: https://docs.sume.com/sdk. This README covers the same surface plus how the package is generated, gated, and published.
Usage
import { createSumeClient, listFormats } from "@sume-com/sdk";
const client = createSumeClient({ apiKey: process.env.SUME_API_KEY! });
const { data } = await listFormats({ client });Auth defaults to x-api-key only (scheme-aware); bearer Authorization is not set.
Server-side only. A Sume API key spends your credits. Never ship one to a browser or a mobile bundle — see Embed a Format in your product.
Wait for a run
Format, Action, and Agent Completion runs are asynchronous. waitForRun polls status_url to a terminal status and then resolves with the full receipt.
import {
createSumeClient,
createFormatRunByVanityPath,
waitForRun,
} from "@sume-com/sdk";
const client = createSumeClient({ apiKey: process.env.SUME_API_KEY! });
const { data: created } = await createFormatRunByVanityPath({
client,
path: { handle: "acme", slug: "product-promo" },
body: { input: { product_url: "https://shop.example.com/p/8823" } },
});
const run = await waitForRun(created!.data.id, {
client,
family: "format",
timeout: 15 * 60_000,
pollInterval: 2_000,
signal: AbortSignal.timeout(20 * 60_000),
onStatus: (status) => console.log(status),
});
if (run.status === "completed") console.log(run.primary_output_url);| Option | Default | Notes |
| -------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| family | — | Required: "action" \| "format" \| "agent". A run id does not say which surface it belongs to, so it cannot be inferred. |
| client | module default | The client from createSumeClient(). |
| timeout | 10 min | Exceeding it throws SumeRunTimeoutError. The deadline is checked before sleeping, so a short timeout does not first wait out a poll interval. |
| pollInterval | 2 s | Gap between status reads. |
| signal | — | Aborts the wait and the in-flight request. Rejects with the signal's reason. |
| onStatus | — | Called on every status read, including the terminal one. Receives (status, snapshot); the snapshot adds next_action, started_at, finished_at, cancelable. |
It resolves for any terminal status, not just completed — a failed run is a result, so read run.status and run.error exactly as a webhook handler would. An API error (401, 404, 5xx) throws SumeRunRequestError carrying status and body.
Prefer a webhook where you can: waitForRun is a poll loop, and run webhooks deliver the identical receipt without one.
One call: create and wait
subscribeFormatRun is createFormatRun* plus waitForRun in one call. It takes either a vanity path or an opaque format_id, forwards Idempotency-Key, and resolves with the terminal receipt.
import { createSumeClient, subscribeFormatRun } from "@sume-com/sdk";
const client = createSumeClient({
apiKey: process.env.SUME_API_KEY!,
baseUrl: "https://api.dev.sume.com", // prod default is https://api.sume.com
});
const run = await subscribeFormatRun({
client,
path: { handle: "acme", slug: "product-promo" },
idempotencyKey: `order-${orderId}`,
body: { input: { product_url: "https://shop.example.com/p/8823" } },
onStatus: (status, snapshot) => console.log(status, snapshot.next_action),
});
if (run.status === "completed") console.log(run.primary_output_url);- Default
timeoutis 20 minutes, notwaitForRun's 10 — video Formats routinely run 10–20. - Resolves for any terminal status, exactly like
waitForRun. It throws only when the create call itself is refused, since there is no run to wait for. - An idempotent replay of an already-finished run returns immediately without polling.
- There is no event stream to subscribe to.
events_urlisnullon every run today, soonStatusreflects status polling — real, but not a log feed.next_actionis the field worth branching on: it separatespoll_statusfromfix_input.
Live Commerce, end to end
The full partner path: upload optional imagery, invoke a team Format, read one durable video URL.
import {
createSumeClient,
subscribeFormatRun,
uploadFile,
LIVE_COMMERCE_OUTPUT_SCHEMA,
LIVE_COMMERCE_PRIMARY_OUTPUT_KEY,
type LiveCommerceFormatInput,
} from "@sume-com/sdk";
const client = createSumeClient({
apiKey: process.env.SUME_TEAM_API_KEY!, // team key — see below
baseUrl: "https://api.dev.sume.com",
});
// Optional: your own imagery, uploaded to a durable Sume URL.
const { url } = await uploadFile({
client,
file: await fs.openAsBlob("./hero.png"),
filename: "hero.png",
});
const input: LiveCommerceFormatInput = {
product_url: "https://shop.example.com/p/8823",
};
const run = await subscribeFormatRun({
client,
path: { handle: "mobidoo", slug: "live-commerce" },
idempotencyKey: `order-${orderId}`,
body: {
input: { ...input, hero_image_url: url },
generation_spend_cap_usd: 3,
output_schema: LIVE_COMMERCE_OUTPUT_SCHEMA,
primary_output_key: LIVE_COMMERCE_PRIMARY_OUTPUT_KEY,
// Or skip the wait entirely and take the webhook:
// communication: { webhook_url: "https://partner.example/hooks/sume" },
},
});
if (run.status === "completed") {
console.log(run.primary_output_url);
console.log(run.output.live_commerce_video.url); // same URL, named
}Naming the output, or not
A Format declares no output schema of its own, so this is a choice you make per call:
| | Request | Read the video at |
| ----------- | ------------------------------------------- | -------------------------------- |
| Default | omit output_schema | output.videos[0].url |
| Named | pass output_schema + primary_output_key | output.live_commerce_video.url |
primary_output_url is set either way. Passing primary_output_key: "live_commerce_video" without a schema does nothing: the key has to exist in the projected output to be selected, and the default projection only ever contains text, images, videos, audio, and files.
Prefer the named form. If a run produces no video, a named schema fails loudly with output_schema_unsatisfied, whereas the default projection quietly returns videos: [] and output_error: null.
Team Formats need a team key
A Format owned by a team workspace can only be invoked with an API key issued in that workspace. Being a member of the team is not enough — a personal key is refused:
{
"error": {
"code": "workspace_key_required",
"message": "This Format belongs to a team workspace...",
"details": { "workspace_id": "org_..." }
}
}This is a 403, and it is deliberate. A team Format's runs are billed to the team wallet, counted against the team's quota, and read back through the team's workspace. A personal key would put the spend on your personal wallet while the run belonged to the team — which, before this was enforced, produced runs that generated a real video and then reported output_schema_unsatisfied because the team-scoped harvest could not see media filed under a personal wallet.
So: create the key from the team's dashboard, not your own. A personal key remains correct for your own personal Formats.
A team handle you are not a member of returns 404, indistinguishable from one that does not exist.
Upload a file
uploadFile reserves a presigned URL, PUTs the bytes straight to storage, and completes the asset, resolving with a durable HTTPS URL you can pass as Format input.
const { url, asset_id } = await uploadFile({
client,
file: new Blob([bytes], { type: "image/png" }),
filename: "hero.png",
});contentTypeis required unlessfileis aBlobcarrying a type.- Failures throw
SumeUploadErrorwithstepset to"create" | "put" | "complete". - The bytes never pass through the Sume API, so upload speed is between you and storage.
- The PUT reuses the
fetchyou gavecreateSumeClient, so a proxied client stays proxied.
Verify a webhook
import { verifyWebhook } from "@sume-com/sdk";
export async function POST(request: Request) {
const body = await request.text(); // raw, before any JSON.parse
const ok = await verifyWebhook({
body,
headers: request.headers,
secret: process.env.SUME_WEBHOOK_SECRET!,
});
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(body);
await recordTerminalRun(event.request_id, event); // dedupe on request_id
return new Response(null, { status: 204 }); // fast 2xx, then work
}- Async, because it uses WebCrypto rather than
node:crypto— that is what keeps the package importable from Workers, Deno, and bundlers that refusenode:specifiers. - Pass the raw body. A parsed-and-reserialized object does not verify; key order and whitespace are part of what was signed. In Express, mount
express.raw({ type: "application/json" })on the webhook route only. headersaccepts aHeaders, aMap, or a plain object (Node'sreq.headers), and is case-insensitive.- Returns
falserather than throwing on a malformed delivery — a missing header is a failed verification, which is what you want to branch on. toleranceSecondsdefaults to 300. Set0to skip the replay-window check.
One verifier covers both surfaces: run webhooks (*.run.terminal) and generation-job webhooks (job.*) share the sume-v1 HMAC-SHA256 scheme over <timestamp>.<raw_body>. The payloads differ; the signature does not. Route on event.
Generate
Regenerate from the committed OpenAPI snapshot:
# Rebase on origin/main first — local checkouts can lag behind the 78-path schema.
pnpm --filter @sume-com/sdk generateInput is always apps/docs/public/api/openapi.json (relative from this package). There is no live-URL mode in the generator config.
Drift gate
src/generated is committed, so it can go stale when the snapshot moves. CI runs:
pnpm --filter @sume-com/sdk checkThis regenerates into a temporary directory and compares — it never touches your working tree — and fails if any file was added, removed, or changed. The fix is always pnpm --filter @sume-com/sdk generate, then commit the result.
Snapshot-sync PRs from sync-docs-openapi-snapshot.yml regenerate the client in the same commit, so they stay green without manual work.
src/create-client.ts, src/wait-for-run.ts, and src/verify-webhook.ts are hand-written and untouched by the generator.
Manual live-drift probe
npx @hey-api/[email protected] -i https://api.sume.com/reference/json -o /tmp/sdk-drift -c @hey-api/client-fetchPublishing
Workspace consumers resolve exports straight to src/index.ts; publishConfig swaps that for dist at publish time, so npm consumers get compiled JS and .d.ts without the workspace needing a build step.
tsc copies import specifiers into dist verbatim, so every relative import — hand-written and generated — carries an explicit .js extension. Without it node rejects dist/index.js with ERR_UNSUPPORTED_DIR_IMPORT, which is what burned 0.1.1. Two things hold the line: this package typechecks under moduleResolution: nodenext, which makes an extensionless relative import a compile error, and openapi-ts.config.ts sets output.module.extension so the generated tree matches.
.github/workflows/sdk-release.yml runs the drift gate, typecheck, tests, build, and a tarball import smoke test, then publishes:
git tag sdk-v0.2.0 && git push origin sdk-v0.2.0Bump version in package.json on main before tagging; the workflow refuses a tag that disagrees with the manifest.
workflow_dispatch defaults to dry_run: true — it packs and stops.
Trusted Publishing
Authentication is npm Trusted Publishing over OIDC. There is no NPM_TOKEN, and no long-lived credential to rotate: npm mints a short-lived token for the run after checking the request came from sumelabs/sume-com running sdk-release.yml.
Three constraints follow from that, all encoded in the workflow:
- Do not rename
sdk-release.yml. The trusted publisher is bound to the workflow filename; renaming it breaks publishing until the npm UI is updated to match. Same for moving the publish step into a reusable workflow. - The publish job must stay on a GitHub-hosted runner. OIDC is not supported from self-hosted runners, so this workflow pins
ubuntu-latestinstead of the monorepo'sCI_RUNS_ON. pnpmpacks,npmpublishes.publishConfigfield overrides are a pnpm feature that npm does not apply, and pnpm cannot do the OIDC exchange — sopnpm packbuilds the tarball andnpm publish <tarball>authenticates. npm must be ≥ 11.5.1, newer than what Node 22 bundles.
Provenance attestations are disabled (NPM_CONFIG_PROVENANCE=false): npm cannot attest builds from a private source repo.
