@picturehouse/sdk
v0.8.0
Published
Typed TypeScript / JavaScript client for the PictureHouse public API. Editorial press-photo storefronts, multi-tenant. Works in modern Node (>=18) and the browser.
Maintainers
Readme
@picturehouse/sdk
Typed TypeScript / JavaScript client for the PictureHouse public API. Works in modern Node (≥18) and in the browser without a bundler shim.
This SDK is the recommended way for tenant frontends (Orange Pictures, etc.) and B2B partners (newsroom CMSs, automation jobs) to talk to PictureHouse.
Install
npm install @picturehouse/sdkPre-built JavaScript + TypeScript declarations included; no bundler shim or postinstall step needed. Works in modern Node (≥ 18) and in any browser bundler (Vite, Webpack, Rspack, esbuild, Parcel, Bun, …).
The package is published from packages/sdk-typescript/ in the PictureHouse repo. Source is MIT-licensed.
Quickstart
Browser storefront (Orange Pictures style)
import { PictureHouse } from "@picturehouse/sdk";
const ph = new PictureHouse({
// Hit your tenant origin so cookies, CORS, and Stripe redirects all
// line up. The /api/v1 path lives on PictureHouse but is reachable
// through your tenant's own domain via the allowedOrigins list set
// by the tenant admin.
baseUrl: "https://orange-pictures.com/api/v1",
});
// 1. Sign the buyer in. The SDK stores the JWT internally so every
// later call carries it automatically.
await ph.auth.login({ email, password });
// 2. List events for the events page.
const events = await ph.events.list({ limit: 20 });
// 3. Browse images for an event.
const photos = await ph.images.list({ event: "ajax-feyenoord" });
// 4. Buyer picks favourites → save to a lightbox (PictureHouse's
// "cart" equivalent, but persistent).
const lb = await ph.lightbox.create({ name: "Friday selects" });
for (const img of selectedImages) {
await ph.lightbox.addItem(lb.id, { imageId: img.id });
}
// 5. Checkout the unpurchased contents of the lightbox.
const session = await ph.checkout.create({
lightboxId: lb.id,
successUrl: "https://orange-pictures.com/thanks",
cancelUrl: "https://orange-pictures.com/cart",
});
window.location.href = session.sessionUrl!;After the buyer returns from Stripe, your /thanks page can:
// Show "owned" images and offer downloads.
const purchases = await ph.purchases.list();
for (const p of purchases.data) {
const dl = await ph.download.issue({ imageId: p.imageId });
// Hand the buyer a 24h-valid link. Clicking it 302-redirects to
// a 5-minute Cloudflare R2 signed URL of the original.
console.log(dl.downloadUrl);
}Server-to-server (newsroom CMS, automation)
import { PictureHouse } from "@picturehouse/sdk";
const ph = new PictureHouse({
baseUrl: "https://orange-pictures.com/api/v1",
apiKey: process.env.ORANGE_PICTURES_API_KEY!, // ph_<64 hex>
});
const events = await ph.events.list({ status: "COMPLETED" });
for (const event of events.data) {
const photos = await ph.images.list({ event: event.slug, limit: 100 });
// …import into the newsroom CMS…
}Authentication
Two modes share the same Authorization: Bearer <token> header:
| Use case | Credential | Where it lives |
| ----------------------------- | ------------------------ | ------------------------------------ |
| B2B server-to-server | ph_<64 hex> API key | apiKey option (server env var) |
| Buyer browser storefront | Short-lived JWT | jwtToken option (set by login()) |
If you pass both, the SDK prefers the JWT (browser flow always wins).
To sign out:
await ph.auth.logout(); // clears the SDK-side JWTEnd-to-end storefront walkthrough
This section is meant to be copy-pasted into a fresh Next.js / React / Vue app to wire up the buyer flow on Orange Pictures (or any tenant frontend).
1. Create the SDK once at app boot
// src/lib/picturehouse.ts
import { PictureHouse } from "@picturehouse/sdk";
export const ph = new PictureHouse({
baseUrl: process.env.NEXT_PUBLIC_PICTUREHOUSE_API_BASE!,
});2. Login + register pages
import { ph } from "@/lib/picturehouse";
import { PictureHouseValidationError, PictureHouseAuthError } from "@picturehouse/sdk";
async function handleLogin(email: string, password: string) {
try {
const { user } = await ph.auth.login({ email, password });
redirect(`/${user.id}/lightboxes`);
} catch (err) {
if (err instanceof PictureHouseValidationError) showFieldErrors(err.details);
else if (err instanceof PictureHouseAuthError) toast("Invalid email or password");
else throw err;
}
}ph.auth.register() is a discriminated union on buyerType — pick the
variant for the entity signing up and TypeScript enforces the right fields:
// Press / media organisation (KvK for NL, VAT for non-NL)
await ph.auth.register({
buyerType: "BUSINESS",
email, password,
company: "De Telegraaf",
country: "NL",
kvkNumber: "12345678",
});
// Sports club / association (KvK for NL clubs; VAT optional)
await ph.auth.register({
buyerType: "CLUB",
email, password,
company: "AFC Ajax",
country: "NL",
kvkNumber: "34123456",
});
// Individual professional athlete (name only)
await ph.auth.register({
buyerType: "ATHLETE",
email, password,
name: "Lieke Martens",
});3. Storefront browse
const eventsPage = await ph.events.list({ status: "COMPLETED", limit: 24 });
const featured = await ph.events.list({ status: "LIVE", limit: 4 });
// Event detail
const players = await ph.events.persons("ajax-feyenoord");
// Filtered image grid
const photos = await ph.images.list({
event: "ajax-feyenoord",
person: "Hakim Ziyech",
limit: 50,
});4. Image detail
const image = await ph.images.get(imageId);
// image.purchased === true once the buyer owns this one — gate the
// "Download" button on this flag instead of re-fetching purchases.5. Lightbox (cart)
const lightboxes = await ph.lightbox.list();
const cart = await ph.lightbox.create({ name: "Today's selects" });
await ph.lightbox.addItem(cart.id, { imageId: image.id, note: "for cover" });
const detail = await ph.lightbox.get(cart.id);
// detail.items[i].purchased — true for already-bought rows, false for
// rows that still need a checkout. Filter on this for the cart subtotal.
await ph.lightbox.removeItem(cart.id, image.id);Claiming a shared lightbox into the cart
When a staff member shares a curated selection via a /share/<token> link,
the recipient signs in (or registers — no guest checkout) and claims it into
their cart, then checks out or downloads:
// The recipient lands on /share/<token>, then signs in. The storefront
// reads the token (e.g. from a `?claim=` query param) and claims it:
const claimed = await ph.lightbox.claim(shareToken);
// → { cartId, name, addedCount, skippedCount, unavailableCount, totalItems }
// Per-image / wallet / image-credit buyers: check out the new cart.
const session = await ph.checkout.create({ lightboxId: claimed.cartId });
// Subscription buyers: skip checkout — download each image directly
// (quota is debited at download time).
// for (const item of (await ph.lightbox.get(claimed.cartId)).items) {
// await ph.download.create({ imageId: item.imageId });
// }Pass ph.lightbox.claim(shareToken, { cartId }) to merge into an existing
cart instead of creating a new one. Only purchasable (published, same-tenant)
images are copied; unavailableCount reports any that were skipped.
6. Checkout
const res = await ph.checkout.create({
lightboxId: cart.id, // OR { imageId } / { imageIds: [...] }
// successUrl / cancelUrl are optional — omit them and the server
// defaults to your storefront's origin.
});
if (res.paidFromWallet) {
// Paid in full from the buyer's wallet — no Stripe redirect. The
// purchase is already COMPLETED; show a success state.
showPurchaseComplete(res);
} else {
// Paying by card. If the buyer has *some* wallet balance but not
// enough, explain why before redirecting:
if (res.walletBalanceCents && res.walletBalanceCents < res.totalAmount) {
toast(
`Your wallet (€${(res.walletBalanceCents / 100).toFixed(2)}) doesn't ` +
`cover the €${(res.totalAmount / 100).toFixed(2)} total — paying by card.`,
);
}
window.location.href = res.sessionUrl!;
}For card payments the buyer pays on Stripe Checkout (hosted by Stripe, not by you). Stripe webhooks fire back at PictureHouse, which marks the Purchase rows as COMPLETED. Your /thanks page can poll ph.purchases.list() or ph.lightbox.get(cart.id) for the freshly-flipped purchased: true flags.
6b. Wallet & credits (prepaid)
Buyers can prepay and spend with no card friction. Top-ups are charged on the tenant's Stripe account; the platform's cut is taken once at top-up, so spending is fee-free. A tenant pack is one of two kinds:
MONEY— tops up a currency balance (balanceCents), spent against each image's price. SetcreditCents > priceCentsto offer a bonus.IMAGES— grants image-count credits (imageCredits): 1 credit = 1 image regardless of its price.
// Show the buyer both balances + the tenant's top-up packs
const { balanceCents, currency, imageCredits } = await ph.wallet.getBalance();
const packs = await ph.wallet.listPacks();
// MONEY pack: { id, name, kind: "MONEY", priceCents, creditCents, bonusCents }
// IMAGES pack: { id, name, kind: "IMAGES", priceCents, creditImages }
// Buyer picks a pack → Stripe Checkout (works for either kind)
const topup = await ph.wallet.topUp({ packId: packs[0].id });
window.location.href = topup.sessionUrl!;
// On return, the balance (money or credits) is granted by webhook.At checkout, ph.checkout.create() settles automatically with no Stripe round-trip when the buyer can cover the whole order — image credits are tried first (paidFromImageCredits: true), then the money wallet (paidFromWallet: true), otherwise it falls back to card. Each source is pay-in-full-or-not-at-all.
7. Downloads
import { PictureHousePaymentRequiredError } from "@picturehouse/sdk";
try {
const dl = await ph.download.issue({ imageId });
// dl.downloadUrl is a 24h single-use link. Hand it to the browser:
window.open(dl.downloadUrl, "_blank");
} catch (err) {
if (err instanceof PictureHousePaymentRequiredError) showBuyDialog();
else throw err;
}8. Subscriptions
// Pricing page (public, no auth needed)
const plans = await ph.subscriptions.plans();
// Buyer picks one
const sub = await ph.subscriptions.subscribe({
planId: plans[0].id,
successUrl: window.location.origin + "/account",
});
window.location.href = sub.sessionUrl!;
// Account page
const me = await ph.subscriptions.me();
console.log(me.subscription?.downloadsRemaining); // null = unlimited
// Cancel-at-period-end
await ph.subscriptions.cancel();Errors
Every method either resolves with the parsed body or rejects with a typed subclass of PictureHouseError:
| Error class | HTTP | When |
| ------------------------------------ | ---- | ----------------------------------------------- |
| PictureHouseValidationError | 400 | Field errors in .details |
| PictureHouseAuthError | 401, 403 | Missing / expired token, inactive client |
| PictureHousePaymentRequiredError | 402 | Image not bought, no active subscription quota |
| PictureHouseNotFoundError | 404 | Image / event / lightbox doesn't exist |
| PictureHouseRateLimitError | 429 | .retryAfter carries the suggested wait |
| PictureHouseServerError | 5xx | API is down — retry with backoff |
| PictureHouseError | any | Base class, always safe to instanceof check |
Raw response body is preserved on error.body for debugging.
Advanced
Custom fetch (tracing, retries, mock in tests)
const ph = new PictureHouse({
baseUrl,
fetch: async (url, init) => {
const start = performance.now();
const res = await fetch(url, init);
metric("picturehouse.latency_ms", performance.now() - start);
return res;
},
});Per-request timeout
Defaults to 20s, override globally:
new PictureHouse({ baseUrl, timeoutMs: 5_000 });Calling endpoints not yet wrapped
const data = await ph.client.request<MyShape>({
method: "GET",
path: "/some-future-endpoint",
});Further reading
The README covers the basics. For real storefront work, see docs/:
01-features.md— what's supported, with API mapping02-tenant-setup.md— origin allowlist, base URL choice, env wiring03-storefront-recipes.md— full Next.js page recipes (events, image detail, lightbox drawer, paywall, account)04-purchase-completion.md— polling pattern + planned outbound webhooks05-not-yet-supported.md— honest gap list with target releases06-troubleshooting.md— CORS, 401, 402, 429, empty-list debugging
Versioning
v0.x = pre-locked API. We may break method signatures between releases. Pin to a specific commit until v1.
License
UNLICENSED — internal use only until further notice.
