@foundryco/sdk
v0.12.0
Published
Official Foundry SDK for Node.js — billing, subscriptions, support tickets, project pipelines, help center content and webhooks.
Downloads
1,011
Maintainers
Readme
Foundry SDK for Node.js
Status: beta — the surface is stable in practice but minor versions may still introduce breaking changes before
1.0.0.
Official SDK for Foundry — billing, subscriptions, support tickets, project pipelines and webhooks for SaaS products.
Install
npm install @foundryco/sdkRequires Node.js 20+.
Quickstart
import { Foundry } from "@foundryco/sdk";
const foundry = new Foundry({
apiKey: process.env.FOUNDRY_API_KEY!, // sk_live_... or sk_test_...
});
// Create a customer
const customer = await foundry.customers.create({
name: "Tienda Don Pepe",
email: "[email protected]",
external_owner_id: "user_abc123",
});
// Open a checkout session for a price
const session = await foundry.checkoutSessions.create({
customer: customer.id,
price: "price_01h...",
success_url: "https://recompry.com/billing/success",
cancel_url: "https://recompry.com/pricing",
});
// session.url is the hosted page where your user pays
console.log(session.url);Resources
| Resource | Methods |
|----------|---------|
| customers | list, retrieve, create, update |
| products | list, retrieve |
| prices | list, retrieve |
| checkoutSessions | create, retrieve |
| subscriptions | list, retrieve, create, cancel, discounts.list, discounts.apply, discounts.revoke |
| entitlements | check |
| billingPortalSessions | create |
| tickets | list, retrieve, create, updateStatus, timeline, createMessage, deleteMessage, assign |
| pipelines.items | list, retrieve, create, update, updateStage, links.*, comments.list, comments.create, tasks.list, tasks.retrieve, tasks.create, files.create |
| helpArticles | list, retrieve, create, update, delete |
| newsItems | list, retrieve, create, update, delete |
| webhookEndpoints | list, retrieve, create, update, delete |
Pipelines
Manage the console's Pipelines module from your product. Three boards, all driven by the same resource:
| Pipeline | Stages |
|----------|--------|
| proyectos | solicitud → cotizacion → propuesta → aprobado → en_desarrollo → revision → entregado → facturacion → finalizado → cancelado |
| onboarding | lead → demo → kickoff → setup → migration → training → go_live → support |
| roadmap | backlog → planeado → en_progreso → qa → canary → produccion → bloqueado → archivado |
import { Foundry, PIPELINE_STAGES } from "@foundryco/sdk";
const foundry = new Foundry({ apiKey: process.env.FOUNDRY_API_KEY! });
// A new subscription kicks off an onboarding
const item = await foundry.pipelines.items.create({
pipeline: "onboarding",
name: `Onboarding ${customer.name}`,
client_org: customer.name,
client_email: customer.email,
customer_id: customer.id,
source: "api",
});
// Link the objects driving this onboarding
await foundry.pipelines.items.links.add(item.id, { kind: "subscription", target_id: subscription.id });
await foundry.pipelines.items.links.add(item.id, { kind: "user", target_id: appUserId, label: "Owner" });
// Move it through the board as setup progresses
await foundry.pipelines.items.updateStage(item.id, { stage: "setup", note: "Cuenta aprovisionada" });
// Read one board column
const inTraining = await foundry.pipelines.items.list({ pipeline: "onboarding", stage: "training" });
// PIPELINE_STAGES.roadmap → ["backlog", "planeado", ..., "archivado"]stagemust belong to the item's pipeline (422otherwise); the pipeline is immutable after creation.- Links are unique per
(item, kind, target_id)— adding a duplicate returns409. - Webhooks: subscribe to
project.created/project.updatedto react to board movements.
Comments, tasks and files on an item
Each item carries the client-facing thread, work plan and deliverables:
// Client-visible thread, oldest → newest. Team-only notes are never returned.
const thread = await foundry.pipelines.items.comments.list(item.id);
const comment = await foundry.pipelines.items.comments.create(item.id, {
body: "Entregamos el primer avance.",
author: "Equipo Recompry",
});
// With an attachment — sent as multipart
await foundry.pipelines.items.comments.create(item.id, {
file: new File([buffer], "avance.pdf", { type: "application/pdf" }),
body: "Adjunto el informe.",
});
// Work plan
const tasks = await foundry.pipelines.items.tasks.list(item.id);
const task = await foundry.pipelines.items.tasks.create(item.id, {
title: "Migrar catálogo",
due_at: "2026-08-15",
});
const detail = await foundry.pipelines.items.tasks.retrieve(item.id, task.id);
detail.files; // deliverables attached to the task
detail.comments; // notes shared with the client
// Standalone deliverable
await foundry.pipelines.items.files.create(item.id, {
file: new File([buffer], "entregable.zip", { type: "application/zip" }),
});Comment length:
bodyis capped at 5000 characters after trimming, the same cap as ticket messages, exported asPIPELINE_COMMENT_BODY_MAX_LENGTH. The SDK throws aTypeErrorbefore the request when you exceed it.
Comments are always the client's. Unlike
tickets.createMessage, there is norole/on_behalf_of: everything posted through the API is recorded as client-authored and client-visible. Internal notes stay in the console.
Task visibility (0.12.0+): tasks are created as
visibility: "client"so they show up inlistandretrieve. Passingvisibility: "team"creates an internal task that the API can no longer read back —retrievewill answer404, by design.
Attachments: max 25 MB; extensions
pdf, doc(x), ppt(x), xls(x), csv, txt, zip, png, jpg, jpeg, webp, svg, gif, avif. Downloadurls are signed and expire. Comment and task lists come back complete — these endpoints are not paginated, so there is nohas_more/next_cursor.
Billing portal (Stripe-style)
Mint a hosted self-service URL for the customer:
const session = await foundry.billingPortalSessions.create({
customer: "cus_...",
return_url: "https://yourapp.com/account",
});
// session.url expires in 24h. Redirect the customer to it.The hosted page lets the customer see their subscription, download invoices (PDF/XML, including Colombia DIAN when applicable), and return to your app.
Support tickets
Tickets reported from the support widget (or created programmatically) are readable and manageable from the SDK:
// List tickets (a product-scoped key only sees its product's tickets)
const page = await foundry.tickets.list({ status: "abierto" });
// Fetch one ticket + its client-visible timeline (messages + events, oldest → newest)
const ticket = await foundry.tickets.retrieve("tick_...");
const timeline = await foundry.tickets.timeline("tick_...");
ticket.assignee; // { id, email } | null
// Change status
await foundry.tickets.updateStatus("tick_...", { status: "resuelto" });
// Assign / reassign to a workspace member by email (or unassign with null)
await foundry.tickets.assign("tick_...", { assignee: "[email protected]" });
// Reply as the customer-facing team, on behalf of a specific member.
// WITHOUT role/on_behalf_of the message is attributed to the CLIENT — that default
// exists because the main use case is relaying your own end user's message.
const reply = await foundry.tickets.createMessage("tick_...", {
body: "Ya está corregido, prueba de nuevo.",
role: "team",
on_behalf_of: "[email protected]",
});
// Published something by mistake? Retract it from the requester's thread.
// Not a hard delete: it becomes a team-only internal note with an audit event.
await foundry.tickets.deleteMessage("tick_...", reply.id);Scoping (0.7.0+): a product-scoped key is confined to its own product's tickets across every ticket endpoint.
timelinereturns client-visible entries only — internal team notes stay in the console.assignee/on_behalf_ofmust be the email of an active workspace member.
Message length:
bodyis capped at 5000 characters, measured after trimming and in UTF-16 code units (an emoji costs 2). The cap is exported asTICKET_MESSAGE_BODY_MAX_LENGTHso long notes can be chunked deterministically;createMessagethrows aTypeErrornaming the field and the actual length rather than letting the API answer with a generic 422. Split notes keep their order in the timeline.
Reading internal notes: there is no read endpoint for messages —
POST .../messagesis the only verb on that path (plusDELETEon a single message), soGETanswers 405 by design. A note sent withvisibility: "team"is not returned bytimelineeither, which is client-visible only. The201response (itsid+visibility) is the confirmation that it was stored; to read it back, use the console.
Help center & news (support widget)
The content behind the widget's Ayuda y novedades tabs — help articles and release announcements, with optional cover images — is fully manageable via the SDK:
import { readFile } from "node:fs/promises";
// Publish a help article with an uploaded cover (stored as a private asset,
// served through signed URLs). `product` is required with workspace keys.
const article = await foundry.helpArticles.create({
product: "prod_...",
title: "Cómo conectar tu cuenta",
body: "Ve a Ajustes → Integraciones y sigue los pasos.",
url: "https://docs.miapp.com/integraciones",
image: { data: await readFile("cover.png"), content_type: "image/png" },
});
article.image_url; // signed URL, ready to render
// Publish a news item (Novedades tab) with a date and a YouTube video card
const news = await foundry.newsItems.create({
product: "prod_...",
title: "Lanzamos reportes v2",
body: "Nuevos dashboards y export a Excel.",
date: "2026-07-17",
video_url: "https://www.youtube.com/watch?v=...",
image_url: "https://cdn.miapp.com/banners/reportes-v2.png", // externally hosted cover
});
// List, edit, remove
const { data } = await foundry.newsItems.list({ product: "prod_..." });
await foundry.helpArticles.update(article.id, { title: "Conectar tu cuenta (2 min)" });
await foundry.helpArticles.update(article.id, { image: null }); // remove the cover
await foundry.newsItems.delete(news.id);Items belong to a product's widget install: the product must have a widget key (Console → Settings → Widget de soporte) or
createresponds 409widget_not_configured. Each tab holds at most 30 items per product (409content_limit_reached). Covers: max 2 MB;content_typemust be one ofimage/png,image/jpeg,image/webp,image/gif,image/svg+xml,image/avif. Pass raw bytes/base64 viaimageor an httpsimage_url— never both. Webhooks:help_article.created|updated|deleted,news_item.created|updated|deleted.
API key scopes
Keys are issued per workspace and can optionally be scoped to a single product. The SDK transparently honors the scope — same code, the server filters responses:
- Workspace-wide key (default): full access to every product, plan, price, subscription and checkout session in the workspace.
- Product-scoped key:
products,prices,subscriptions,checkoutSessions,tickets,helpArticles,newsItemsand — since 0.12.0 —pipelines.itemsonly see resources for that product. Creating a checkout session against a price that belongs to a different product returns a validation error.
customers, invoices and webhookEndpoints remain workspace-wide regardless of
scope — contacts and billing records are shared, and webhooks are workspace-level by
design.
Changed in 0.12.0: pipeline items used to ignore the product scope, so a key pinned to product A listed every project in the workspace. They now behave like tickets: items of another product answer
404, and an item created with a product-scoped key belongs to that product instead of the workspace default. Sub-resources (comments, tasks, files, links) inherit the scope from their item.
A key never reaches another workspace: every resource, including children addressed by raw id, is resolved against the key's workspace first.
Generate keys (and pick a product scope) under Settings → API keys in the console.
Webhooks
Verify and parse webhook deliveries from Foundry:
import { Foundry, WebhookSignatureError } from "@foundryco/sdk";
const foundry = new Foundry({ apiKey: process.env.FOUNDRY_API_KEY! });
// In your HTTP handler, with the raw request body:
try {
const event = await foundry.webhooks.constructEvent(
rawBody,
request.headers["foundry-signature"],
process.env.FOUNDRY_WEBHOOK_SECRET!, // whsec_...
);
switch (event.type) {
case "subscription.created":
// event.data.object is the Subscription resource
break;
case "checkout_session.completed":
// event.data.object is the CheckoutSession resource
break;
// ...
}
} catch (err) {
if (err instanceof WebhookSignatureError) {
return new Response("invalid signature", { status: 401 });
}
throw err;
}Errors
All API errors inherit from FoundryAPIError and carry status, code, message, requestId:
import {
AuthenticationError,
NotFoundError,
RateLimitError,
ValidationError,
} from "@foundryco/sdk";
try {
await foundry.customers.retrieve("cus_doesnotexist");
} catch (err) {
if (err instanceof NotFoundError) {
// 404 — resource doesn't exist or doesn't belong to your workspace
} else if (err instanceof RateLimitError) {
// 429 — back off (err.retryAfter has the seconds, when present)
} else if (err instanceof ValidationError) {
// 422 — body/query failed validation; err.details has per-field info
}
}Configuration
Server-side only. The secret key (
sk_...) is a bearer credential — never expose it in browser or mobile bundles. Load it from an environment variable, not a string literal. The SDK requires anhttps://base URL (localhost excepted) and warns if instantiated in a browser.
const foundry = new Foundry({
apiKey: process.env.FOUNDRY_API_KEY!, // sk_live_... / sk_test_... — from env, never hardcoded
baseURL: "https://api.foundry.greenstudio.vc", // default
timeout: 30_000, // ms, default 30s
maxRetries: 3, // default — only 408/429/5xx
fetch: customFetch, // optional — defaults to globalThis.fetch
});Mutating calls (POST/PATCH/DELETE) get an auto-generated Idempotency-Key when you
don't supply one, so a retried request can't create a duplicate resource.
Per-request:
await foundry.customers.create(
{ name: "..." },
{
idempotencyKey: crypto.randomUUID(),
signal: abortController.signal,
},
);Modes
API keys carry their environment in the prefix:
sk_live_...→ operates against live customers and real chargessk_test_...→ operates against test data; charges go to Wompi sandbox
The same SDK code works for both — switch by setting a different apiKey.
License
MIT — see LICENSE.
