@creativekit/server
v1.0.42
Published
Server-side CreativeKit SDK with OAuth authentication
Maintainers
Readme
@creativekit/server
Server-side CreativeKit SDK with OAuth2 authentication
⚠️ Node.js only - Never expose to browsers
Quick Start
npm install @creativekit/serverimport { CreativeKitServer } from "@creativekit/server";
const ck = new CreativeKitServer({
apiBase: "https://api.adswag.nl",
clientId: process.env.CREATIVEKIT_CLIENT_ID!,
clientSecret: process.env.CREATIVEKIT_CLIENT_SECRET!,
});
// Create creative with presigned upload URL (AWS-style)
const creative = await ck.creatives.create("audio:podcast-aac");
// Return to frontend (client uploads directly to presigned URL)
// IMPORTANT: After upload, client must call commit to start processing
res.json({
creativeId: creative.id,
uploadUrl: creative.uploadSlots?.[0]?.url,
uploadHeaders: creative.uploadSlots?.[0]?.headers,
});
// Commit endpoint - triggers processing
await ck.creatives.commit(creative.id);API Reference
Creatives
// Create creative + get presigned URL (AWS-style)
const creative = await ck.creatives.create(profile, options);
// Options:
// multipart?: boolean - Use multipart upload for large files
// fileSize?: number - File size (required for multipart)
// metadata?: object - Custom metadata to attach
// autoFinalize?: boolean - Auto-finalize when processing succeeds (skip draft workflow)
// Commit after upload (starts processing)
await ck.creatives.commit(id);
// Get creative status and artifacts
const creative = await ck.creatives.get(id);
// Status values: "AWAITING_UPLOAD" | "QUEUED" | "PROCESSING" | "SUCCEEDED" | "FAILED" | "CANCELED"
// AWAITING_UPLOAD = created with directUpload, waiting for file upload + commit
// Progress is 0.0-1.0 (multiply by 100 for percentage)
// List creatives with filtering
const list = await ck.creatives.list({ status: "SUCCEEDED", limit: 20 });
// Options: status?, profile?, finalized?, limit?, pageToken?
// Finalize (mark as permanent, prevent auto-cleanup)
await ck.creatives.finalize(id);
// Generate preview URL for completed creative
const preview = await ck.creatives.preview(id);
// Returns: { url: string, expiresAt: number }
// Management
await ck.creatives.delete(id);
await ck.creatives.cancel(id);
await ck.creatives.retry(id);
// Edit a draft: reopen one slot → (client uploads) → replace to reprocess
const { uploadSlots } = await ck.creatives.reopenSlot(id, "frame_2");
await ck.creatives.replaceSlot({ id, slot: "frame_2" }); // file already re-uploaded
// …or do the whole thing server-side in one call (reopen + upload + reprocess):
await ck.creatives.replaceSlot({ id, slot: "frame_2", file: newImage });
// Edit a FINALIZED creative: clone into a fresh draft, change a subset of
// slots, then commit + finalize (the original is never modified).
const draft = await ck.creatives.clone(finalizedId); // copies all raw uploads
// draft.uploadSlots covers every slot; PUT new files only for the changed ones
await ck.creatives.commit(draft.id);
await ck.creatives.finalize(draft.id);
// Server-side upload (Node.js scripts/workers); supports slotCounts + slotTransforms
const result = await ck.creatives.upload({ profile, files });
// SSE proxy to frontend (recommended)
await ck.creatives.getEventsStream(id, res);Editing is draft-only.
reopenSlot/replaceSlotrequire the creative to beSUCCEEDEDorFAILEDand not finalized. Reprocessing always re-runs from the original raw uploads, so there is no generational quality loss. See End-to-End Flows.
Auth (Client Token Generation)
Generate short-lived tokens for client-side use without exposing your client secret:
// Generate a scoped access token
const tokenData = await ck.auth.generateToken("creative:create creative:read creative:commit");
// Return to frontend
res.json({
token: tokenData.accessToken,
expiresIn: tokenData.expiresIn, // seconds
});Profiles
// List available processing profiles
const profiles = await ck.profiles.list();
// Returns: Profile[] with id, type, description, etc.
// Pre-validate declared file metadata against a profile WITHOUT uploading.
// Advisory pre-flight check — the authoritative validation still runs on the
// real bytes after upload. Returns the same { errors, warnings } shapes the
// processing pipeline emits, namespaced per slot (e.g. "standard.aspectRatio").
const result = await ck.profiles.validateMetadata("native:image", {
slots: {
standard: {
filename: "banner.png",
mimeType: "image/png",
sizeBytes: 1_048_576,
width: 1200,
height: 628,
},
},
});
// Returns: { passed: boolean, errors: ValidationError[], warnings?: ValidationWarning[] }
// For dynamic-slot profiles (e.g. native:carousel), pass slotCounts so per-frame
// slots (frame_1..frame_N) and slot-group rules resolve:
await ck.profiles.validateMetadata("native:carousel", {
slotCounts: { frame: 4 },
slots: { frame_1: { width: 1080, height: 1080, sizeBytes: 500_000 } },
});Bundles
Bundles group related profiles sold together (e.g. the native ad pack: standard + large).
// List all bundles. Pass { expandProfiles: true } to inline each member's
// full profile spec (upload slots + validation rules) in one request.
const bundles = await ck.profiles.listBundles({ expandProfiles: true });
// Get a single bundle by id (throws ApiError with status 404 if missing)
const bundle = await ck.profiles.getBundle("native:standard", {
expandProfiles: true,
});Health
// Comprehensive health check (API + credentials)
const health = await ck.health.check();
// Returns: { isHealthy, apiReachable, credentialsValid, details }
// Basic connectivity test
const reachable = await ck.health.ping();
// Returns: boolean
// Validate OAuth credentials
const validation = await ck.health.validateCredentials();
// Returns: { valid: boolean, error?: string }Utils
// Check if client has valid credentials configured
const authenticated = ck.utils.isAuthenticated();
// Get current API configuration
const config = ck.utils.getConfig();
// Returns: { apiBase, hasCredentials }
// Get last known connection status
const status = ck.utils.getConnectionStatus();Debug
Troubleshooting methods for diagnosing authentication issues:
// Validate credentials with detailed error info
const result = await ck.debug.validateCredentials();
// Returns: { valid, error?, details? }
// Test API connection with diagnostics
const conn = await ck.debug.testConnection();
// Returns: { apiReachable, credentialsValid, responseTime?, error?, details? }
// Get current token information
const tokenInfo = await ck.debug.getTokenInfo();
// Returns: { hasCredentials, tokenCached, tokenExpiry?, error? }End-to-End Flows
Every flow below is copy-pasteable. They cover the full creative lifecycle: create → upload → commit → watch → preview → finalize, plus editing (reopen/replace), multi-slot/carousel, pre-upload validation, and cancel/retry/delete.
Method → endpoint reference
| SDK method | REST endpoint |
| -------------------------------------------- | ---------------------------------------------- |
| ck.creatives.create(profile, opts) | POST /v1/creatives |
| ck.creatives.commit(id, body?) | POST /v1/creatives/{id}/commit |
| ck.creatives.upload(params) | create + PUT + commit (server-side convenience) |
| ck.creatives.get(id) | GET /v1/creatives/{id} |
| ck.creatives.list(opts) | GET /v1/creatives |
| ck.creatives.cancel(id) / retry(id) | PATCH /v1/creatives/{id} |
| ck.creatives.delete(id) | DELETE /v1/creatives/{id} |
| ck.creatives.reopenSlot(id, slot) | POST /v1/creatives/{id}/slots/{slot}/reopen |
| ck.creatives.replaceSlot(params) | POST /v1/creatives/{id}/slots/{slot}/replace |
| ck.creatives.preview(id) | POST /v1/creatives/{id}/preview |
| ck.creatives.finalize(id) | POST /v1/creatives/{id}/finalize |
| ck.creatives.clone(id) | POST /v1/creatives/{id}/clone |
| ck.creatives.getEventsStream(id, res) | GET /v1/creatives/{id}/events (SSE) |
| ck.profiles.list(opts?) | GET /v1/profiles |
| ck.profiles.validateMetadata(id, body) | POST /v1/profiles/{id}/validate |
| ck.profiles.listBundles(opts?) | GET /v1/bundles |
| ck.profiles.getBundle(id, opts?) | GET /v1/bundles/{id} |
| ck.auth.generateToken(scopes) | POST /oauth/token |
1. Browser upload (recommended)
The browser uploads directly to storage via presigned URLs; your backend only
issues URLs and proxies lifecycle calls. Use this with @creativekit/client.
Backend (Express) — thin proxy:
// Create + return presigned upload slot(s)
app.post("/api/creatives", async (req, res) => {
const { profile, slotCounts } = req.body;
const creative = await ck.creatives.create(profile, { slotCounts });
res.json({ creativeId: creative.id, uploadSlots: creative.uploadSlots });
});
// Commit to start processing (optionally pass slotTransforms for crops)
app.post("/api/creatives/:id/commit", async (req, res) => {
await ck.creatives.commit(req.params.id, { slotTransforms: req.body?.slotTransforms });
res.status(204).send();
});
// Status + live events + finalize
app.get("/api/creatives/:id", async (req, res) => res.json(await ck.creatives.get(req.params.id)));
app.get("/api/creatives/:id/events", (req, res) => ck.creatives.getEventsStream(req.params.id, res, { corsOrigin: req.headers.origin }));
app.post("/api/creatives/:id/finalize", async (req, res) => { await ck.creatives.finalize(req.params.id); res.status(204).send(); });The browser side (client.upload(...), client.streamEvents(...)) is in the
@creativekit/client README.
2. Fully server-side upload (scripts, workers, queues)
ck.creatives.upload() does create → PUT each file → commit → wait, all in
Node. Pass slotCounts for dynamic profiles and slotTransforms for crops.
import { readFile } from "node:fs/promises";
// Single-file profile
const audio = await ck.creatives.upload({
profile: "audio:podcast-aac",
files: { audio: new File([await readFile("ep.mp3")], "ep.mp3", { type: "audio/mpeg" }) },
// `progress` (0-100) is the canonical progress field across CreativeKit
// SDKs (the client SDK's `percent` is a deprecated alias of it).
onProgress: (p) => console.log(`${p.stage} ${p.progress}%`),
});
// Native ad pack (multi-slot): "standard" + "large" are sold as one bundle.
// Each slot is a separate file; optionally crop each to its target.
const native = await ck.creatives.upload({
profile: "native:image",
files: { standard: standardFile, large: largeFile },
slotTransforms: {
// crop rect is in SOURCE pixels; target is the output resolution after crop+scale
standard: { aspectRatio: "1.91:1", cropX: 0, cropY: 0, cropW: 1200, cropH: 628, targetWidth: 1200, targetHeight: 628 },
},
});
if (native.status === "SUCCEEDED") console.log(native.artifacts);3. Carousel (dynamic slots)
Carousels declare a frame template slot. slotCounts: { frame: N } expands it
into frame_1 … frame_N. Works the same for server-side upload or presigned
browser upload (pass slotCounts to create).
const carousel = await ck.creatives.upload({
profile: "native:carousel",
slotCounts: { frame: 4 },
files: { frame_1: f1, frame_2: f2, frame_3: f3, frame_4: f4 },
});Changing one frame later re-runs the whole pipeline (all frames re-encode from their raw uploads). See flow 4.
4. Edit an existing creative (reopen → replace)
Swap a single slot/frame of a draft (non-finalized) creative without
recreating it. The creative must be SUCCEEDED or FAILED, and the other
slots' raw uploads must still be within the draft TTL.
Option A — fully server-side (one call):
const updated = await ck.creatives.replaceSlot({
id: creativeId,
slot: "frame_2",
file: newFrameFile, // reopen + upload + reprocess + wait
slotTransform: { aspectRatio: "1:1", cropX: 0, cropY: 0, cropW: 1080, cropH: 1080, targetWidth: 1080, targetHeight: 1080 }, // optional
});Option B — browser-driven (presigned):
// 1) Backend reopens the slot and returns fresh presigned URLs
app.post("/api/creatives/:id/slots/:slot/reopen", async (req, res) => {
const { uploadSlots } = await ck.creatives.reopenSlot(req.params.id, req.params.slot);
res.json({ uploadSlots });
});
// 2) Browser PUTs the new file to that URL (client.replaceSlot handles this)
// 3) Backend triggers reprocessing (no file arg → just reprocess)
app.post("/api/creatives/:id/slots/:slot/replace", async (req, res) => {
await ck.creatives.replaceSlot({
id: req.params.id,
slot: req.params.slot,
slotTransform: req.body?.slotTransform, // optional crop from the browser
});
res.status(204).send();
});What can be edited? Only slot contents of a draft (re-upload a file to a slot). Profile/type are fixed at create time. Once
finalized, a creative is immutable — to change it, clone it (flow 4b). PublicGET /c/{id}/{filename}only serves finalized creatives, so live assets never change underneath a viewer.
4b. "Edit" a finalized creative (clone → change subset → finalize)
Finalized creatives are immutable, so editing one means producing a new
creative. clone makes that cheap: it duplicates the source into a fresh draft
(new ID, AWAITING_UPLOAD) and server-side copies every raw upload, so the
unchanged slots need no re-upload and reprocess losslessly from the original
source. Change only the slots you want, then commit + finalize.
// 1) Clone the finalized creative → new draft seeded with all original frames
app.post("/api/creatives/:id/clone", async (req, res) => {
const draft = await ck.creatives.clone(req.params.id);
// draft.uploadSlots covers EVERY slot (e.g. frame_1..frame_8)
res.status(201).json({ id: draft.id, uploadSlots: draft.uploadSlots });
});
// 2) Browser PUTs new files ONLY for the changed slots (e.g. 4 of 8 frames)
// to their presigned URLs. The other slots keep the copied raw uploads.
// 3) Commit reprocesses all slots from raw (one clean pass), then finalize.
await ck.creatives.commit(draftId);
await ck.creatives.finalize(draftId);
// Result: a brand-new finalized creative (new ID/URL). The original is untouched;
// the platform should swap its reference to the new creative.To remove or reorder carousel slides (not just swap file contents), pass
slotPlan on clone:
// 5-frame source → 3-frame draft (drops frame_2 and frame_4)
const draft = await ck.creatives.clone(finalizedId, {
slotPlan: { frame: ["frame_1", "frame_3", "frame_5"] },
});
// draft.uploadSlots = frame_1..frame_3, seeded from source frame_1, frame_3, frame_5Clone relies on the source still having its raw uploads (finalized creatives retain them). If they are gone,
clonereturns 409 — create a fresh creative instead.
5. Pre-upload validation (fail fast, no upload)
Check declared metadata against a profile's rules before pushing bytes. Advisory only — the authoritative check still runs on the real bytes at commit.
const check = await ck.profiles.validateMetadata("native:image", {
slots: { standard: { mimeType: "image/png", sizeBytes: 1_048_576, width: 1200, height: 628 } },
});
if (!check.passed) {
// surface check.errors to the user and skip the upload
return res.status(422).json({ errors: check.errors, warnings: check.warnings });
}
// else proceed with create/upload6. Cancel, retry, delete, list
await ck.creatives.cancel(id); // stop in-flight processing
await ck.creatives.retry(id); // retry a FAILED creative
await ck.creatives.delete(id); // remove a creative + its artifacts
// Paginate finalized creatives for a profile
let pageToken: string | undefined;
do {
const page = await ck.creatives.list({ profile: "native:image", finalized: true, limit: 50, pageToken });
page.items.forEach((c) => console.log(c.id, c.status));
pageToken = page.nextPageToken;
} while (pageToken);SSE Events Proxy
Proxy real-time processing events to your frontend with a single method call:
// NestJS
@Get(':id/events')
async streamEvents(@Param('id') id: string, @Res() res: Response) {
await ck.creatives.getEventsStream(id, res);
}
// Express
app.get('/creatives/:id/events', async (req, res) => {
await ck.creatives.getEventsStream(req.params.id, res);
});The SDK handles:
- ✅ SSE headers (Content-Type, Cache-Control, Connection)
- ✅ Terminal state detection (auto-closes on SUCCEEDED, FAILED, CANCELED)
- ✅ Automatic stream cleanup
- ✅ Client disconnect handling
- ✅ CORS headers (optional)
With CORS support and callbacks:
await ck.creatives.getEventsStream(id, res, {
corsOrigin: req.headers.origin,
onComplete: (status) => console.log(`Creative ${id}: ${status}`),
onError: (error) => console.error(`Stream error: ${error.message}`),
});Note: SSE events are minimal signals (
id,status,progress). Clients should callGET /creatives/:idfor full details on terminal states.
Completion Webhooks
Instead of polling or SSE, register a webhook at create time. The backend
POSTs to your callbackUrl when the creative reaches a terminal status
(SUCCEEDED / FAILED / CANCELED), signed with your callbackSecret.
const creative = await ck.creatives.create("video:web-1080p-h264", {
callbackUrl: "https://your-backend.example.com/webhooks/creativekit",
callbackSecret: process.env.CREATIVEKIT_WEBHOOK_SECRET!, // required with callbackUrl
});
callbackSecretis required whenevercallbackUrlis set — the API rejects unsigned webhook registrations (400).
Receiving webhooks — verify the X-CreativeKit-Signature header
(hex HMAC-SHA256 over the raw body) with the SDK helpers:
import express from "express";
import { parseWebhookEvent, WEBHOOK_SIGNATURE_HEADER } from "@creativekit/server";
// Use a raw body parser: the signature covers the exact bytes received.
app.post(
"/webhooks/creativekit",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = parseWebhookEvent(req.body, {
secret: process.env.CREATIVEKIT_WEBHOOK_SECRET!,
signature: req.header(WEBHOOK_SIGNATURE_HEADER),
});
} catch {
return res.sendStatus(401);
}
// Delivery is at-least-once — dedupe on event.eventId.
// The payload is minimal ({ eventId, id, status, error }); on SUCCEEDED,
// fetch the creative for artifacts and CDN URLs:
if (event.status === "SUCCEEDED") {
const creative = await ck.creatives.get(event.id);
// ...store creative.artifacts...
}
res.sendStatus(204);
}
);verifyWebhookSignature(secret, rawBody, signature) is also exported if you
only want the boolean check. Deliveries are retried with exponential backoff
(~10 attempts) on 5xx/network errors; 2xx and 4xx both count as delivered, so
reconcile terminal states with GET /v1/creatives/{id} as a fallback.
Finalize Workflow (Draft → Permanent)
Processed creatives start as "drafts" that can be previewed but will be automatically cleaned up after the draft TTL expires (default: 24h). To keep a creative permanently:
// After user reviews the preview and confirms
await ck.creatives.finalize(creativeId);
// Check finalization status
const creative = await ck.creatives.get(creativeId);
console.log(creative.finalized); // true
console.log(creative.finalizedAt); // timestamp in msWhy finalize?
- Cost control: Storage costs only for confirmed creatives
- User confirmation: Let users preview before committing
- Idempotent: Safe to call multiple times
Skip draft workflow with autoFinalize:
If you don't need user preview confirmation, auto-finalize on success:
const creative = await ck.creatives.create("video:web-1080p-h264", {
autoFinalize: true, // Automatically finalized when processing succeeds
});Express endpoint example:
app.post("/creatives/:id/finalize", async (req, res) => {
await ck.creatives.finalize(req.params.id);
res.status(204).send();
});Preview URLs
Generate signed preview URLs for completed creatives:
// Get a signed preview URL (expires after a period)
const { url, expiresAt } = await ck.creatives.preview(creativeId);
// Return to frontend for display
res.json({ previewUrl: url, expiresAt });Express endpoint example:
// Note: The SDK uses POST internally to generate the preview URL
app.post("/creatives/:id/preview", async (req, res) => {
const preview = await ck.creatives.preview(req.params.id);
res.json(preview);
});Handling Failed Creatives
When a creative fails, check both results.validation (for validation errors) and failure (for pipeline errors):
Validation Errors
Detailed validation errors are in results.validation.errors:
const creative = await ck.creatives.get(id);
if (creative.status === "FAILED" && creative.results?.validation) {
console.log("Validation failed:");
for (const error of creative.results.validation.errors) {
console.log(` ${error.field}: ${error.message}`);
console.log(` Expected: ${error.expected}, Got: ${error.actual}`);
console.log(` Code: ${error.code}`);
}
}Validation Error Fields:
| Field | Description |
| ----- | ----------- |
| field | Property that failed (e.g., aspectRatio, duration) |
| rule | Validation rule violated (e.g., allowedValues, range) |
| expected | What the profile requires |
| actual | What was found in the uploaded file |
| code | Machine-readable error code |
| message | Human-readable error message |
| profileHint | Reference to the profile constraint |
Pipeline Errors
For unrecoverable errors (transcode failures, etc.), check failure:
if (creative.status === "FAILED" && creative.failure) {
console.log(`Pipeline failed at step: ${creative.failure.step}`);
console.log(`Error: ${creative.failure.message}`);
console.log(`Retriable: ${creative.failure.retriable}`);
}Express Endpoint Example
app.get("/api/creatives/:id", async (req, res) => {
const creative = await ck.creatives.get(req.params.id);
// Return validation errors in a client-friendly format
if (creative.status === "FAILED") {
const validationErrors = creative.results?.validation?.errors || [];
const pipelineError = creative.failure;
return res.json({
...creative,
// Flatten for easier client consumption
errorSummary: validationErrors.length
? validationErrors.map((e) => e.message).join("; ")
: pipelineError?.message || "Unknown error",
});
}
res.json(creative);
});Full Documentation
See the main SDK documentation for:
- Complete API reference
- Architecture patterns
- Full examples (Express, Next.js, NestJS)
- Security best practices
- Troubleshooting
Security
- ✅ Use environment variables for credentials
- ✅ Never expose this SDK to browsers
- ✅ Keep
clientSecretsecure - ✅ Add auth middleware to your backend
License
MIT
