# Playbook Developers > Official developer documentation for Playbook, the coder-friendly way to host, search, and ship visual files. Covers the REST API for creative asset management, the playbook-sdk for embedding a media library or portfolio, and the hosted MCP server for connecting AI assistants (Claude, Cursor, ChatGPT) to a Playbook workspace. ## Documentation ### Getting started Start building with the Playbook API — the coder-friendly way to host, search, and ship your visual files. Authenticate, make your first call, and understand how organizations, boards, and assets fit together. - [Getting Started](/docs/getting_started.md): Start building with the Playbook API — the coder-friendly way to host, search, and ship your visual files. Authenticate, make your first call, and understand how organizations, boards, and assets fit together. ### Examples End-to-end examples for building real products on the Playbook API — portfolios, digital asset management, client galleries, brand portals, marketplaces, and AI media pipelines. - [Examples](/docs/examples.md): End-to-end examples for building real products on the Playbook API — portfolios, digital asset management, client galleries, brand portals, marketplaces, and AI media pipelines. - [AI media pipeline](/docs/examples/ai-pipeline.md): Build an AI media pipeline on the Playbook API — have agents search semantically, ingest generated variations in bulk, and auto-tag them, or skip code entirely with the hosted MCP server. - [Brand portal](/docs/examples/brand-portal.md): Build a brand portal on the Playbook API — publish approved logos, fonts, and templates as an always-current, embeddable gallery with token-scoped access for partners. - [Client gallery](/docs/examples/client-gallery.md): Build a client proofing gallery on the Playbook API — share a board of selects, collect feedback with comments, and deliver high-res downloads once the client approves. - [Digital asset management](/docs/examples/digital-asset-management.md): Build a digital asset management (DAM) system on the Playbook API — define custom fields, ingest and tag assets, search by metadata, and keep downstream systems in sync with webhooks. - [Creative marketplace](/docs/examples/marketplace.md): Build a creative marketplace on the Playbook API — accept seller uploads with signed URLs, power natural-language discovery with AI search, and deliver licensed originals on purchase. - [Portfolio](/docs/examples/portfolio.md): Build an auto-updating portfolio on the Playbook API — create a board, add work, then publish it as a public web gallery or render your own with the board assets endpoint. ### Guides - [AI-Powered Search](/docs/guides/ai_search.md): Search Playbook assets with natural language using AI Search — semantic, concept-aware retrieval that goes beyond keyword matching. - [Advanced Asset Management](/docs/guides/asset_management.md): Organize a portfolio or media library programmatically — the Playbook API patterns for moving, batching, tagging, and curating visual files at scale. - [Comments and Collaboration](/docs/guides/comments.md): Read, post, and manage threaded comments on Playbook assets and boards via the API for collaboration and review workflows. - [Create Boards in an Organization](/docs/guides/create_boards.md): Create new boards inside a Playbook organization via the API, including required permissions, parameters, and example requests. - [Custom Fields](/docs/guides/custom_fields.md): Add structured metadata to Playbook assets with custom fields — define schemas, predefined options, and update values via the API. - [Fetching Data from the Playbook API](/docs/guides/fetching_data.md): Retrieve organizations, boards, and assets from Playbook with simple GET calls — authentication, pagination, and example responses. - [Connect AI Assistants via MCP](/docs/guides/mcp.md): Connect Claude, Cursor, ChatGPT, and other MCP clients to Playbook so AI assistants can search, tag, upload, and manage your media via natural language. - [Embed a Gallery with the SDK](/docs/guides/sdk.md): Embed a Playbook media gallery in your product with the playbook-sdk — install, initialize with your org slug and token, and customize columns, search, and events. - [Search Assets in User's Organization](/docs/guides/search.md): Search Playbook assets across an organization by keyword, filters, and pagination using the v1 search endpoint. - [Sharing and Publishing](/docs/guides/sharing_and_publishing.md): Share Playbook assets and boards privately with shared links, or publish boards as public web pages — full API reference and examples. - [Uploading Assets to Playbook](/docs/guides/upload.md): Host your visual files in Playbook via API — upload from a public URL or via the two-step signed-upload flow, with endpoints, parameters, and complete examples. - [Webhooks](/docs/guides/webhooks.md): Receive real-time webhook notifications when Playbook assets are added, updated, or deleted — set up triggers, payloads, and verification. ### API reference Full REST API reference for Playbook, the media library and digital asset management (DAM) platform. Endpoints for assets, boards, search, sharing, custom fields, and webhooks, with an interactive request explorer and code samples in curl, Python, Node, and Ruby. - [Playbook API Reference](/docs/api.md): Full REST API reference for Playbook, the media library and digital asset management (DAM) platform. Endpoints for assets, boards, search, sharing, custom fields, and webhooks, with an interactive request explorer and code samples in curl, Python, Node, and Ruby. - [AI Search](/docs/api/ai-search-organization-assets.md): AI powered search through current organization's assets - [List Asset Children](/docs/api/asset-children.md): Get children for asset if it is a group - [List Asset Comments](/docs/api/asset-comments.md): Asset comments - [Change Asset Tags](/docs/api/change-asset-tags.md): Updates asset tags - [Check Organization Name](/docs/api/check-organization-name.md): Checks name availability for the new organization - [List Board Assets](/docs/api/collection-assets.md): Get collection's assets - [Copy Assets](/docs/api/copy-assets.md): Bulk copy assets to a different board (async operation) - [Create Asset Permalinks](/docs/api/create-asset-permalinks.md): Creates permalinks for assets - [Create Custom Field](/docs/api/create-custom-field.md): Creates new custom field - [Create Organization](/docs/api/create-organization.md): Creates new organization - [Create Webhook](/docs/api/create-trigger.md): Create a new webhook trigger. - [Delete Asset Permalinks](/docs/api/delete-asset-permalinks.md): Deletes permalinks for assets - [Delete Custom Field](/docs/api/delete-custom-field.md): Deletes custom field and all its options - [Delete Webhook](/docs/api/delete-trigger.md): Delete a webhook trigger. - [Delete User](/docs/api/delete-user.md): Delete user - [Download Asset](/docs/api/download-asset.md): Get download links for asset - [Download Shared Asset](/docs/api/download-shared-asset.md): Get download links for a shared asset - [Get Organization](/docs/api/get-organization.md): Get info about one organization - [Get Asset](/docs/api/get-organization-asset.md): Get info about one asset from organization - [Get Board](/docs/api/get-organization-collection.md): Get info about one collection from organization - [List Board Children](/docs/api/get-organization-collection-children.md): Get children of specific collection - [List Board Comments](/docs/api/get-organization-collection-comments.md): Get collection comments - [List Board Descendants](/docs/api/get-organization-collection-descendants.md): Get descendants of a specific board - [Get User](/docs/api/get-user.md): Get info about one user - [List User Favorites](/docs/api/get-user-favorites.md): Get info about user's favorites - [List User Uploads](/docs/api/get-user-uploads.md): Get info about user's uploads - [Group Assets](/docs/api/group-assets.md): Groups multiple assets together under a parent asset - [List Users](/docs/api/list-users.md): Get list of users - [Get Current User](/docs/api/me.md): Get current user's info - [Move Assets](/docs/api/move-assets.md): Bulk move assets to a different board - [List My Organizations](/docs/api/my-organizations.md): Get current user's organizations - [Batch Create Assets From URLs](/docs/api/organization-asset-batch-create-from-urls.md): Creates up to 100 assets at once by ingesting their bytes from public URLs. - [Batch Complete Upload](/docs/api/organization-asset-batch-upload-complete.md): **Final Step of Batch Asset Upload Process** - [Batch Prepare Upload](/docs/api/organization-asset-batch-upload-prepare.md): **Step 1 of Batch Asset Upload Process** - [Transform Assets](/docs/api/organization-asset-transform.md): Transform image assets using arbitraty params - [Complete Upload](/docs/api/organization-asset-upload-complete.md): **Final Step of Asset Upload Process** - [Prepare Upload](/docs/api/organization-asset-upload-prepare.md): **Step 1 of Asset Upload Process** - [List Assets](/docs/api/organization-assets.md): Get current organization's assets - [List Boards](/docs/api/organization-collections.md): Get current organization's boards - [Create Asset](/docs/api/organization-create-asset.md): Creates new asset - [Create Board](/docs/api/organization-create-collection.md): Creating new board in current organization - [List Custom Fields](/docs/api/organization-custom-fields.md): Get current organization's custom fields - [Delete Asset](/docs/api/organization-delete-asset.md): Deletes an asset from the organization - [Delete Board](/docs/api/organization-destroy-collection.md): Destroys collection in current organization - [Update Asset](/docs/api/organization-update-asset.md): Updates asset attributes (PUT also would work) - [Update Board](/docs/api/organization-update-collection.md): Update board in current organization. PUT request also will work - [Playbook API](/docs/api/playbook-api.md): The Playbook API lets you host, organize, search, and ship visual media: a - [Publish Board](/docs/api/publish-organization-collection.md): Publish a board to the web - [Search Assets](/docs/api/search-organization-assets.md): Search through current organization's assets - [Share Asset](/docs/api/share-organization-asset.md): Creates a Shared link with asset - [Share Board](/docs/api/share-organization-collection.md): Create a Shared link for a board - [Track Downloads](/docs/api/track-download-on-organization.md): Tracks a download - [Ungroup Assets](/docs/api/ungroup-assets.md): Removes assets from their groups, making them individual assets again - [Update Asset Status](/docs/api/update-asset-status.md): Updates asset status ### conventions How the Playbook API behaves across every endpoint. Base URL, Bearer auth and scopes, the data response envelope, status codes, rate limiting, pagination, and async ingest. One ground-truth reference for developers and AI agents. - [API Conventions](/docs/conventions.md): How the Playbook API behaves across every endpoint. Base URL, Bearer auth and scopes, the data response envelope, status codes, rate limiting, pagination, and async ingest. One ground-truth reference for developers and AI agents. ## Optional - [Playbook product overview (llms.txt)](https://www.playbook.com/llms.txt): Product-level LLM index for playbook.com: pricing, use cases, and platform overview. --- # Full Documentation Content ## [Introduction](/docs/api/playbook-api.md) [The Playbook API lets you host, organize, search, and ship visual media: a](/docs/api/playbook-api.md) --- # AI Search ``` GET /v1/:slug/ai_search ``` AI powered search through current organization's assets ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 404 returns empty results when no matches unauthenticated parent query not found --- # List Asset Children ``` GET /v1/:slug/assets/:token/children ``` Get children for asset if it is a group ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 406 successful unauthenticated not acceptable --- # List Asset Comments ``` GET /v1/:slug/assets/:token/comments ``` Asset comments ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # Change Asset Tags ``` POST /v1/:slug/assets/:token/change_tags ``` Updates asset tags ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # Check Organization Name ``` GET /v1/organizations/check ``` Checks name availability for the new organization ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 not available --- # List Board Assets ``` GET /v1/:slug/boards/:board_token/assets ``` Get collection's assets ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 successful unauthenticated --- # Copy Assets ``` POST /v1/:slug/assets/copy_assets ``` Bulk copy assets to a different board (async operation) ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 202 * 401 * 404 * 406 * 422 successfully enqueued copy unauthenticated destination board not found empty tokens tokens over limit --- # Create Asset Permalinks ``` POST /v1/:slug/assets/add_permalinks ``` Creates permalinks for assets ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 404 * 406 * 422 permalinks created successfully unauthenticated assets not found empty asset tokens permalink limit exceeded --- # Create Custom Field ``` POST /v1/:slug/custom_fields ``` Creates new custom field ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 422 successful unauthenticated when options are empty --- # Create Organization ``` POST /v1/organizations ``` Creates new organization ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # Create Webhook ``` POST /v1/trigger ``` Create a new webhook trigger. ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 400 successful invalid hook --- # Delete Asset Permalinks ``` POST /v1/:slug/assets/remove_permalinks ``` Deletes permalinks for assets ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 404 * 406 permalinks deleted successfully unauthenticated assets not found empty asset tokens --- # Delete Custom Field ``` DELETE /v1/:slug/custom_fields/:token ``` Deletes custom field and all its options ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 204 * 401 * 404 successful unauthenticated not found --- # Delete Webhook ``` DELETE /v1/trigger/:id ``` Delete a webhook trigger. ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 204 successful --- # Delete User ``` DELETE /v1/users/:token ``` Delete user ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 204 * 403 successful unauthorized --- # Download Asset ``` GET /v1/:slug/assets/:token/download ``` Get download links for asset ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # Download Shared Asset ``` GET /v1/shared/:shared_org_slug/assets/:token/download ``` Get download links for a shared asset ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 403 * 404 successful with published link unauthenticated unauthorized - asset not in shared collection asset not found --- # Get Organization ``` GET /v1/organizations/:slug ``` Get info about one organization ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 404 successful not found --- # Get Asset ``` GET /v1/:slug/assets/:token ``` Get info about one asset from organization ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 404 successful with first\_displayable\_child for group not found --- # Get Board ``` GET /v1/:slug/boards/:token ``` Get info about one collection from organization ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 404 successful not found --- # List Board Children ``` GET /v1/:slug/boards/:token/children ``` Get children of specific collection ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # List Board Comments ``` GET /v1/:slug/boards/:token/comments ``` Get collection comments ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # List Board Descendants ``` GET /v1/:slug/boards/:token/descendants ``` Get descendants of a specific board ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # Get User ``` GET /v1/users/:token ``` Get info about one user ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 404 successful not found --- # List User Favorites ``` GET /v1/users/:token/favorites ``` Get info about user's favorites ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # List User Uploads ``` GET /v1/users/:token/uploads ``` Get info about user's uploads ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # Group Assets ``` POST /v1/:slug/assets/group_assets ``` Groups multiple assets together under a parent asset ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 404 * 406 * 422 successfully grouped assets unauthenticated child assets not found empty child asset tokens cannot group into an asset that is already part of a group --- # List Users ``` GET /v1/:slug/users ``` Get list of users ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # Get Current User ``` GET /v1/me ``` Get current user's info ## Responses[​](#responses "Direct link to Responses") * 200 * 401 successful unauthenticated --- # Move Assets ``` POST /v1/:slug/assets/move_assets ``` Bulk move assets to a different board ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 403 * 404 * 406 * 422 successfully moved assets unauthenticated viewer cannot move assets they do not own destination board not found empty tokens tokens over limit --- # List My Organizations ``` GET /v1/organizations ``` Get current user's organizations ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 successful unauthenticated --- # Batch Create Assets From URLs ``` POST /v1/:slug/assets/batch_create_from_urls ``` Creates up to 100 assets at once by ingesting their bytes from public URLs. Each request is processed asynchronously: the API returns immediately with a `is_skeleton: true` placeholder per asset. A background worker (`DownloadFromURLWorker`) fetches the bytes and updates the record. Clients should poll `GET /v1/:slug/assets/:token` and treat the upload as finished when one of these holds: * `is_skeleton: false` and `media_type` is populated → upload succeeded * `is_skeleton: false` and `source_error` is non-null → upload failed * `is_skeleton: false` and `is_link: true` → kept as a bare link (when `as_link` was true) ## Important[​](#important "Direct link to Important") * Maximum 100 assets per batch. * All assets in a batch land in the same collection. Pass `collection_token` (or `collection_id`). * If `collection_token` is omitted or invalid, assets fall through to the default anonymous board. * Each asset may set `as_link: true` to skip the byte fetch and store a bare link. ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 406 batch URL ingest accepted unauthenticated asset missing uri --- # Batch Complete Upload ``` POST /v1/:slug/assets/batch_upload_complete ``` **Final Step of Batch Asset Upload Process** Completes a batch upload by creating Asset records for all uploaded files. Each asset is created individually — a failure mid-batch does not roll back previously created assets. ## Request[​](#request "Direct link to Request") ``` { "batch": { "batch_id": "uuid-from-batch_upload_prepare", "collection_token": "optional-collection-token", "assets": [ { "uuid": "client-uuid-1", "signed_gcs_id": "value-from-prepare", "title": "photo.jpg", "media_type": "image/jpeg", "width": 1920, "height": 1080 } ] } } ``` ## Important[​](#important "Direct link to Important") * `signed_gcs_id` must be the exact value from `batch_upload_prepare` * `batch_id` is optional but recommended — it releases the upload reservation * For Backblaze multipart uploads, include `multipart_upload_id` * Maximum 100 assets per batch ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 404 * 422 batch upload completed unauthenticated file not found in storage invalid signed\_gcs\_id --- # Batch Prepare Upload ``` POST /v1/:slug/assets/batch_upload_prepare ``` **Step 1 of Batch Asset Upload Process** Prepares signed upload URLs for up to 100 assets in a single request. Returns a `batch_id` that must be passed to `batch_upload_complete`. The response format for each asset matches the single-asset `upload_prepare` response, with a `uuid` field for client-side correlation. ## Request[​](#request "Direct link to Request") ``` { "batch": { "assets": [ { "title": "photo.jpg", "size": 1024000, "media_type": "image/jpeg", "uuid": "client-uuid-1" }, { "title": "doc.pdf", "size": 5242880, "media_type": "application/pdf", "uuid": "client-uuid-2" } ] } } ``` ## Upload Flow[​](#upload-flow "Direct link to Upload Flow") 1. Call this endpoint with an array of assets 2. Upload each file to its `upload_url` (same flow as single-asset upload\_prepare) 3. Call `batch_upload_complete` with the `batch_id` and all `signed_gcs_id` values ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 422 batch upload prepared unauthenticated batch size exceeded --- # Transform Assets ``` POST /v1/:slug/assets/transform ``` Transform image assets using arbitraty params ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # Complete Upload ``` POST /v1/:slug/assets/upload_complete ``` **Final Step of Asset Upload Process** This endpoint completes the asset upload process by creating the Asset record in Playbook's database. Call this after successfully uploading the file to cloud storage. ## What this endpoint does:[​](#what-this-endpoint-does "Direct link to What this endpoint does:") 1. Verifies the signed\_gcs\_id from upload\_prepare 2. For Backblaze multipart: completes the multipart upload using multipart\_upload\_id 3. Confirms the file exists in cloud storage 4. Creates the Asset record in Playbook's database 5. Triggers background processing (thumbnails, transcoding, etc.) 6. Returns the complete Asset object with URLs and metadata *** ## For GCS Organizations[​](#for-gcs-organizations "Direct link to For GCS Organizations") After completing the 4-step GCS upload flow: ``` POST /v1/{org_slug}/assets/upload_complete Headers: Authorization: Bearer {your_access_token} Content-Type: application/json Body: { "asset": { "signed_gcs_id": "{value_from_upload_prepare}", "title": "my-image.jpg", "description": "Optional description", "collection_token": "optional_collection_token" } } ``` *** ## For Backblaze Organizations[​](#for-backblaze-organizations "Direct link to For Backblaze Organizations") ### Single-Part Upload (no multipart\_upload\_id):[​](#single-part-upload-no-multipart_upload_id "Direct link to Single-Part Upload (no multipart_upload_id):") ``` POST /v1/{org_slug}/assets/upload_complete Body: { "asset": { "signed_gcs_id": "{value_from_upload_prepare}", "title": "my-image.jpg" } } ``` ### Multipart Upload (has multipart\_upload\_id):[​](#multipart-upload-has-multipart_upload_id "Direct link to Multipart Upload (has multipart_upload_id):") ``` POST /v1/{org_slug}/assets/upload_complete Body: { "asset": { "signed_gcs_id": "{value_from_upload_prepare}", "multipart_upload_id": "{value_from_upload_prepare}", "title": "my-large-video.mp4" } } ``` *** ## Required Parameters:[​](#required-parameters "Direct link to Required Parameters:") * **signed\_gcs\_id**: Always required - exact value from upload\_prepare response * **multipart\_upload\_id**: Required only for Backblaze multipart uploads (files >= 5MB) ## Important:[​](#important "Direct link to Important:") * Do not URL-encode or modify the signed\_gcs\_id * This step is **required** - without it, the file exists in storage but not in Playbook * For Backblaze multipart: ensure all parts uploaded before calling this endpoint ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 404 * 422 Asset upload completed successfully unauthenticated file not found in storage invalid signed\_gcs\_id --- # Prepare Upload ``` POST /v1/:slug/assets/upload_prepare ``` **Step 1 of Asset Upload Process** This endpoint prepares signed URLs for uploading assets to cloud storage. The response varies based on the organization's storage provider (GCS or Backblaze). Check the **storage\_provider** field to determine which upload flow to use. *** ## GCS Upload Flow (4 Steps)[​](#gcs-upload-flow-4-steps "Direct link to GCS Upload Flow (4 Steps)") When `storage_provider` is `"gcs"`, use the resumable upload protocol: ### Step 1: Prepare Upload (this endpoint)[​](#step-1-prepare-upload-this-endpoint "Direct link to Step 1: Prepare Upload (this endpoint)") ``` POST /v1/{org_slug}/assets/upload_prepare Headers: Authorization: Bearer {your_access_token} Content-Type: application/json Body: { "asset": { "title": "my-image.jpg", "media_type": "image/jpeg", "size": 1048576 } } ``` **Returns:** storage\_provider="gcs", upload\_url, signed\_gcs\_id, encrypted\_organization\_metadata, file\_extension ### Step 2: Initialize Resumable Upload Session (GCS API)[​](#step-2-initialize-resumable-upload-session-gcs-api "Direct link to Step 2: Initialize Resumable Upload Session (GCS API)") ``` POST {upload_url} Headers: Content-Type: {media_type} x-goog-resumable: start x-goog-meta-encrypted-organization-metadata: {encrypted_organization_metadata} x-goog-meta-extension: {file_extension} Body: EMPTY ``` **Returns:** HTTP 200 with `Location` header containing session URL ### Step 3: Upload File Data (GCS API)[​](#step-3-upload-file-data-gcs-api "Direct link to Step 3: Upload File Data (GCS API)") ``` PUT {session_url_from_location_header} Headers: Content-Type: {media_type} Body: {binary_file_data} ``` **Returns:** HTTP 200 OK when upload completes ### Step 4: Complete Upload (Playbook API)[​](#step-4-complete-upload-playbook-api "Direct link to Step 4: Complete Upload (Playbook API)") ``` POST /v1/{org_slug}/assets/upload_complete Body: { "asset": { "signed_gcs_id": "{signed_gcs_id}", "title": "my-image.jpg" } } ``` *** ## Backblaze Upload Flow[​](#backblaze-upload-flow "Direct link to Backblaze Upload Flow") When `storage_provider` is `"backblaze"`, check if `multipart_upload_id` is present. ### Single-Part Upload (files < 5MB)[​](#single-part-upload-files--5mb "Direct link to Single-Part Upload (files < 5MB)") When `multipart_upload_id` is null and `upload_url` is present: **Step 1:** Call upload\_prepare → get `storage_provider="backblaze"`, `upload_url`, `signed_gcs_id`, `file_extension`, `encrypted_organization_metadata` **Step 2:** PUT file with required headers: ``` PUT {upload_url} Headers: Content-Type: {media_type} x-amz-meta-extension: {file_extension} x-amz-meta-encrypted-organization-metadata: {encrypted_organization_metadata} Body: {binary_file_data} ``` **Step 3:** Call upload\_complete with `signed_gcs_id` ### Multipart Upload (files >= 5MB)[​](#multipart-upload-files--5mb "Direct link to Multipart Upload (files >= 5MB)") When `multipart_upload_id` and `parts` are present: **Step 1:** Call upload\_prepare → get `storage_provider="backblaze"`, `parts`, `multipart_upload_id`, `part_size`, `signed_gcs_id` **Step 2:** Upload each part (no extra headers needed for parts): ``` # For each part in parts array: PUT {part.url} Body: {file_chunk} # Slice file: [(part_number-1) * part_size, part_number * part_size] ``` **Step 3:** Call upload\_complete with `signed_gcs_id` AND `multipart_upload_id` *** ## Important Notes:[​](#important-notes "Direct link to Important Notes:") * GCS Steps 2 & 3 are Google Cloud Storage API calls (no Authorization header needed) * Backblaze single-part uploads require headers that match the signed URL signature * Signed URLs expire after 24 hours * For GCS: session URLs from Step 2 have shorter expiration * For Backblaze multipart: upload parts in order (part\_number 1, 2, 3...) * Size parameter is optional: if omitted, single-part upload is used (works up to 5GB) ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 422 Backblaze large file upload via proxy (no multipart needed) unauthenticated when browser plugin limits are exceeded --- # List Assets ``` GET /v1/:slug/assets ``` Get current organization's assets ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 successful unauthenticated --- # List Boards ``` GET /v1/:slug/boards ``` Get current organization's boards ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 successful unauthenticated --- # Create Asset ``` POST /v1/:slug/assets ``` Creates new asset ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 406 * 422 successfully creates an asset not acceptable when limits are met --- # Create Board ``` POST /v1/:slug/boards ``` Creating new board in current organization ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful sub-board creation --- # List Custom Fields ``` GET /v1/:slug/custom_fields ``` Get current organization's custom fields ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 successful unauthenticated --- # Delete Asset ``` DELETE /v1/:slug/assets/:token ``` Deletes an asset from the organization ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 204 * 404 successful not found --- # Delete Board ``` DELETE /v1/:slug/boards/:token ``` Destroys collection in current organization ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 204 successful --- # Update Asset ``` PATCH /v1/:slug/assets/:token ``` Updates asset attributes (PUT also would work) ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # Update Board ``` PATCH /v1/:slug/boards/:token ``` Update board in current organization. PUT request also will work ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- Version: v1 # Playbook API The Playbook API lets you host, organize, search, and ship visual media: a media library and full digital asset manager (DAM) exposed over REST. **Base URL:** `https://api.playbook.com/v1`. Most endpoints are scoped to an organization (workspace) slug, for example `/v1/{org}/assets`. ### Authentication[​](#authentication "Direct link to Authentication") Send a Bearer token in the `Authorization` header: `Authorization: Bearer YOUR_TOKEN`. Create and manage tokens yourself in the Playbook app, with no approval step. Tokens carry `read` and `write` scopes, enforced at the API layer, so a write call made with a read-only token returns `403`. ### Response envelope[​](#response-envelope "Direct link to Response envelope") Successful responses wrap the payload in a top-level `data` key. List endpoints return `data` as an array. ### Status codes[​](#status-codes "Direct link to Status codes") `401` bad token, `403` missing scope, `404` not found, `406` / `422` validation, `429` rate limited (back off and retry), `5xx` server error. ### Pagination[​](#pagination "Direct link to Pagination") List endpoints accept `page` and `per_page` query parameters. ### Async ingest[​](#async-ingest "Direct link to Async ingest") Uploads are asynchronous. Creating an asset returns a skeleton immediately; poll `GET /v1/{org}/assets/{token}` until `is_skeleton` is `false`. ### Build with AI[​](#build-with-ai "Direct link to Build with AI") Every operation here is also available to AI assistants through the hosted [MCP server](/docs/guides/mcp.md). New to the API? Start with [Getting Started](/docs/getting_started.md). ## Authentication[​](#authentication "Direct link to Authentication") * HTTP: Bearer Auth | Security Scheme Type: | http | | -------------------------- | ------ | | HTTP Authorization Scheme: | bearer | --- # Publish Board ``` POST /v1/:slug/boards/:token/publish ``` Publish a board to the web ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # Search Assets ``` GET /v1/:slug/search ``` Search through current organization's assets ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 404 returns empty results when no matches unauthenticated organization not found --- # Share Asset ``` POST /v1/:slug/assets/:token/share ``` Creates a Shared link with asset ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # Share Board ``` POST /v1/:slug/boards/:token/share ``` Create a Shared link for a board ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # Track Downloads ``` POST /v1/:slug/track_downloaded ``` Tracks a download ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 204 successful --- # Ungroup Assets ``` POST /v1/:slug/assets/ungroup_assets ``` Removes assets from their groups, making them individual assets again ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 * 401 * 404 * 406 * 422 successfully ungrouped assets unauthenticated child assets not found empty child asset tokens assets are not in groups --- # Update Asset Status ``` POST /v1/:slug/assets/:token/update_status ``` Updates asset status ## Request[​](#request "Direct link to request") ## Responses[​](#responses "Direct link to Responses") * 200 successful --- # API Conventions Behaviors that hold across every Playbook API endpoint. If you are building an integration, or an AI agent, against Playbook, treat this page as ground truth. ## Base URL[​](#base-url "Direct link to Base URL") All REST endpoints are served under: ``` https://api.playbook.com/v1 ``` Most endpoints are scoped to an organization (workspace) slug, for example `/v1/{org}/assets`. ## Authentication[​](#authentication "Direct link to Authentication") Every request authenticates with a **Bearer token** in the `Authorization` header: ``` Authorization: Bearer YOUR_TOKEN ``` Create and manage tokens yourself in the Playbook app, with no approval step. See [Getting Started](/docs/getting_started.md). ### Scopes[​](#scopes "Direct link to Scopes") Tokens carry scopes, enforced at the API layer: * **`read`** lists and fetches boards, assets, comments, and search results. * **`write`** creates, updates, moves, deletes, uploads, comments, shares, and publishes. A write call made with a read-only token returns `403`. Never ship a token in client-side code or paste it into a chat with an AI model. Configure it once as a request header. ## Response envelope[​](#response-envelope "Direct link to Response envelope") Successful responses wrap the payload in a top-level `data` key: ``` { "data": { "token": "abc123", "title": "Hero shot" } } ``` List endpoints return `data` as an array. Always read from `data`. ## Status codes and errors[​](#status-codes-and-errors "Direct link to Status codes and errors") | Code | Meaning | | ----- | -------------------------------------------------------------------- | | `200` | Success. | | `401` | Authentication failed. Check your Bearer token. | | `403` | Permission denied. Check the token's `read` / `write` scopes. | | `404` | Not found. Check the organization slug, board token, or asset token. | | `406` | Invalid input (used by batch URL ingest validation). | | `422` | Validation error. The response body explains which field failed. | | `429` | Rate limited. Back off and retry (see below). | | `5xx` | Playbook API error. Retry with backoff. | Error responses include a human-readable message. Tokens are never echoed back in error bodies. ## Rate limiting[​](#rate-limiting "Direct link to Rate limiting") The API may return `429 Too Many Requests` under sustained load. Back off and retry after a short delay, using exponential backoff. Specific limits are not published, so design clients to tolerate `429` and `5xx` responses gracefully. ## Pagination[​](#pagination "Direct link to Pagination") List endpoints accept `page` and `per_page` query parameters: ``` GET /v1/{org}/assets?page=2&per_page=50 ``` ## Async ingest[​](#async-ingest "Direct link to Async ingest") Uploads are **asynchronous**. Creating an asset (from a URL, a batch of URLs, or a signed upload) returns immediately with a **skeleton asset**. The row exists, but the bytes are still being fetched and processed. Poll `GET /v1/{org}/assets/{token}` until `is_skeleton` is `false`, then check: * `media_type` populated means the asset processed successfully. * `source_error` non-null means ingest failed (the message is the latest worker error). * `is_link: true` means it was kept as a bare link (only when `as_link: true` was passed). For batch URL ingest, pass a `uuid` per item to correlate each async result back to your request. See the [Upload guide, Async Ingest Semantics](/docs/guides/upload.md#async-ingest-semantics) for the full contract and recommended polling cadence. ## Driving Playbook from an AI agent[​](#driving-playbook-from-an-ai-agent "Direct link to Driving Playbook from an AI agent") Everything above is also available to AI assistants through the hosted **MCP server**, which exposes the same operations as tools, with the same scopes and the same async semantics, and can read these docs at runtime via `read_documentation`. See the [MCP guide](/docs/guides/mcp.md). --- # Examples Real products, built end to end on the Playbook API. Each example walks the full flow — the endpoints you call, in what order, with copy-pasteable code — so you can lift the pattern straight into your app. Every example assumes you have a token and your org slug. If you don't yet, see [Getting Started](/docs/getting_started.md#how-to-get-access). Throughout, we use: ``` export PLAYBOOK_TOKEN="your_api_token" export ORG="your-org" # your organization slug ``` All requests are authenticated with `Authorization: Bearer $PLAYBOOK_TOKEN` against `https://api.playbook.com/v1`. ## Pick a use case[​](#pick-a-use-case "Direct link to Pick a use case") | Example | What you build | Key endpoints | | ---------------------------------------------------------------------- | ------------------------------------------- | ------------------------------------ | | [Portfolio](/docs/examples/portfolio.md) | An auto-updating public portfolio | boards, upload, **publish** | | [Digital asset management](/docs/examples/digital-asset-management.md) | A searchable source of truth with metadata | custom fields, **search**, webhooks | | [Client gallery](/docs/examples/client-gallery.md) | Proofing galleries with feedback + delivery | **share**, comments, shared download | | [Brand portal](/docs/examples/brand-portal.md) | Always-current approved-asset distribution | **publish**, embed, tokens | | [Creative marketplace](/docs/examples/marketplace.md) | Upload, discovery, and licensed delivery | signed upload, **ai\_search**, share | | [AI media pipeline](/docs/examples/ai-pipeline.md) | Agents that search, ingest, and tag media | **ai\_search**, batch ingest, MCP | New to the API? Start with [Getting Started](/docs/getting_started.md) and the [SDK guide](/docs/guides/sdk.md), then come back here. --- # AI media pipeline Wire agents into your media library: search by meaning, pull references, generate variations, and file the results back — automatically. Two paths: call the API directly, or point any MCP client at the hosted server. ![An AI media pipeline using the Playbook MCP server](/img/examples/ai-pipeline.webp) ## The flow[​](#the-flow "Direct link to The flow") 1. Agent **searches semantically** for references (AI search). 2. Agent **ingests generated variations** in bulk. 3. Agent **auto-tags** them with custom fields. 4. Or skip the glue code entirely and use the **MCP server**. ## 1. Semantic search for references[​](#1-semantic-search-for-references "Direct link to 1. Semantic search for references") AI search returns meaning-ranked results with a cursor for paging deeper: agent.ts ``` const ORG = "your-org"; const TOKEN = process.env.PLAYBOOK_TOKEN!; async function aiSearch(query: string, cursor?: string) { const params = new URLSearchParams({ query }); if (cursor) params.set("after_cursor", cursor); const res = await fetch( `https://api.playbook.com/v1/${ORG}/ai_search?${params}`, { headers: { Authorization: `Bearer ${TOKEN}` } }, ); return res.json(); // { data: [...assets], after_cursor?: "..." } } const refs = await aiSearch("brand palette: warm coral, moody lighting"); ``` ## 2. Ingest generated variations[​](#2-ingest-generated-variations "Direct link to 2. Ingest generated variations") Your model produces new images (hosted somewhere reachable). Add them in one call — Playbook fetches and processes each in the background: ingest.ts ``` async function ingestVariations(boardToken: string, urls: string[]) { const res = await fetch( `https://api.playbook.com/v1/${ORG}/assets/batch_create_from_urls`, { method: "POST", headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ batch: { collection_token: boardToken, assets: urls.map((uri, i) => ({ uuid: `gen-${i}`, uri, title: `Variation ${i + 1}`, })), }, }), }, ); return (await res.json()).data; // [{ uuid, asset }] — each starts as a skeleton } ``` ## 3. Auto-tag the results[​](#3-auto-tag-the-results "Direct link to 3. Auto-tag the results") Have the agent file each variation against your taxonomy: tag.ts ``` async function tag(assetToken: string, fields: Record) { await fetch(`https://api.playbook.com/v1/${ORG}/assets/${assetToken}/update`, { method: "PATCH", headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ asset: { fields } }), }); } await tag("variation-1", { "Approval Status": "In Review", Source: "AI" }); ``` ## 4. Or skip the code — use MCP[​](#4-or-skip-the-code--use-mcp "Direct link to 4. Or skip the code — use MCP") If your agent runs in Claude, Cursor, or ChatGPT, you don't need any of the above. Point it at the hosted MCP server and it can search, tag, upload, and organize your media in natural language: ``` claude mcp add playbook --transport http https://mcp.playbook.com \ --header "Authorization: Bearer $PLAYBOOK_TOKEN" ``` See the [MCP guide](/docs/guides/mcp.md) for Cursor and ChatGPT setup and the full tool list. ## Related[​](#related "Direct link to Related") * [AI Search](/docs/guides/ai_search.md) * [Uploading Assets](/docs/guides/upload.md) * [Custom Fields](/docs/guides/custom_fields.md) * [MCP Server](/docs/guides/mcp.md) --- # Brand portal Distribute approved brand assets — logos, fonts, templates — to partners and teams, with a single source that's always current. Approve once on a board; everyone downstream sees the update immediately. ![A brand portal built on the Playbook API](/img/examples/brand-portal.webp) ## The flow[​](#the-flow "Direct link to The flow") 1. Keep a board of **approved** assets (curated with [custom fields](/docs/examples/digital-asset-management.md#1-define-your-taxonomy)). 2. **Publish** it as a public portal, or **share** it for a link-gated one. 3. **Embed** the portal in your intranet or marketing site. 4. Serve downloads with **token-scoped** API keys. ## 1. Curate approved assets[​](#1-curate-approved-assets "Direct link to 1. Curate approved assets") Filter your library down to what's cleared for distribution and collect it on a "Brand kit" board. An approval custom field makes this reliable: ``` curl "https://api.playbook.com/v1/$ORG/search?query=&filters[fields][Approval%20Status]=Approved" \ -H "Authorization: Bearer $PLAYBOOK_TOKEN" ``` ## 2. Publish the portal[​](#2-publish-the-portal "Direct link to 2. Publish the portal") ``` curl -X POST "https://api.playbook.com/v1/$ORG/boards/brand-kit/publish" \ -H "Authorization: Bearer $PLAYBOOK_TOKEN" ``` ``` { "data": { "url": "https://playbook.com/p/your-org/brand-kit" } } ``` A published board is a public, always-current gallery — update the board and the portal updates with it, no redeploy. For a link-gated portal instead, use `POST /v1/$ORG/boards/brand-kit/share`. See the [published vs. shared](/docs/guides/sharing_and_publishing.md#published-vs-shared) comparison. ## 3. Embed it[​](#3-embed-it "Direct link to 3. Embed it") Drop the portal straight into your intranet or brand site: ``` ``` ## 4. Scope partner access[​](#4-scope-partner-access "Direct link to 4. Scope partner access") Issue **separate API tokens** per integration so each only sees what it should, and revoke without touching the others. Fetch the approved set on your own domain and render a branded download page: brand-kit.tsx ``` const ORG = "your-org"; const TOKEN = process.env.PLAYBOOK_TOKEN!; // a token scoped for the portal type Asset = { token: string; title: string; display_url: string; raw_url: string }; async function getBrandKit(): Promise { const res = await fetch( `https://api.playbook.com/v1/${ORG}/boards/brand-kit/assets?per_page=100`, { headers: { Authorization: `Bearer ${TOKEN}` } }, ); return (await res.json()).data; } export default async function BrandKit() { const assets = await getBrandKit(); return ( ); } ``` ## Related[​](#related "Direct link to Related") * [Sharing & Publishing](/docs/guides/sharing_and_publishing.md) * [Custom Fields](/docs/guides/custom_fields.md) * [Fetching Data](/docs/guides/fetching_data.md) * [Getting Started — tokens](/docs/getting_started.md#how-to-get-access) --- # Client gallery Send clients a gallery of selects, collect their feedback inline, and deliver final high-res files the moment they approve — all through the API. ![A client proofing gallery built on the Playbook API](/img/examples/client-gallery.webp) ## The flow[​](#the-flow "Direct link to The flow") 1. Build a board of selects and add the assets. 2. **Share** the board to get a client-facing link. 3. Collect feedback with **comments**. 4. Deliver approved originals via the **shared download** endpoint. ## 1. Create the selects board[​](#1-create-the-selects-board "Direct link to 1. Create the selects board") ``` curl -X POST "https://api.playbook.com/v1/$ORG/boards" \ -H "Authorization: Bearer $PLAYBOOK_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Acme x Studio — Round 1 selects" }' ``` Add the selects with [batch upload](/docs/examples/portfolio.md#2-add-work-to-the-board), passing the board's `token`. ## 2. Share it with the client[​](#2-share-it-with-the-client "Direct link to 2. Share it with the client") ``` curl -X POST "https://api.playbook.com/v1/$ORG/boards/round-1-selects/share" \ -H "Authorization: Bearer $PLAYBOOK_TOKEN" ``` ``` { "data": { "url": "https://playbook.com/s/def456/round-1-selects" } } ``` Send that URL to the client. Shared board links carry every asset, its metadata, and (optionally) comments and downloads. See [Sharing & Publishing](/docs/guides/sharing_and_publishing.md). ## 3. Collect feedback[​](#3-collect-feedback "Direct link to 3. Collect feedback") Clients leave feedback on the shared board in Playbook; read it back per asset to power a review thread in your own UI: review\.ts ``` const ORG = "your-org"; const TOKEN = process.env.PLAYBOOK_TOKEN!; async function getComments(assetToken: string) { const res = await fetch( `https://api.playbook.com/v1/${ORG}/assets/${assetToken}/comments`, { headers: { Authorization: `Bearer ${TOKEN}` } }, ); const { data } = await res.json(); // threaded: each comment may have `replies` return data; } ``` Comments are threaded (replies nest under `replies`), which maps cleanly onto a proofing conversation. See [Comments](/docs/guides/comments.md). ## 4. Deliver on approval[​](#4-deliver-on-approval "Direct link to 4. Deliver on approval") Once a client signs off, hand them the original file. Use the shared-download endpoint with the board's share slug: deliver.ts ``` async function downloadUrl(assetToken: string, shareSlug: string) { const res = await fetch( `https://api.playbook.com/v1/shared/${ORG}/assets/${assetToken}/download?sharedlinkslug=${shareSlug}`, { headers: { Authorization: `Bearer ${TOKEN}` } }, ); const { data } = await res.json(); return data.raw_url; // full-resolution original; data.display_url for preview } ``` Gate the call behind your own "approved" state so originals only go out once the client accepts. ## Related[​](#related "Direct link to Related") * [Sharing & Publishing](/docs/guides/sharing_and_publishing.md) * [Comments](/docs/guides/comments.md) * [Fetching Data](/docs/guides/fetching_data.md) --- # Digital asset management A searchable source of truth for brand and marketing teams: structured metadata, powerful search, and events that keep the rest of your stack in sync. ![A digital asset management dashboard built on the Playbook API](/img/examples/dam.webp) ## The flow[​](#the-flow "Direct link to The flow") 1. Define **custom fields** for your taxonomy (approval status, campaign, etc.). 2. Ingest assets and set their fields. 3. **Search and filter** across metadata and full text. 4. Fire **webhooks** so downstream systems stay current. ## 1. Define your taxonomy[​](#1-define-your-taxonomy "Direct link to 1. Define your taxonomy") Create the fields your team files against. Here, an approval workflow: ``` curl -X POST "https://api.playbook.com/v1/$ORG/custom_fields" \ -H "Authorization: Bearer $PLAYBOOK_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "field": { "name": "Approval Status", "options": ["Draft", "In Review", "Approved", "Rejected"] } }' ``` See [Custom Fields](/docs/guides/custom_fields.md) for field types and options. ## 2. Ingest and tag[​](#2-ingest-and-tag "Direct link to 2. Ingest and tag") Ingest the asset into a board… ``` curl -X POST "https://api.playbook.com/v1/$ORG/assets" \ -H "Authorization: Bearer $PLAYBOOK_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "asset": { "uri": "https://example.com/campaign/hero.jpg", "title": "Spring hero", "collection_token": "spring-campaign" } }' ``` …then set (and later change) its custom fields as it moves through review: ``` curl -X PATCH "https://api.playbook.com/v1/$ORG/assets/spring-hero/update" \ -H "Authorization: Bearer $PLAYBOOK_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "asset": { "fields": { "Approval Status": "Approved" } } }' ``` ## 3. Search across your library[​](#3-search-across-your-library "Direct link to 3. Search across your library") Filter on media type, board, and any custom field at once. This is the query behind a "show me approved images from the spring campaign" view: dam-search.ts ``` const ORG = "your-org"; const TOKEN = process.env.PLAYBOOK_TOKEN!; async function search(query: string, filters: Record = {}) { const params = new URLSearchParams({ query }); for (const [key, value] of Object.entries(filters)) { params.append(key, value); } const res = await fetch( `https://api.playbook.com/v1/${ORG}/search?${params}`, { headers: { Authorization: `Bearer ${TOKEN}` } }, ); const { data } = await res.json(); return data; } // Approved images in the spring campaign const results = await search("", { "filters[media_type]": "image/jpeg", "filters[collection_token]": "spring-campaign", "filters[fields][Approval Status]": "Approved", }); ``` For semantic, natural-language queries ("moody product shots on a dark background"), swap `search` for [AI Search](/docs/guides/ai_search.md). ## 4. Stay in sync with webhooks[​](#4-stay-in-sync-with-webhooks "Direct link to 4. Stay in sync with webhooks") Register a trigger so your DAM dashboard, Slack, or data warehouse updates the moment assets change: ``` curl -X POST "https://api.playbook.com/v1/trigger" \ -H "Authorization: Bearer $PLAYBOOK_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "hook_url": "https://your-server.com/webhooks/playbook" }' ``` Your endpoint receives asset create/update events (including the asset payload and its `display_url`). See [Webhooks](/docs/guides/webhooks.md) for the event schema and signature verification. ## Related[​](#related "Direct link to Related") * [Custom Fields](/docs/guides/custom_fields.md) * [Search](/docs/guides/search.md) · [AI Search](/docs/guides/ai_search.md) * [Webhooks](/docs/guides/webhooks.md) * [Asset Management](/docs/guides/asset_management.md) --- # Creative marketplace Run a stock, template, or asset marketplace: sellers upload, buyers discover and preview, and you deliver the licensed original on purchase. ![A creative marketplace built on the Playbook API](/img/examples/marketplace.webp) ## The flow[​](#the-flow "Direct link to The flow") 1. **Sellers upload** files directly with the signed-upload flow. 2. **Buyers discover** with AI (natural-language) search. 3. **Preview** with CDN URLs; **deliver** the original on purchase via a share. ## 1. Seller uploads (signed URL)[​](#1-seller-uploads-signed-url "Direct link to 1. Seller uploads (signed URL)") For files that live on the seller's device, use the two-step signed upload so bytes go straight to storage, never through your server: upload.ts ``` const ORG = "your-org"; const TOKEN = process.env.PLAYBOOK_TOKEN!; async function uploadFile(file: File, boardToken: string) { // 1. Ask Playbook for a pre-signed URL const prep = await fetch( `https://api.playbook.com/v1/${ORG}/assets/upload_prepare`, { method: "POST", headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ asset: { title: file.name, size: file.size, collection_token: boardToken, }, }), }, ); const { data } = await prep.json(); // 2. PUT the bytes directly to storage (no auth header on this request) await fetch(data.upload_url, { method: "PUT", body: file }); return data; // includes the new asset token; poll it until processing finishes } ``` See [Uploading Assets](/docs/guides/upload.md) for the full signed flow, the URL-based alternative, and how to poll for completion. ## 2. Buyer discovery (AI search)[​](#2-buyer-discovery-ai-search "Direct link to 2. Buyer discovery (AI search)") Let buyers describe what they want in plain language. AI search ranks by meaning and paginates with a cursor: discover.ts ``` async function discover(query: string, cursor?: string) { const params = new URLSearchParams({ query }); if (cursor) params.set("after_cursor", cursor); const res = await fetch( `https://api.playbook.com/v1/${ORG}/ai_search?${params}`, { headers: { Authorization: `Bearer ${TOKEN}` } }, ); return res.json(); // { data: [...], after_cursor?: "..." } } const page = await discover("sunlit minimalist workspace, top-down"); ``` Combine with filters (`filters[media_type]`, `filters[boards][]`) to scope by format or seller. See [AI Search](/docs/guides/ai_search.md). ## 3. Preview, then deliver on purchase[​](#3-preview-then-deliver-on-purchase "Direct link to 3. Preview, then deliver on purchase") Show the watermark-free `display_url` as the browse/preview image. On a completed purchase, mint a share and hand over the full-resolution original: fulfill.ts ``` async function fulfill(assetToken: string) { // Create a shareable link for the purchased asset const share = await fetch( `https://api.playbook.com/v1/${ORG}/assets/${assetToken}/share`, { method: "POST", headers: { Authorization: `Bearer ${TOKEN}` } }, ); const shareSlug = new URL((await share.json()).data.url).pathname.split("/")[2]; // Resolve the original download URL for the buyer const dl = await fetch( `https://api.playbook.com/v1/shared/${ORG}/assets/${assetToken}/download?sharedlinkslug=${shareSlug}`, { headers: { Authorization: `Bearer ${TOKEN}` } }, ); return (await dl.json()).data.raw_url; // licensed original } ``` Gate `fulfill()` behind your payment webhook so originals only ship after a successful charge. ## Related[​](#related "Direct link to Related") * [Uploading Assets](/docs/guides/upload.md) * [AI Search](/docs/guides/ai_search.md) · [Search](/docs/guides/search.md) * [Sharing & Publishing](/docs/guides/sharing_and_publishing.md) --- # Portfolio Give every user a polished, always-current portfolio. You curate a board, add their work, and either **publish** it as a hosted public gallery or fetch the assets and render your own UI. Update the board and the portfolio updates itself — no redeploy. ![A portfolio site built on the Playbook API](/img/examples/portfolio.webp) ## The flow[​](#the-flow "Direct link to The flow") 1. Create a board — the portfolio container. 2. Add work to it (from URLs, or a direct upload). 3. **Publish** the board for a hosted public gallery + embeddable iframe, *or* fetch the board's assets and render your own gallery. ## 1. Create the portfolio board[​](#1-create-the-portfolio-board "Direct link to 1. Create the portfolio board") ``` curl -X POST "https://api.playbook.com/v1/$ORG/boards" \ -H "Authorization: Bearer $PLAYBOOK_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Jordan Lee — Photography", "description": "Selected work, 2026" }' ``` The response includes the new board's `token` (e.g. `jordan-lee-photography`). See [Creating Boards](/docs/guides/create_boards.md). ## 2. Add work to the board[​](#2-add-work-to-the-board "Direct link to 2. Add work to the board") Point Playbook at the image URLs — it fetches, processes, and generates delivery-ready renditions for you: ``` curl -X POST "https://api.playbook.com/v1/$ORG/assets/batch_create_from_urls" \ -H "Authorization: Bearer $PLAYBOOK_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "batch": { "collection_token": "jordan-lee-photography", "assets": [ { "uuid": "1", "uri": "https://example.com/shoot/01.jpg", "title": "Coastline" }, { "uuid": "2", "uri": "https://example.com/shoot/02.jpg", "title": "Studio portrait" } ] } }' ``` Uploads are asynchronous — each asset comes back as a skeleton and finishes processing in the background. See [Uploading Assets](/docs/guides/upload.md) for the signed-upload flow (for local files) and how to poll for completion. ## 3a. Publish it (hosted gallery)[​](#3a-publish-it-hosted-gallery "Direct link to 3a. Publish it (hosted gallery)") The fastest path: publish the board and hand back a public URL. ``` curl -X POST "https://api.playbook.com/v1/$ORG/boards/jordan-lee-photography/publish" \ -H "Authorization: Bearer $PLAYBOOK_TOKEN" ``` ``` { "data": { "url": "https://playbook.com/p/your-org/jordan-lee-photography" } } ``` Embed it anywhere: ``` ``` See [Sharing & Publishing](/docs/guides/sharing_and_publishing.md) for the difference between shared and published links. ## 3b. Or render your own gallery[​](#3b-or-render-your-own-gallery "Direct link to 3b. Or render your own gallery") Prefer full control over the markup? Fetch the board's assets and lay them out yourself. Each asset carries a CDN `display_url`. Portfolio.tsx ``` const ORG = "your-org"; const BOARD = "jordan-lee-photography"; const TOKEN = process.env.PLAYBOOK_TOKEN; type Asset = { token: string; title: string; display_url: string }; async function getPortfolio(): Promise { const res = await fetch( `https://api.playbook.com/v1/${ORG}/boards/${BOARD}/assets?per_page=50`, { headers: { Authorization: `Bearer ${TOKEN}` } }, ); const { data } = await res.json(); return data; } export default async function Portfolio() { const assets = await getPortfolio(); return (
{assets.map((a) => ( {a.title} ))}
); } ``` To keep the portfolio fresh, just add or reorder assets on the board — both the published gallery and your custom render reflect it on the next load. ## Related[​](#related "Direct link to Related") * [Creating Boards](/docs/guides/create_boards.md) * [Fetching Data](/docs/guides/fetching_data.md) * [Sharing & Publishing](/docs/guides/sharing_and_publishing.md) * [SDK galleries](/docs/guides/sdk.md) --- # Getting Started Welcome! Playbook is a creative asset management platform with an API built for developers, not just admins. Whether you're hosting a client's visual files, building a portfolio tool, or wiring an AI agent into your media library, the Playbook API gives your apps programmatic access to a sophisticated asset library. You can ingest, manage, search, organize, and deliver rich media content all via standard RESTful calls. It's designed for seamless integration into creative workflows and backend systems alike. ## Quick start[​](#quick-start "Direct link to Quick start") Once you have a token (see [how to get access](#how-to-get-access) below), searching your library is one call: ``` curl https://api.playbook.com/v1/your-org/search?query=logo \ -H "Authorization: Bearer $PLAYBOOK_TOKEN" ``` ## What you can build[​](#what-you-can-build "Direct link to What you can build") The Playbook API gives you programmatic access to a full media management platform, featuring: * Flexible uploads and real-time processing * Advanced organization with tags and board structures * Powerful search capabilities (metadata, tags, full-text) * Galleries and different asset views * Global media delivery via CDN for speed * Optional advanced features like bulk editing and AI tools ## What you can do[​](#what-you-can-do "Direct link to What you can do") Explore key API endpoints to: * **Manage assets** – Upload, update, delete, and retrieve media files. * **Organize your media** – Search assets using metadata and full-text search; build and manage Boards and Organizational structures. * **Handle collaborators** – Manage users and permissions within an Organization. * Programmatically harness Playbook’s media intelligence and content organization tools. See the full list of available endpoints under **Assets, Search, Boards, Organizations, and Users**. ## How to get access[​](#how-to-get-access "Direct link to How to get access") API token management is available in the Playbook app — you create and manage your own tokens, no approval step required. 1. **Open the Developer tab:** Go to [**Developer → SDK** in the Playbook app](https://www.playbook.com/sign-up?navigateTo=sdk). If you don't have a workspace yet, you'll be prompted to create one first. 2. **Generate a token:** Create an API token scoped to your workspace, then send it as a `Bearer` token in the `Authorization` header (see [the API reference](/docs/api.md)). 3. **Questions?** For custom workflows or licensing, [reach out to our team](mailto:support@playbook.com). ## Next steps[​](#next-steps "Direct link to Next steps") Ready to dive deeper? * **Explore Tutorials:** Follow our step-by-step guides to integrate specific SDK features and components, starting with [uploading assets](/docs/guides/upload.md) * **Connect AI assistants:** Wire Claude, ChatGPT, Cursor, or any other MCP-aware client to your Playbook workspace via the [Playbook MCP Server](/docs/guides/mcp.md). * **Consult the API Reference:** Find detailed information on all API endpoints, SDK methods, and parameters. ([API Reference](/docs/api.md)) * **Understand Product & Pricing:** Learn about our flexible pricing options based on your usage and feature requirements. ([Playbook Pricing](https://www.playbook.com/pricing)) * **Contact Us:** Have questions or special requirements? Reach out to our [support team](mailto:support@playbook.com) to discuss custom workflows and licensing. ## Interested in deeper integration?[​](#interested-in-deeper-integration "Direct link to Interested in deeper integration?") If you'd like richer UI integration, advanced media management, or turnkey frontend components, check out our [**Playbook SDK**](https://www.playbook.com/sdk). It enables you to quickly embed a polished media library—complete with asset views, galleries, tagging, AI-powered editing, CDN-backed delivery, and bulk editing tools—right into your application. We’d love to hear about your use case. Reach out to [schedule a call](https://www.playbook.com/contact) with the team --- # AI-Powered Search Discover assets using natural language and semantic understanding with Playbook's AI Search. ## Overview[​](#overview "Direct link to Overview") AI Search goes beyond keyword matching to understand the meaning and context of your queries. Instead of exact text matches, AI Search finds assets based on visual similarity, concept understanding, and semantic relevance. ### AI Search vs. Regular Search[​](#ai-search-vs-regular-search "Direct link to AI Search vs. Regular Search") | Feature | Regular Search | AI Search | | ------------------------ | --------------------- | ---------------------- | | **Method** | Text/keyword matching | Semantic understanding | | **Query Style** | Exact terms | Natural language | | **Results** | Title/tag matches | Concept-based matches | | **Visual Understanding** | No | Yes | | **Best For** | Known filenames | Exploring, discovering | *** ## Prerequisites[​](#prerequisites "Direct link to Prerequisites") * **Access Token**: API token with search permissions * **Organization Slug**: Your organization identifier * **AI Search Access**: Feature must be enabled for your organization *** ## Basic AI Search[​](#basic-ai-search "Direct link to Basic AI Search") ### Simple Query[​](#simple-query "Direct link to Simple Query") ``` curl "https://api.playbook.com/v1/my-org/ai_search?query=sunset+beach+photos" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response:** ``` { "data": [ { "id": 301, "token": "coastal-sunset", "title": "Golden Hour Coast", "media_type": "image/jpeg", "display_url": "https://cdn.playbook.com/coastal-sunset.jpg" }, { "id": 302, "token": "beach-evening", "title": "Evening Beach Walk", "media_type": "image/jpeg", "display_url": "https://cdn.playbook.com/beach-evening.jpg" } ], "pagy": { "after_cursor": "eyJpZCI6MzAyLCJzY29yZSI6MC44NX0=" } } ``` ### Natural Language Queries[​](#natural-language-queries "Direct link to Natural Language Queries") AI Search understands natural language - ask questions the way you'd ask a person: ``` # Find conceptually related assets "images that would work well for a summer campaign" # Describe what you're looking for "product photos with white background and good lighting" # Ask for specific moods or styles "minimalist designs with lots of negative space" # Find by use case "images suitable for social media headers" ``` *** ## Advanced Queries[​](#advanced-queries "Direct link to Advanced Queries") ### Combining Concepts[​](#combining-concepts "Direct link to Combining Concepts") ``` # Multiple concepts curl "https://api.playbook.com/v1/my-org/ai_search?query=professional+headshot+bright+background+smiling" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### Filtering AI Search Results[​](#filtering-ai-search-results "Direct link to Filtering AI Search Results") Combine AI search with traditional filters for precision: ``` curl "https://api.playbook.com/v1/my-org/ai_search?query=modern+workspace&filters[media_type]=image/jpeg&filters[boards][]=office-photos" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Available Filters:** ``` { "filters": { "media_type": "image/jpeg", "boards": ["board-token-1", "board-token-2"], "title": "contains-this-text", "tags": ["approved", "branding"], "tags_op": "and" } } ``` *** ## Pagination[​](#pagination "Direct link to Pagination") AI Search uses cursor-based pagination for optimal performance. ### First Page[​](#first-page "Direct link to First Page") ``` curl "https://api.playbook.com/v1/my-org/ai_search?query=product+photography" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### Next Page[​](#next-page "Direct link to Next Page") Use the `after_cursor` from the previous response: ``` curl "https://api.playbook.com/v1/my-org/ai_search?query=product+photography&after_cursor=eyJpZCI6MzAyLCJzY29yZSI6MC44NX0=" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### JavaScript Example[​](#javascript-example "Direct link to JavaScript Example") ``` async function searchAllPages(query) { const allResults = []; let cursor = null; while (true) { const params = new URLSearchParams({ query: query }); if (cursor) { params.append('after_cursor', cursor); } const response = await fetch( `https://api.playbook.com/v1/my-org/ai_search?${params}`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ); const { data, pagy } = await response.json(); allResults.push(...data); if (!pagy.after_cursor) break; cursor = pagy.after_cursor; } return allResults; } ``` *** ## Refinement with Parent Queries[​](#refinement-with-parent-queries "Direct link to Refinement with Parent Queries") Use parent queries to refine and narrow down search results: ### Initial Search[​](#initial-search "Direct link to Initial Search") ``` curl "https://api.playbook.com/v1/my-org/ai_search?query=office+interior" \ -H "Authorization: Bearer YOUR_TOKEN" ``` Save the search result token or ID as `parent_id`. ### Refined Search[​](#refined-search "Direct link to Refined Search") ``` curl "https://api.playbook.com/v1/my-org/ai_search?query=modern+minimalist&parent_id=search-token-123" \ -H "Authorization: Bearer YOUR_TOKEN" ``` This narrows down the "office interior" results to only modern minimalist styles. *** ## Use Cases[​](#use-cases "Direct link to Use Cases") ### 1. Visual Discovery[​](#1-visual-discovery "Direct link to 1. Visual Discovery") Find assets based on visual characteristics: ``` # By color palette "images with warm autumn colors" # By composition "photos with central subject and blurred background" # By style "illustrations with flat design aesthetic" ``` ### 2. Conceptual Search[​](#2-conceptual-search "Direct link to 2. Conceptual Search") Search by abstract concepts: ``` "assets that convey trust and professionalism" "images that feel energetic and dynamic" "designs with a vintage feel" ``` ### 3. Smart Asset Discovery[​](#3-smart-asset-discovery "Direct link to 3. Smart Asset Discovery") Help users find assets they didn't know how to describe: ``` // User interface example async function smartAssetFinder(userDescription) { const response = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/ai_search`, { method: 'GET', headers: { 'Authorization': `Bearer ${ACCESS_TOKEN}` }, params: { query: userDescription, filters: { media_type: 'image/*', tags: ['approved'] } } } ); return await response.json(); } // Usage const results = await smartAssetFinder( "bright cheerful images for kids product page" ); ``` ### 4. Content Recommendations[​](#4-content-recommendations "Direct link to 4. Content Recommendations") Build "similar assets" features: ``` async function findSimilarAssets(assetToken) { // First, get the asset details const asset = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ).then(r => r.json()); // Use AI search with asset characteristics const query = `${asset.data.title} ${asset.data.tags.join(' ')}`; const similar = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/ai_search?query=${encodeURIComponent(query)}`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ).then(r => r.json()); // Filter out the original asset return similar.data.filter(a => a.token !== assetToken); } ``` *** ## Best Practices[​](#best-practices "Direct link to Best Practices") ### 1. Query Construction[​](#1-query-construction "Direct link to 1. Query Construction") **Good Queries:** ``` ✅ "professional product photos with white background" ✅ "landscape images with mountains and blue sky" ✅ "minimalist logo designs in monochrome" ``` **Less Effective:** ``` ❌ "good images" (too vague) ❌ "IMG_1234.jpg" (use regular search for filenames) ❌ "red" (too generic, be more specific) ``` ### 2. Combining Search Types[​](#2-combining-search-types "Direct link to 2. Combining Search Types") Use AI Search for discovery, then refine with regular search: ``` async function hybridSearch(concept, exactTags) { // Start with AI search for concept const aiResults = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/ai_search?query=${concept}`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ).then(r => r.json()); // Filter by exact tags const filtered = aiResults.data.filter(asset => exactTags.every(tag => asset.tags.includes(tag)) ); return filtered; } // Usage const results = await hybridSearch( "corporate headshots with natural lighting", ["approved", "2024"] ); ``` ### 3. Performance Optimization[​](#3-performance-optimization "Direct link to 3. Performance Optimization") **Cache Common Searches:** ``` const searchCache = new Map(); async function cachedAISearch(query, ttl = 3600000) { const cacheKey = `ai_search:${query}`; if (searchCache.has(cacheKey)) { const cached = searchCache.get(cacheKey); if (Date.now() - cached.timestamp < ttl) { return cached.data; } } const response = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/ai_search?query=${encodeURIComponent(query)}`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ); const data = await response.json(); searchCache.set(cacheKey, { data, timestamp: Date.now() }); return data; } ``` ### 4. User Experience Tips[​](#4-user-experience-tips "Direct link to 4. User Experience Tips") **Provide Search Suggestions:** ``` const searchSuggestions = [ "Product photos with clean backgrounds", "Team photos in office settings", "Lifestyle images with natural lighting", "Brand assets in primary colors" ]; function showSearchHints() { return searchSuggestions.map(suggestion => ({ label: suggestion, onClick: () => performAISearch(suggestion) })); } ``` *** ## Complete Implementation Example[​](#complete-implementation-example "Direct link to Complete Implementation Example") Here's a full search interface implementation: ``` class PlaybookAISearch { constructor(orgSlug, accessToken) { this.orgSlug = orgSlug; this.accessToken = accessToken; this.baseUrl = 'https://api.playbook.com/v1'; } async search(query, options = {}) { const params = new URLSearchParams({ query: query }); // Add filters if provided if (options.filters) { if (options.filters.media_type) { params.append('filters[media_type]', options.filters.media_type); } if (options.filters.boards) { options.filters.boards.forEach(board => { params.append('filters[boards][]', board); }); } if (options.filters.tags) { options.filters.tags.forEach(tag => { params.append('filters[tags][]', tag); }); } } // Add pagination cursor if (options.after_cursor) { params.append('after_cursor', options.after_cursor); } // Add parent query for refinement if (options.parent_id) { params.append('parent_id', options.parent_id); } const response = await fetch( `${this.baseUrl}/${this.orgSlug}/ai_search?${params}`, { headers: { Authorization: `Bearer ${this.accessToken}` } } ); if (!response.ok) { throw new Error(`AI Search failed: ${response.statusText}`); } return await response.json(); } async *searchStream(query, options = {}) { let cursor = null; while (true) { const results = await this.search(query, { ...options, after_cursor: cursor }); yield results.data; if (!results.pagy.after_cursor) break; cursor = results.pagy.after_cursor; } } async searchAll(query, options = {}) { const allResults = []; for await (const batch of this.searchStream(query, options)) { allResults.push(...batch); } return allResults; } } // Usage const search = new PlaybookAISearch('my-org', 'your_token'); // Simple search const results = await search.search("sunset beach photos"); // Filtered search const filtered = await search.search("product photography", { filters: { media_type: "image/jpeg", tags: ["approved", "2024"] } }); // Get all results across pages const allResults = await search.searchAll("office interiors"); // Stream results for progressive loading for await (const batch of search.searchStream("brand assets")) { console.log(`Loaded ${batch.length} assets`); displayAssets(batch); } ``` *** ## Error Handling[​](#error-handling "Direct link to Error Handling") ### Common Errors[​](#common-errors "Direct link to Common Errors") **401: Unauthenticated** ``` { "error": "Unauthenticated" } ``` Solution: Check your access token is valid. **403: AI Search not available** ``` { "error": "AI Search is not available for your organization" } ``` Solution: Contact support to enable AI Search for your plan. **400: Invalid query** ``` { "error": "Query parameter is required" } ``` Solution: Ensure the query parameter is not empty. *** ## Comparison: When to Use Each Search Type[​](#comparison-when-to-use-each-search-type "Direct link to Comparison: When to Use Each Search Type") ### Use Regular Search When:[​](#use-regular-search-when "Direct link to Use Regular Search When:") * Searching for specific filenames * Looking up assets by exact tags * Filtering by board membership * You know the exact terms used in titles ### Use AI Search When:[​](#use-ai-search-when "Direct link to Use AI Search When:") * Exploring your asset library * Searching by visual concepts * Finding assets for specific use cases * Looking for aesthetically similar assets * Users describe what they want in natural language ### Use Both Together:[​](#use-both-together "Direct link to Use Both Together:") ``` async function comprehensiveSearch(query, tags) { // Get AI search results const aiResults = await aiSearch(query); // Filter by exact tags const filtered = aiResults.filter(asset => tags.every(tag => asset.tags.includes(tag)) ); return filtered; } ``` *** ## Related API Endpoints[​](#related-api-endpoints "Direct link to Related API Endpoints") * [AI Search](/docs/api/ai-search-organization-assets.md) * [Regular Search](/docs/api/search-organization-assets.md) * [List Assets](/docs/api/organization-assets.md) *** ## Next Steps[​](#next-steps "Direct link to Next Steps") * Learn about [Asset Management](/docs/guides/asset_management.md) to organize search results * Explore [Custom Fields](/docs/guides/custom_fields.md) for additional filtering * Read about [Webhooks](/docs/guides/webhooks.md) to automate workflows based on search results --- # Advanced Asset Management Learn how to organize, group, tag, and share assets programmatically using the Playbook API. ## Prerequisites[​](#prerequisites "Direct link to Prerequisites") * **Access Token**: API token with asset management permissions * **Organization Slug**: Your organization identifier * **Asset Tokens**: IDs of assets you want to manage *** ## Asset Grouping[​](#asset-grouping "Direct link to Asset Grouping") Group multiple assets together to create variants, versions, or related content collections. ### Creating Asset Groups[​](#creating-asset-groups "Direct link to Creating Asset Groups") Group assets under a parent asset to create a cohesive set: ``` curl -X POST "https://api.playbook.com/v1/my-org/assets/group_assets" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "parent_asset_token": "hero-image-main", "child_asset_tokens": ["hero-variant-1", "hero-variant-2", "hero-variant-3"] }' ``` **Response:** ``` { "data": { "id": 123, "token": "hero-image-main", "title": "Hero Image", "is_group": true, "group_id": null } } ``` ### Use Cases for Asset Groups[​](#use-cases-for-asset-groups "Direct link to Use Cases for Asset Groups") **1. Image Variants** * Group different sizes/formats of the same image * Mobile, desktop, and retina versions * Different color variations **2. Version Control** * Track iterations of design work * Group drafts with final versions * Maintain revision history **3. Related Content** * Package assets that belong together * Group photos from the same shoot * Organize components of a larger project ### Ungrouping Assets[​](#ungrouping-assets "Direct link to Ungrouping Assets") Remove assets from their groups when you need to separate them: ``` curl -X POST "https://api.playbook.com/v1/my-org/assets/ungroup_assets" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "child_asset_tokens": ["hero-variant-1", "hero-variant-2"] }' ``` **Response:** ``` { "data": [ { "id": 124, "token": "hero-variant-1", "is_group": false, "group_id": null }, { "id": 125, "token": "hero-variant-2", "is_group": false, "group_id": null } ] } ``` ### Retrieving Group Children[​](#retrieving-group-children "Direct link to Retrieving Group Children") Get all assets within a group: ``` curl "https://api.playbook.com/v1/my-org/assets/hero-image-main/children" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response:** ``` { "data": [ { "id": 124, "token": "hero-variant-1", "title": "Hero Mobile", "group_id": 123, "group_sort": 1 }, { "id": 125, "token": "hero-variant-2", "title": "Hero Desktop", "group_id": 123, "group_sort": 2 } ], "pagy": { "current_page": 1, "total_count": 2 } } ``` *** ## Bulk Move and Copy[​](#bulk-move-and-copy "Direct link to Bulk Move and Copy") When you need to relocate or duplicate many assets at once, use the dedicated bulk endpoints instead of looping over single-asset updates. Both accept the same shape: an array of asset `tokens` and a destination `collection_token`. ### Bulk Move[​](#bulk-move "Direct link to Bulk Move") `move_assets` is synchronous. The request returns once all assets have been moved. ``` curl -X POST "https://api.playbook.com/v1/my-org/assets/move_assets" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "tokens": ["asset-tok-1", "asset-tok-2", "asset-tok-3"], "collection_token": "destination-board" }' ``` Response: `200 OK`. Errors: * `403` — the caller is a viewer trying to move assets they don't own. * `404` — destination board does not exist. * `406` — `tokens` array is empty. * `422` — too many tokens for one request. ### Bulk Copy[​](#bulk-copy "Direct link to Bulk Copy") `copy_assets` is **asynchronous** and returns `202 Accepted` immediately. Playbook copies the assets in a background job; poll the destination board to observe new assets appearing, or subscribe to webhook events to be notified. ``` curl -X POST "https://api.playbook.com/v1/my-org/assets/copy_assets" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "tokens": ["asset-tok-1", "asset-tok-2"], "collection_token": "destination-board" }' ``` Response: `202 Accepted`. Same error semantics as `move_assets`, except no `403` (copying does not change the source). ### Move vs. Copy at a glance[​](#move-vs-copy-at-a-glance "Direct link to Move vs. Copy at a glance") | Behavior | `move_assets` | `copy_assets` | | --------------------- | ---------------------- | ------------------------------- | | Source assets removed | Yes | No | | Sync vs async | Synchronous (200) | Async (202; background worker) | | Permission required | Owner of source assets | Read on source, write on target | *** ## Tagging Assets[​](#tagging-assets "Direct link to Tagging Assets") Tags help categorize and filter assets for better organization. ### Adding Tags to Assets[​](#adding-tags-to-assets "Direct link to Adding Tags to Assets") ``` curl -X POST "https://api.playbook.com/v1/my-org/assets/logo-png/change_tags" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '["branding", "logo", "approved", "v2"]' ``` **Response:** ``` { "data": { "id": 200, "token": "logo-png", "tags": ["branding", "logo", "approved", "v2"] } } ``` ### Tag Management Best Practices[​](#tag-management-best-practices "Direct link to Tag Management Best Practices") **1. Consistent Naming** ``` // Good: lowercase, hyphenated ["brand-assets", "social-media", "high-priority"] // Avoid: mixed case, spaces ["Brand Assets", "social_media", "High Priority"] ``` **2. Hierarchical Tags** ``` // Organize with prefixes ["status:approved", "status:needs-review", "status:archived"] ["category:logo", "category:icon", "category:banner"] ["project:website", "project:mobile-app"] ``` **3. Tag-Based Workflows** ``` # Batch update multiple assets for token in logo-main logo-alt logo-mobile; do curl -X POST "https://api.playbook.com/v1/my-org/assets/$token/change_tags" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '["branding", "approved", "q4-2024"]' done ``` ### Searching by Tags[​](#searching-by-tags "Direct link to Searching by Tags") Use the search endpoint with tag filters: ``` curl "https://api.playbook.com/v1/my-org/search?query=&filters[tags][]=branding&filters[tags][]=approved&filters[tags_op]=and" \ -H "Authorization: Bearer YOUR_TOKEN" ``` *** ## Permalinks[​](#permalinks "Direct link to Permalinks") Create permanent, shareable URLs for assets that don't expire. ### Creating Permalinks[​](#creating-permalinks "Direct link to Creating Permalinks") ``` curl -X POST "https://api.playbook.com/v1/my-org/assets/add_permalinks" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '["logo-png", "banner-jpg", "icon-svg"]' ``` **Response:** ``` { "data": [ { "id": 200, "token": "logo-png", "permalink": "https://playbook.com/p/abc123def" }, { "id": 201, "token": "banner-jpg", "permalink": "https://playbook.com/p/def456ghi" }, { "id": 202, "token": "icon-svg", "permalink": "https://playbook.com/p/ghi789jkl" } ] } ``` ### Permalink Use Cases[​](#permalink-use-cases "Direct link to Permalink Use Cases") **1. External Integrations** ``` // Store permalinks in your CMS const asset = { id: 'header-image', playbookPermalink: 'https://playbook.com/p/abc123def', displayName: 'Header Background' }; ``` **2. Email Campaigns** ``` Product Banner ``` **3. Documentation** ``` ![Company Logo](https://playbook.com/p/def456ghi) ``` ### Removing Permalinks[​](#removing-permalinks "Direct link to Removing Permalinks") ``` curl -X POST "https://api.playbook.com/v1/my-org/assets/remove_permalinks" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '["logo-png", "banner-jpg"]' ``` *** ## Sharing Assets[​](#sharing-assets "Direct link to Sharing Assets") Create temporary or password-protected shared links for specific assets. ### Creating a Shared Link[​](#creating-a-shared-link "Direct link to Creating a Shared Link") ``` curl -X POST "https://api.playbook.com/v1/my-org/assets/design-mockup/share" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response:** ``` { "data": { "url": "https://playbook.com/s/xyz789abc/design-mockup" } } ``` ### Share vs. Permalink[​](#share-vs-permalink "Direct link to Share vs. Permalink") | Feature | Shared Link | Permalink | | ----------------------- | ------------------------- | -------------------- | | **Expiration** | Can be revoked | Permanent | | **Password Protection** | Yes (via UI) | No | | **Analytics** | View tracking | Basic metrics | | **Best For** | Client reviews, approvals | Long-term references | *** ## Deleting Assets[​](#deleting-assets "Direct link to Deleting Assets") Remove assets from your organization permanently. ### Delete a Single Asset[​](#delete-a-single-asset "Direct link to Delete a Single Asset") ``` curl -X DELETE "https://api.playbook.com/v1/my-org/assets/logo-png" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response:** `204 No Content` ### Batch Deletion[​](#batch-deletion "Direct link to Batch Deletion") ``` for token in old-banner outdated-logo draft-icon; do curl -X DELETE "https://api.playbook.com/v1/my-org/assets/$token" \ -H "Authorization: Bearer YOUR_TOKEN" done ``` ### Important Notes[​](#important-notes "Direct link to Important Notes") * Deleted assets cannot be recovered via the API. * Deleting a group parent does not automatically delete its children — ungroup first if needed. * Assets in shared or published boards can still be deleted by authorized users. *** ## Complete Asset Management Workflow[​](#complete-asset-management-workflow "Direct link to Complete Asset Management Workflow") Here's a complete example of managing assets for a product launch: ``` const PLAYBOOK_API = 'https://api.playbook.com'; const ACCESS_TOKEN = 'your_token_here'; const ORG_SLUG = 'my-org'; async function manageLaunchAssets() { // 1. Upload product images const uploadedAssets = await uploadProductImages(); // 2. Group variants together await fetch(`${PLAYBOOK_API}/v1/${ORG_SLUG}/assets/group_assets`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ACCESS_TOKEN}`, }, body: JSON.stringify({ parent_asset_token: uploadedAssets.main, child_asset_tokens: uploadedAssets.variants }) }); // 3. Tag for organization const tags = ['product-launch', 'q4-2024', 'approved']; for (const token of [uploadedAssets.main, ...uploadedAssets.variants]) { await fetch(`${PLAYBOOK_API}/v1/${ORG_SLUG}/assets/${token}/change_tags`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ACCESS_TOKEN}`, }, body: JSON.stringify(tags) }); } // 4. Create permalinks for marketing team const permalinkResponse = await fetch( `${PLAYBOOK_API}/v1/${ORG_SLUG}/assets/add_permalinks`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ACCESS_TOKEN}`, }, body: JSON.stringify([uploadedAssets.main]) } ); const { data } = await permalinkResponse.json(); console.log('Permalink created:', data[0].permalink); // 5. Share with external reviewers const shareResponse = await fetch( `${PLAYBOOK_API}/v1/${ORG_SLUG}/assets/${uploadedAssets.main}/share`, { method: 'POST', headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ); const shareData = await shareResponse.json(); console.log('Share link:', shareData.data.url); } ``` *** ## Error Handling[​](#error-handling "Direct link to Error Handling") ### Common Errors[​](#common-errors "Direct link to Common Errors") **422: Cannot group asset already in a group** ``` { "error": "Cannot group into an asset that is already part of a group" } ``` Solution: Ungroup the child assets first. **422: Permalink limit exceeded** ``` { "error": "Permalink limit exceeded for your plan" } ``` Solution: Remove unused permalinks or upgrade your plan. **404: Assets not found** ``` { "error": "Child assets not found" } ``` Solution: Verify asset tokens are correct and accessible. *** ## Best Practices[​](#best-practices "Direct link to Best Practices") 1. **Group Thoughtfully**: Don't over-group. Keep groups to logically related assets. 2. **Tag Consistently**: Establish tagging conventions early and document them. 3. **Permalinks for Stability**: Use permalinks for external references that need to remain stable. 4. **Shared Links for Collaboration**: Use shared links for temporary external access. 5. **Audit Regularly**: Periodically review and clean up unused groups, tags, and permalinks. *** ## Related API Endpoints[​](#related-api-endpoints "Direct link to Related API Endpoints") * [Group Assets](/docs/api/group-assets.md) * [Ungroup Assets](/docs/api/ungroup-assets.md) * [Move Assets](/docs/api/move-assets.md) * [Copy Assets](/docs/api/copy-assets.md) * [Change Tags](/docs/api/change-asset-tags.md) * [Add Permalinks](/docs/api/create-asset-permalinks.md) * [Remove Permalinks](/docs/api/delete-asset-permalinks.md) * [Share Asset](/docs/api/share-organization-asset.md) * [Asset Children](/docs/api/asset-children.md) *** ## Next Steps[​](#next-steps "Direct link to Next Steps") * Learn about [Custom Fields](/docs/guides/custom_fields.md) for advanced metadata * Explore [Webhooks](/docs/guides/webhooks.md) to automate asset workflows * Read about [AI Search](/docs/guides/ai_search.md) for intelligent asset discovery --- # Comments and Collaboration Learn how to work with comments on assets and boards for team collaboration and feedback. ## Overview[​](#overview "Direct link to Overview") Comments enable team collaboration directly on assets and boards. They support: * Threaded discussions * @mentions (via UI) * Comment resolution * Timestamps and authorship *** ## Prerequisites[​](#prerequisites "Direct link to Prerequisites") * **Access Token**: API token with read permissions * **Organization Slug**: Your organization identifier * **Asset/Board Tokens**: IDs of content to comment on *** ## Viewing Asset Comments[​](#viewing-asset-comments "Direct link to Viewing Asset Comments") Get all comments for a specific asset: ``` curl "https://api.playbook.com/v1/my-org/assets/product-photo/comments?page=1&per_page=20" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response:** ``` { "data": [ { "token": "comment-abc123", "description": "Love the lighting in this shot!", "user_token": "user-xyz789", "created_at": "2024-10-09T14:30:00Z", "updated_at": "2024-10-09T14:30:00Z", "resolved_at": null, "replies": [ { "token": "comment-def456", "description": "Thanks! Used a softbox for the effect", "user_token": "user-abc123", "created_at": "2024-10-09T15:00:00Z", "updated_at": "2024-10-09T15:00:00Z", "resolved_at": null, "replies": [] } ] } ], "pagy": { "current_page": 1, "page_items": 1, "total_pages": 1, "total_count": 1 } } ``` *** ## Viewing Board Comments[​](#viewing-board-comments "Direct link to Viewing Board Comments") Get all comments for a board: ``` curl "https://api.playbook.com/v1/my-org/boards/main-collection/comments" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response structure is similar to asset comments.** *** ## Working with Comment Threads[​](#working-with-comment-threads "Direct link to Working with Comment Threads") ### Understanding Replies[​](#understanding-replies "Direct link to Understanding Replies") Comments can have nested replies: ``` Comment (parent) └── Reply 1 (child) └── Reply to Reply 1 (grandchild) └── Reply 2 (child) ``` ### Accessing Replies[​](#accessing-replies "Direct link to Accessing Replies") ``` async function getAllCommentsFlat(assetToken) { const response = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/comments`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ); const { data } = await response.json(); // Flatten replies const allComments = []; function flatten(comments, depth = 0) { for (const comment of comments) { allComments.push({ ...comment, depth }); if (comment.replies?.length > 0) { flatten(comment.replies, depth + 1); } } } flatten(data); return allComments; } ``` *** ## Use Cases[​](#use-cases "Direct link to Use Cases") ### 1. Review and Approval Workflow[​](#1-review-and-approval-workflow "Direct link to 1. Review and Approval Workflow") Display comments in a review interface: ``` class ReviewInterface { async loadAssetForReview(assetToken) { // Get asset details const asset = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ).then(r => r.json()); // Get comments const comments = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/comments`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ).then(r => r.json()); return { asset: asset.data, comments: comments.data, status: asset.data.fields?.['Approval Status'], needsResolution: comments.data.some(c => !c.resolved_at) }; } async canApprove(assetToken) { const review = await this.loadAssetForReview(assetToken); // Check all comments are resolved return !review.needsResolution; } } ``` ### 2. Comment Notifications[​](#2-comment-notifications "Direct link to 2. Comment Notifications") Notify users when comments are added: ``` async function checkNewComments(assetToken, lastCheck) { const response = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/comments`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ); const { data } = await response.json(); // Find comments since last check const newComments = []; function findNew(comments) { for (const comment of comments) { const commentDate = new Date(comment.created_at); if (commentDate > lastCheck) { newComments.push(comment); } if (comment.replies) { findNew(comment.replies); } } } findNew(data); return newComments; } // Usage const lastChecked = new Date('2024-10-09T12:00:00Z'); const newComments = await checkNewComments('product-photo', lastChecked); if (newComments.length > 0) { console.log(`${newComments.length} new comments`); // Send notification } ``` ### 3. Comment Analytics[​](#3-comment-analytics "Direct link to 3. Comment Analytics") Track comment activity: ``` class CommentAnalytics { async getCommentStats(assetToken) { const response = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/comments`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ); const { data } = await response.json(); const stats = { totalComments: 0, resolvedComments: 0, unresolvedComments: 0, totalReplies: 0, uniqueUsers: new Set(), commentsByUser: {}, averageResponseTime: 0 }; function analyze(comments, isReply = false) { for (const comment of comments) { if (isReply) { stats.totalReplies++; } else { stats.totalComments++; } if (comment.resolved_at) { stats.resolvedComments++; } else { stats.unresolvedComments++; } stats.uniqueUsers.add(comment.user_token); stats.commentsByUser[comment.user_token] = (stats.commentsByUser[comment.user_token] || 0) + 1; if (comment.replies) { analyze(comment.replies, true); } } } analyze(data); stats.uniqueUsers = stats.uniqueUsers.size; return stats; } async getMostActiveAssets(assetTokens) { const results = await Promise.all( assetTokens.map(async token => ({ token, stats: await this.getCommentStats(token) })) ); return results.sort((a, b) => b.stats.totalComments - a.stats.totalComments ); } } ``` ### 4. Export Comments[​](#4-export-comments "Direct link to 4. Export Comments") Export comments for reporting or archival: ``` async function exportCommentsToCSV(assetToken) { const response = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/comments`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ); const { data } = await response.json(); const rows = [ ['Comment ID', 'User', 'Text', 'Created', 'Resolved', 'Depth'] ]; function addRows(comments, depth = 0) { for (const comment of comments) { rows.push([ comment.token, comment.user_token, comment.description, comment.created_at, comment.resolved_at || 'No', depth ]); if (comment.replies) { addRows(comment.replies, depth + 1); } } } addRows(data); return rows.map(row => row.join(',')).join('\n'); } // Usage const csv = await exportCommentsToCSV('product-photo'); console.log(csv); ``` *** ## Pagination[​](#pagination "Direct link to Pagination") Handle large comment threads with pagination: ``` async function getAllComments(assetToken) { let allComments = []; let page = 1; let hasMore = true; while (hasMore) { const response = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/comments?` + `page=${page}&per_page=50`, { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ); const { data, pagy } = await response.json(); allComments = allComments.concat(data); hasMore = page < pagy.total_pages; page++; } return allComments; } ``` *** ## Comment Resolution Tracking[​](#comment-resolution-tracking "Direct link to Comment Resolution Tracking") Track which comments have been resolved: ``` class CommentResolutionTracker { async getUnresolvedComments(assetToken) { const comments = await getAllComments(assetToken); const unresolved = []; function findUnresolved(commentList) { for (const comment of commentList) { if (!comment.resolved_at) { unresolved.push(comment); } if (comment.replies) { findUnresolved(comment.replies); } } } findUnresolved(comments); return unresolved; } async generateResolutionReport(assetTokens) { const report = {}; for (const token of assetTokens) { const unresolved = await this.getUnresolvedComments(token); report[token] = { unresolvedCount: unresolved.length, comments: unresolved }; } return report; } async assetsNeedingAttention(assetTokens, threshold = 5) { const report = await this.generateResolutionReport(assetTokens); return Object.entries(report) .filter(([_, data]) => data.unresolvedCount >= threshold) .map(([token, data]) => ({ token, ...data })); } } // Usage const tracker = new CommentResolutionTracker(); const needsAttention = await tracker.assetsNeedingAttention( ['asset-1', 'asset-2', 'asset-3'], 3 // Alert if 3+ unresolved comments ); console.log('Assets needing attention:', needsAttention); ``` *** ## Complete Comment Management System[​](#complete-comment-management-system "Direct link to Complete Comment Management System") Here's a full implementation: ``` class CommentManager { constructor(orgSlug, accessToken) { this.orgSlug = orgSlug; this.accessToken = accessToken; this.baseUrl = 'https://api.playbook.com/v1'; } async getAssetComments(assetToken, options = {}) { const params = new URLSearchParams({ page: options.page || 1, per_page: options.perPage || 20 }); const response = await fetch( `${this.baseUrl}/${this.orgSlug}/assets/${assetToken}/comments?${params}`, { headers: { Authorization: `Bearer ${this.accessToken}` } } ); return await response.json(); } async getBoardComments(boardToken, options = {}) { const params = new URLSearchParams({ page: options.page || 1, per_page: options.perPage || 20 }); const response = await fetch( `${this.baseUrl}/${this.orgSlug}/boards/${boardToken}/comments?${params}`, { headers: { Authorization: `Bearer ${this.accessToken}` } } ); return await response.json(); } async getAllCommentsFlat(resourceType, token) { const getComments = resourceType === 'asset' ? this.getAssetComments.bind(this) : this.getBoardComments.bind(this); let allComments = []; let page = 1; while (true) { const { data, pagy } = await getComments(token, { page }); // Flatten comments and replies const flatComments = this.flattenComments(data); allComments = allComments.concat(flatComments); if (page >= pagy.total_pages) break; page++; } return allComments; } flattenComments(comments, depth = 0, parentToken = null) { const result = []; for (const comment of comments) { result.push({ ...comment, depth, parentToken, hasReplies: comment.replies && comment.replies.length > 0 }); if (comment.replies) { const replies = this.flattenComments( comment.replies, depth + 1, comment.token ); result.push(...replies); } } return result; } async getCommentStatistics(resourceType, token) { const comments = await this.getAllCommentsFlat(resourceType, token); const stats = { total: comments.length, topLevel: comments.filter(c => c.depth === 0).length, replies: comments.filter(c => c.depth > 0).length, resolved: comments.filter(c => c.resolved_at).length, unresolved: comments.filter(c => !c.resolved_at).length, users: [...new Set(comments.map(c => c.user_token))].length, avgDepth: comments.reduce((sum, c) => sum + c.depth, 0) / comments.length || 0, oldestUnresolved: this.findOldestUnresolved(comments) }; return stats; } findOldestUnresolved(comments) { const unresolved = comments.filter(c => !c.resolved_at); if (unresolved.length === 0) return null; return unresolved.reduce((oldest, current) => new Date(current.created_at) < new Date(oldest.created_at) ? current : oldest ); } async generateReport(resourceType, tokens) { const report = []; for (const token of tokens) { const stats = await this.getCommentStatistics(resourceType, token); report.push({ token, ...stats }); } return report; } } // Usage const manager = new CommentManager('my-org', 'your_token'); // Get all comments for an asset const comments = await manager.getAllCommentsFlat('asset', 'product-photo'); console.log(`Found ${comments.length} total comments`); // Get statistics const stats = await manager.getCommentStatistics('asset', 'product-photo'); console.log('Comment stats:', stats); // Generate report for multiple assets const report = await manager.generateReport('asset', [ 'product-photo', 'banner-image', 'logo-design' ]); console.log('Report:', report); ``` *** ## Best Practices[​](#best-practices "Direct link to Best Practices") ### 1. Monitor Unresolved Comments[​](#1-monitor-unresolved-comments "Direct link to 1. Monitor Unresolved Comments") Set up alerts for assets with many unresolved comments: ``` async function checkUnresolvedAlerts() { const assets = await getAssetsInReview(); for (const asset of assets) { const stats = await manager.getCommentStatistics('asset', asset.token); if (stats.unresolved > 5) { await sendAlert({ to: 'team@company.com', subject: `Action needed: ${asset.title}`, body: `This asset has ${stats.unresolved} unresolved comments.` }); } } } ``` ### 2. Track Response Times[​](#2-track-response-times "Direct link to 2. Track Response Times") Measure how quickly teams respond to comments: ``` function calculateResponseTime(comments) { const topLevel = comments.filter(c => c.depth === 0); const responseTimes = topLevel .filter(c => c.hasReplies) .map(c => { const firstReply = c.replies[0]; const parentTime = new Date(c.created_at); const replyTime = new Date(firstReply.created_at); return replyTime - parentTime; }); const avgResponseTime = responseTimes.reduce((sum, t) => sum + t, 0) / responseTimes.length; return { average: avgResponseTime / 1000 / 60, // minutes count: responseTimes.length }; } ``` ### 3. Archive Old Threads[​](#3-archive-old-threads "Direct link to 3. Archive Old Threads") Export resolved comment threads for archival: ``` async function archiveResolvedComments(assetToken, olderThan) { const comments = await manager.getAllCommentsFlat('asset', assetToken); const toArchive = comments.filter(c => c.resolved_at && new Date(c.resolved_at) < olderThan ); if (toArchive.length > 0) { await saveToArchive(assetToken, toArchive); console.log(`Archived ${toArchive.length} comments`); } } ``` *** ## Related API Endpoints[​](#related-api-endpoints "Direct link to Related API Endpoints") * [Asset Comments](/docs/api/asset-comments.md) * [Board Comments](/docs/api/get-organization-collection-comments.md) *** ## Next Steps[​](#next-steps "Direct link to Next Steps") * Learn about [Sharing and Publishing](/docs/guides/sharing_and_publishing.md) to share assets with external reviewers * Explore [Webhooks](/docs/guides/webhooks.md) to get notified of new comments * Read about [Asset Management](/docs/guides/asset_management.md) for organizing commented assets --- # Create Boards in an Organization Use this guide to add new boards inside an existing Playbook organization. ## Prerequisites[​](#prerequisites "Direct link to Prerequisites") 1. **Access token**: A valid Playbook API token with permissions to manage boards, sent as a Bearer token. 2. **Organization Slug** (`slug`): The URL-friendly identifier for your organization (e.g., `coolclient-ltd`). ## Endpoint & Authentication[​](#endpoint--authentication "Direct link to Endpoint & Authentication") ``` POST /v1/{slug}/boards Authorization: Bearer YOUR_TOKEN Content-Type: application/json ``` ## Request Payload[​](#request-payload "Direct link to Request Payload") | Field | Type | Required | Description | | ------------------------- | ------ | -------- | ----------------------------------------------- | | title | string | yes | Name of the new board. | | description | string | no | Optional description for it. | | parent\_collection\_token | string | no | Token of a parent board, to create a sub-board. | ### Sample Request — Top-Level Board[​](#sample-request--top-level-board "Direct link to Sample Request — Top-Level Board") ``` curl -X POST https://api.playbook.com/v1/coolclient-ltd/boards \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Marketing Assets", "description": "Images and banners for marketing" }' ``` ### Sample Request — Sub-Board[​](#sample-request--sub-board "Direct link to Sample Request — Sub-Board") ``` curl -X POST https://api.playbook.com/v1/coolclient-ltd/boards \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "title": "Social Media", "parent_collection_token": "marketing-assets" }' ``` ## Sample Response[​](#sample-response "Direct link to Sample Response") ``` HTTP/1.1 200 OK Content-Type: application/json { "data": { "id": 124, "token": "social-media", "title": "Social Media", "created_at": "2023-07-10T12:34:56Z", "updated_at": "2023-07-10T12:34:56Z", "parent_id": 123 } } ``` ## Error Handling[​](#error-handling "Direct link to Error Handling") * `401 Unauthorized`: Missing or invalid token. * `422 Unprocessable Entity`: Validation errors (e.g., missing title). * `404 Not Found`: The specified `slug` does not exist. ## Next Steps[​](#next-steps "Direct link to Next Steps") * Use the new `token` from the response when uploading assets into this board. --- # Custom Fields Add structured metadata to your assets with custom fields and predefined options. ## Overview[​](#overview "Direct link to Overview") Custom fields allow you to add standardized metadata to assets beyond the default title, description, and tags. They're perfect for: * Status tracking (Draft, Review, Approved) * Project categorization * Rights management * Asset attributes (Season, Collection, Style) * Workflow states *** ## Prerequisites[​](#prerequisites "Direct link to Prerequisites") * **Access Token**: API token with admin permissions * **Organization Slug**: Your organization identifier *** ## Creating Custom Fields[​](#creating-custom-fields "Direct link to Creating Custom Fields") ### Basic Custom Field[​](#basic-custom-field "Direct link to Basic Custom Field") Create a custom field with predefined options: ``` curl -X POST "https://api.playbook.com/v1/my-org/custom_fields" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "field": { "name": "Approval Status", "options": ["Draft", "In Review", "Approved", "Rejected"] } }' ``` **Response:** ``` { "data": { "name": "Approval Status", "token": "field-abc123", "options": [ { "name": "Draft", "token": "opt-draft-xyz" }, { "name": "In Review", "token": "opt-review-abc" }, { "name": "Approved", "token": "opt-approved-def" }, { "name": "Rejected", "token": "opt-rejected-ghi" } ] } } ``` *** ## Listing Custom Fields[​](#listing-custom-fields "Direct link to Listing Custom Fields") Get all custom fields in your organization: ``` curl "https://api.playbook.com/v1/my-org/custom_fields" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response:** ``` { "data": [ { "name": "Approval Status", "token": "field-abc123", "options": [ { "name": "Draft", "token": "opt-draft-xyz" }, { "name": "Approved", "token": "opt-approved-def" } ] }, { "name": "Season", "token": "field-def456", "options": [ { "name": "Spring/Summer", "token": "opt-ss-abc" }, { "name": "Fall/Winter", "token": "opt-fw-def" } ] } ], "pagy": { "current_page": 1, "total_count": 2 } } ``` *** ## Assigning Field Values to Assets[​](#assigning-field-values-to-assets "Direct link to Assigning Field Values to Assets") Once fields are created, assign values when creating or updating assets: ### On Asset Creation[​](#on-asset-creation "Direct link to On Asset Creation") ``` curl -X POST "https://api.playbook.com/v1/my-org/assets" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "uri": "https://example.com/product.jpg", "title": "Product Photo", "fields": { "Approval Status": "Draft", "Season": "Spring/Summer" } }' ``` ### On Asset Update[​](#on-asset-update "Direct link to On Asset Update") ``` curl -X PATCH "https://api.playbook.com/v1/my-org/assets/product-photo/update" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "asset": { "fields": { "Approval Status": "Approved" } } }' ``` **Response:** ``` { "data": { "id": 123, "token": "product-photo", "title": "Product Photo", "fields": { "Approval Status": "Approved", "Season": "Spring/Summer" } } } ``` *** ## Deleting Custom Fields[​](#deleting-custom-fields "Direct link to Deleting Custom Fields") Remove custom fields when they're no longer needed: ``` curl -X DELETE "https://api.playbook.com/v1/my-org/custom_fields/field-abc123" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Note:** Deleting a field removes it from all assets. *** ## Use Cases[​](#use-cases "Direct link to Use Cases") ### 1. Approval Workflow[​](#1-approval-workflow "Direct link to 1. Approval Workflow") Track asset approval status: ``` // Create approval workflow field const approvalField = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/custom_fields`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ACCESS_TOKEN}`, }, body: JSON.stringify({ field: { name: 'Approval Status', options: ['Draft', 'Pending Review', 'Approved', 'Rejected', 'Needs Revision'] } }) } ).then(r => r.json()); // Move asset through workflow async function updateApprovalStatus(assetToken, status) { return await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ACCESS_TOKEN}`, }, body: JSON.stringify({ asset: { fields: { 'Approval Status': status } } }) } ).then(r => r.json()); } ``` ### 2. Rights Management[​](#2-rights-management "Direct link to 2. Rights Management") Track usage rights and licenses: ``` curl -X POST "https://api.playbook.com/v1/my-org/custom_fields" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "field": { "name": "Usage Rights", "options": [ "Full Rights", "Editorial Only", "Web Only", "Print Only", "Limited License", "Expired" ] } }' ``` ### 3. Project Organization[​](#3-project-organization "Direct link to 3. Project Organization") Organize assets by project or campaign: ``` curl -X POST "https://api.playbook.com/v1/my-org/custom_fields" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "field": { "name": "Project", "options": [ "Q4 Campaign", "Website Redesign", "Mobile App", "Social Media", "Print Catalog" ] } }' ``` ### 4. Asset Attributes[​](#4-asset-attributes "Direct link to 4. Asset Attributes") Track specific characteristics: ``` // Color palette field await createCustomField('Color Palette', [ 'Warm Tones', 'Cool Tones', 'Monochrome', 'Vibrant', 'Pastel' ]); // Style field await createCustomField('Style', [ 'Minimalist', 'Modern', 'Vintage', 'Industrial', 'Rustic' ]); // Orientation field await createCustomField('Orientation', [ 'Landscape', 'Portrait', 'Square' ]); ``` *** ## Searching by Custom Fields[​](#searching-by-custom-fields "Direct link to Searching by Custom Fields") Retrieve assets filtered by custom field values: ``` # Get all approved assets curl "https://api.playbook.com/v1/my-org/search?query=&filters[fields][Approval Status]=Approved" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Note:** The search endpoint filters by custom field names and values. Combine with other filters for precise results. *** ## Complete Implementation Example[​](#complete-implementation-example "Direct link to Complete Implementation Example") Here's a full custom fields management system: ``` class CustomFieldsManager { constructor(orgSlug, accessToken) { this.orgSlug = orgSlug; this.accessToken = accessToken; this.baseUrl = 'https://api.playbook.com/v1'; } async createField(name, options) { const response = await fetch( `${this.baseUrl}/${this.orgSlug}/custom_fields`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.accessToken}`, }, body: JSON.stringify({ field: { name, options } }) } ); if (!response.ok) { throw new Error(`Failed to create field: ${response.statusText}`); } return await response.json(); } async listFields() { const response = await fetch( `${this.baseUrl}/${this.orgSlug}/custom_fields`, { headers: { Authorization: `Bearer ${this.accessToken}` } } ); if (!response.ok) { throw new Error(`Failed to list fields: ${response.statusText}`); } return await response.json(); } async deleteField(fieldToken) { const response = await fetch( `${this.baseUrl}/${this.orgSlug}/custom_fields/${fieldToken}`, { method: 'DELETE', headers: { Authorization: `Bearer ${this.accessToken}` } } ); if (!response.ok) { throw new Error(`Failed to delete field: ${response.statusText}`); } return response.status === 204; } async updateAssetFields(assetToken, fields) { const response = await fetch( `${this.baseUrl}/${this.orgSlug}/assets/${assetToken}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.accessToken}`, }, body: JSON.stringify({ asset: { fields } }) } ); if (!response.ok) { throw new Error(`Failed to update asset fields: ${response.statusText}`); } return await response.json(); } async setupWorkflow() { // Create multiple related fields const approvalStatus = await this.createField('Approval Status', [ 'Draft', 'In Review', 'Approved', 'Rejected' ]); const priority = await this.createField('Priority', [ 'Low', 'Medium', 'High', 'Urgent' ]); const project = await this.createField('Project', [ 'Q4 Campaign', 'Website', 'Mobile App', 'Print' ]); return { approvalStatus, priority, project }; } } // Usage const manager = new CustomFieldsManager('my-org', 'your_token'); // Setup fields await manager.setupWorkflow(); // Update an asset await manager.updateAssetFields('asset-token-123', { 'Approval Status': 'Approved', 'Priority': 'High', 'Project': 'Q4 Campaign' }); // List all fields const fields = await manager.listFields(); console.log('Available fields:', fields.data); ``` *** ## Best Practices[​](#best-practices "Direct link to Best Practices") ### 1. Plan Your Field Structure[​](#1-plan-your-field-structure "Direct link to 1. Plan Your Field Structure") **Before creating fields, consider:** * What workflows do you need to support? * What metadata is actually useful? * How will users interact with these fields? **Example planning:** ``` const fieldPlan = { 'Approval Status': { purpose: 'Track review workflow', options: ['Draft', 'In Review', 'Approved', 'Rejected'], requiredFor: ['client-facing assets'] }, 'Usage Rights': { purpose: 'Manage licensing', options: ['Full Rights', 'Editorial Only', 'Licensed'], requiredFor: ['all external assets'] }, 'Project': { purpose: 'Organize by campaign', options: ['Q4 Campaign', 'Website', 'Social'], requiredFor: ['campaign assets'] } }; ``` ### 2. Keep Options Manageable[​](#2-keep-options-manageable "Direct link to 2. Keep Options Manageable") **Good:** ``` // Clear, distinct options ['Draft', 'Review', 'Approved', 'Archived'] ``` **Avoid:** ``` // Too many options ['Draft 1', 'Draft 2', 'Draft 3', 'Review 1', 'Review 2'...] // Use tags or version control instead ``` ### 3. Standardize Naming[​](#3-standardize-naming "Direct link to 3. Standardize Naming") **Consistent:** ``` 'Approval Status' // Title Case 'Usage Rights' // Title Case 'Project' // Title Case ``` **Inconsistent:** ``` 'approval_status' // snake_case 'USAGE-RIGHTS' // SCREAMING-KEBAB 'project' // lowercase ``` ### 4. Document Field Usage[​](#4-document-field-usage "Direct link to 4. Document Field Usage") ``` const fieldDocumentation = { 'Approval Status': { description: 'Current approval state of the asset', options: { 'Draft': 'Asset is work in progress', 'In Review': 'Submitted for approval', 'Approved': 'Ready for use', 'Rejected': 'Needs revision' }, workflow: 'Draft → In Review → Approved/Rejected' } }; ``` *** ## Advanced Patterns[​](#advanced-patterns "Direct link to Advanced Patterns") ### Batch Field Assignment[​](#batch-field-assignment "Direct link to Batch Field Assignment") Update multiple assets at once: ``` async function batchUpdateFields(assetTokens, fields) { const updates = assetTokens.map(token => fetch( `https://api.playbook.com/v1/${ORG_SLUG}/assets/${token}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ACCESS_TOKEN}`, }, body: JSON.stringify({ asset: { fields } }) } ) ); return await Promise.all(updates); } // Usage await batchUpdateFields( ['asset-1', 'asset-2', 'asset-3'], { 'Approval Status': 'Approved', 'Project': 'Q4 Campaign' } ); ``` ### Field-Based Automation[​](#field-based-automation "Direct link to Field-Based Automation") Create rules based on field values: ``` async function autoTagByFields(asset) { const { fields, token } = asset; // Auto-tag based on field values const autoTags = []; if (fields['Approval Status'] === 'Approved') { autoTags.push('ready-to-use'); } if (fields['Priority'] === 'Urgent') { autoTags.push('high-priority'); } if (fields['Usage Rights'] === 'Full Rights') { autoTags.push('unrestricted'); } // Update asset with auto-generated tags if (autoTags.length > 0) { await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/assets/${token}/change_tags`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ACCESS_TOKEN}`, }, body: JSON.stringify([...asset.tags, ...autoTags]) } ); } } ``` ### Field Validation[​](#field-validation "Direct link to Field Validation") Validate field values before assignment: ``` class FieldValidator { constructor(fields) { this.fieldDefinitions = fields; } validate(fieldName, value) { const field = this.fieldDefinitions.find(f => f.name === fieldName); if (!field) { throw new Error(`Field "${fieldName}" does not exist`); } const validOptions = field.options.map(o => o.name); if (!validOptions.includes(value)) { throw new Error( `Invalid value "${value}" for field "${fieldName}". ` + `Valid options: ${validOptions.join(', ')}` ); } return true; } validateAll(fields) { const errors = []; for (const [name, value] of Object.entries(fields)) { try { this.validate(name, value); } catch (error) { errors.push(error.message); } } if (errors.length > 0) { throw new Error(`Validation errors:\n${errors.join('\n')}`); } return true; } } // Usage const fields = await manager.listFields(); const validator = new FieldValidator(fields.data); try { validator.validateAll({ 'Approval Status': 'Approved', 'Priority': 'High' }); // Proceed with update } catch (error) { console.error('Invalid fields:', error.message); } ``` *** ## Error Handling[​](#error-handling "Direct link to Error Handling") ### Common Errors[​](#common-errors "Direct link to Common Errors") **422: Options are empty** ``` { "error": "Options cannot be empty" } ``` Solution: Provide at least one option when creating a field. **404: Field not found** ``` { "error": "Custom field not found" } ``` Solution: Verify the field token is correct. **422: Invalid field value** ``` { "error": "Invalid option for field" } ``` Solution: Ensure the value matches one of the field's defined options exactly. *** ## Related API Endpoints[​](#related-api-endpoints "Direct link to Related API Endpoints") * [List Custom Fields](/docs/api/organization-custom-fields.md) * [Create Custom Field](/docs/api/create-custom-field.md) * [Delete Custom Field](/docs/api/delete-custom-field.md) * [Update Asset](/docs/api/organization-update-asset.md) *** ## Next Steps[​](#next-steps "Direct link to Next Steps") * Learn about [Asset Management](/docs/guides/asset_management.md) to organize assets with custom fields * Explore [Webhooks](/docs/guides/webhooks.md) to automate actions based on field changes * Read about [Search](/docs/guides/search.md) to filter assets by custom fields --- # Fetching Data from the Playbook API Retrieve key resources — organizations, boards, and assets—using simple GET calls. ## Prerequisites[​](#prerequisites "Direct link to Prerequisites") 1. **Access token**: a valid Playbook API token with read permissions, sent in the `Authorization: Bearer` header. 2. **Organization Slug** (`slug`): Your organization's unique identifier. 3. **Board Token** (`board_token`): Token of a specific board (for assets endpoint). *** ## List Your Organizations[​](#list-your-organizations "Direct link to List Your Organizations") ### Endpoint[​](#endpoint "Direct link to Endpoint") ``` GET /organizations ``` ### Authentication[​](#authentication "Direct link to Authentication") Send `Authorization: Bearer YOUR_TOKEN` with the request. ### Query Parameters[​](#query-parameters "Direct link to Query Parameters") * `page` (integer, optional, default: 1) * `per_page` (integer, optional, default: 20) ### Example Request[​](#example-request "Direct link to Example Request") ``` curl "https://api.playbook.com/v1/organizations?page=1&per_page=20" \ -H "Authorization: Bearer YOUR_TOKEN" | jq ``` ### Example Response[​](#example-response "Direct link to Example Response") ``` { "data": [ { "id": 1, "slug": "coolclient-ltd", "name": "CoolClient Ltd" }, { "id": 2, "slug": "acme-corp", "name": "Acme Corp" } ], "pagy": { "current_page": 1, "page_items": 20, "total_pages": 1, "total_count": 2 } } ``` *** ## List Boards in an Organization[​](#list-boards-in-an-organization "Direct link to List Boards in an Organization") ### Endpoint[​](#endpoint-1 "Direct link to Endpoint") ``` GET /{slug}/boards ``` ### Authentication[​](#authentication-1 "Direct link to Authentication") Send `Authorization: Bearer YOUR_TOKEN` with the request. ### Query Parameters[​](#query-parameters-1 "Direct link to Query Parameters") * `page` (integer, optional) * `per_page` (integer, optional) * `depth` (integer, optional) — fetch nested children up to this level ### Example Request[​](#example-request-1 "Direct link to Example Request") ``` curl "https://api.playbook.com/v1/coolclient-ltd/boards?page=1&per_page=20" \ -H "Authorization: Bearer YOUR_TOKEN" | jq ``` ### Example Response[​](#example-response-1 "Direct link to Example Response") ``` { "data": [ { "id": 123, "token": "boardToken111", "title": "Homepage Assets" }, { "id": 124, "token": "boardToken222", "title": "Blog Banners" } ], "pagy": { "current_page": 1, "page_items": 2, "total_pages": 1, "total_count": 2 } } ``` *** ## List Assets in a Board[​](#list-assets-in-a-board "Direct link to List Assets in a Board") ### Endpoint[​](#endpoint-2 "Direct link to Endpoint") ``` GET /{slug}/boards/{board_token}/assets ``` ### Authentication[​](#authentication-2 "Direct link to Authentication") Send `Authorization: Bearer YOUR_TOKEN` with the request. ### Query Parameters[​](#query-parameters-2 "Direct link to Query Parameters") * `page` (integer, optional) * `per_page` (integer, optional) ### Example Request[​](#example-request-2 "Direct link to Example Request") ``` curl "https://api.playbook.com/v1/coolclient-ltd/boards/boardToken1234/assets?page=1&per_page=20" \ -H "Authorization: Bearer YOUR_TOKEN" | jq ``` ### Example Response[​](#example-response-2 "Direct link to Example Response") ``` { "data": [ { "id": 987, "token": "assetToken111", "title": "hero.jpg", "display_url": "https://cdn.playbook.com/hero.jpg" }, { "id": 988, "token": "assetToken222", "title": "logo_dark.png", "display_url": "https://cdn.playbook.com/logo_dark.png" } ], "pagy": { "current_page": 1, "page_items": 2, "total_pages": 1, "total_count": 2 } } ``` *** ## Error Handling[​](#error-handling "Direct link to Error Handling") * `401 Unauthorized`: invalid or missing token. * `404 Not Found`: invalid `slug` or `board_token`. * `422 Unprocessable Entity`: invalid query parameters. *** ## Tips & Next Steps[​](#tips--next-steps "Direct link to Tips & Next Steps") * Use `depth` when listing boards to fetch nested child boards in one call. * Combine these endpoints to build dashboards or synced local caches of Playbook content. --- # Connect AI Assistants via MCP Playbook ships an official **MCP (Model Context Protocol)** server that lets AI assistants — Claude Desktop, Claude Code, Cursor, ChatGPT, and any other MCP-aware client — read and modify your Playbook workspace using natural language. Ask the assistant to "find every approved logo on the Brand board," "tag these screenshots as `mobile-onboarding`," or "upload this batch of URLs to the Marketing board" and it will call the right Playbook API for you. The server is built on top of the same v1 REST API documented in this site, so anything an MCP-connected agent can do, you can also do directly via HTTP — and vice versa. ## How it works[​](#how-it-works "Direct link to How it works") ``` AI assistant ⇄ MCP client ⇄ Playbook MCP server ⇄ Playbook v1 REST API ``` 1. **MCP client** advertises a list of "tools" exposed by the Playbook MCP server. 2. When the user asks a question, the assistant decides which tool to call, fills in the arguments, and the MCP client invokes the tool over JSON-RPC. 3. The Playbook MCP server translates each tool call into one or more authenticated requests against the Playbook v1 REST API. 4. The response is filtered (large/internal fields stripped) and returned to the assistant as text the model can reason about. The server is hosted at `https://mcp.playbook.com`. Configure your MCP client with the URL and a Bearer token — no local install required. ## Authentication[​](#authentication "Direct link to Authentication") The MCP server authenticates to the Playbook API using your **Bearer token**, the same token described in [Getting Started](/docs/getting_started.md). Scopes: * `read` — required for every tool. Lets the assistant browse boards, assets, comments, documentation. * `write` — required for any mutation (uploads, moves, edits, deletes, comments, board changes). Provide a token with only `read` scope if you want a strictly read-only assistant. The MCP server enforces scopes at the API layer — if the assistant tries to call a write tool with a read-only token, the request returns 403. > **Never paste your token into a chat with the model.** Configure it once in the MCP client config as an HTTP `Authorization` header. The assistant only ever sees tool names and arguments, never the token. ## Client configuration[​](#client-configuration "Direct link to Client configuration") The Playbook MCP server speaks the **streamable HTTP transport** at `https://mcp.playbook.com`. Clients that support HTTP MCP natively (Claude Code, Cursor, ChatGPT) connect directly with a Bearer token in the `Authorization` header. Clients that only speak stdio (Claude Desktop) bridge through the [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) npx shim — also covered below. ### Claude Code[​](#claude-code "Direct link to Claude Code") The quickest way is a single command — it writes the config for you: ``` claude mcp add playbook --transport http https://mcp.playbook.com --header "Authorization: Bearer your_bearer_token_here" ``` Or add it by hand to `.mcp.json` at your project root, or to the global `~/.claude.json`: ``` { "mcpServers": { "playbook": { "type": "http", "url": "https://mcp.playbook.com", "headers": { "Authorization": "Bearer your_bearer_token_here" } } } } ``` ### Cursor[​](#cursor "Direct link to Cursor") Settings → **MCP** → "Add new MCP server" and pick the **HTTP** transport. Use the same shape: ``` { "mcpServers": { "playbook": { "type": "http", "url": "https://mcp.playbook.com", "headers": { "Authorization": "Bearer your_bearer_token_here" } } } } ``` ### ChatGPT[​](#chatgpt "Direct link to ChatGPT") Use the "Custom Connector" flow in ChatGPT settings: * **URL:** `https://mcp.playbook.com` * **Authentication:** Bearer token (paste your Playbook API token) ### Claude Desktop[​](#claude-desktop "Direct link to Claude Desktop") Claude Desktop currently only supports stdio MCP servers, so it bridges through the `mcp-remote` shim, which forwards stdio JSON-RPC to the hosted HTTP server. Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or the equivalent on Windows: ``` { "mcpServers": { "playbook": { "command": "npx", "args": [ "-y", "mcp-remote", "https://mcp.playbook.com", "--header", "Authorization: Bearer your_bearer_token_here" ] } } } ``` ## Available tools[​](#available-tools "Direct link to Available tools") All tools require the `read` scope. Tools marked **`write`** additionally require the `write` scope. ### Read tools[​](#read-tools "Direct link to Read tools") | Tool | What it does | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `list_organizations` | List the workspaces the token has access to. | | `list_boards` | List boards in a workspace, with optional search and hierarchy depth. | | `get_board` | Fetch a single board's details by token. | | `list_board_children` | List the direct child boards of a parent. | | `list_assets` | List assets, optionally scoped to a board. | | `get_asset` | Full details of one asset, including upload status (see [Async ingest semantics](#async-ingest-semantics)). | | `search_assets` | Keyword search across filename / title / tags. | | `ai_search` | AI-powered visual and semantic search. | | `get_comments` | Read comments on an asset or board. | | `list_documentation` | Index the Playbook API docs and guides available to the assistant. | | `read_documentation` | Read a specific docs page (e.g. `upload`, `webhooks`, `search`). Lets the assistant ground answers in the official docs without hallucinating. | ### Write tools — assets[​](#write-tools--assets "Direct link to Write tools — assets") | Tool | What it does | | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `upload_from_url`  **`write`** | Ingest a single asset by public URL. Returns immediately with a skeleton; Playbook fetches the bytes asynchronously. | | `upload_from_urls`  **`write`** | Ingest up to 100 assets in one call by public URL — all assets land on the same board, asynchronously. | | `move_assets`  **`write`** | Bulk-move up to 1000 assets to a target board. | | `copy_assets`  **`write`** | Bulk-copy up to 1000 assets to a target board (async). | | `change_asset_tags`  **`write`** | Add and/or remove tags on an asset. | | `update_asset_status`  **`write`** | Set an asset's status (e.g. `Approved`, `In Review`). | | `update_asset`  **`write`** | Update title, description, tags, status, or board for one asset. | | `delete_asset`  **`write`** | Soft-delete an asset (recoverable from trash). | | `share_asset`  **`write`** | Create or return the shareable link for one asset. | ### Write tools — boards[​](#write-tools--boards "Direct link to Write tools — boards") | Tool | What it does | | ---------------------------- | ----------------------------------------------------- | | `create_board`  **`write`** | Create a new board, optionally nested under a parent. | | `update_board`  **`write`** | Update a board's title, description, or parent. | | `delete_board`  **`write`** | Delete a board and all of its contents. | | `share_board`  **`write`** | Create or return the shareable link for a board. | | `publish_board`  **`write`** | Publish a board as a public page. | ### Write tools — comments[​](#write-tools--comments "Direct link to Write tools — comments") | Tool | What it does | | ----------------------------- | ---------------------------------------------- | | `create_comment`  **`write`** | Post a top-level comment on an asset or board. | ## Async ingest semantics[​](#async-ingest-semantics "Direct link to Async ingest semantics") The `upload_from_url` and `upload_from_urls` tools return immediately with **skeleton assets** — the asset rows exist but the bytes are still being fetched. The MCP response wraps each asset as `{ asset, status: "queued", note: "..." }` to nudge the assistant against reporting the upload as "done" prematurely. To detect completion, call `get_asset` on each returned asset token. The upload is finished when `is_skeleton: false` AND one of: * `media_type` populated → success * `source_error` non-null → failed (the message is the latest worker error) * `is_link: true` (only when `as_link: true` was passed) → kept as a bare link, no fetch attempted See the [Upload guide → Async Ingest Semantics](/docs/guides/upload.md#async-ingest-semantics) for the same contract documented from the REST side, including recommended polling cadence. ## Errors[​](#errors "Direct link to Errors") Tool errors are returned as MCP `isError: true` text content with a human-readable message. The server maps Playbook API errors as follows: | Status | Tool message | | ------ | -------------------------------------------------------------------------------- | | 401 | "Authentication failed — check your Bearer token." | | 403 | "Permission denied — check your token's scopes (`read`/`write`)." | | 404 | "Not found — check the organization slug, board token, or asset token." | | 406 | `"Invalid input: "` (used by batch URL ingest validation). | | 422 | `"Validation error: "`. | | 429 | "Rate limited — too many requests. Wait a moment and try again." | | 5xx | `"Playbook API error: "`. | Bearer tokens are never echoed back to the assistant, even when the upstream error body would contain one. --- # Embed a Gallery with the SDK The [`playbook-sdk`](https://www.npmjs.com/package/playbook-sdk) is a lightweight, framework-agnostic frontend library that renders a responsive masonry gallery — with search, board navigation, a full-screen viewer, and downloads — backed by your Playbook workspace. It talks to the same [REST API](/docs/api.md) documented here, so anything the gallery shows, you can also drive directly over HTTP. ## Install[​](#install "Direct link to Install") ``` npm install playbook-sdk ``` Or drop it in from a CDN: ``` ``` ## Initialize[​](#initialize "Direct link to Initialize") Add a container element, then initialize the SDK with your organization slug and an [auth token](/docs/getting_started.md): ``` ``` ``` import PlaybookSDK from "playbook-sdk"; const gallery = PlaybookSDK.init({ containerId: "gallery", organizationSlug: "your-org-slug", authToken: "your-auth-token", }); ``` That's it — a live, searchable gallery renders into `#gallery`. ## Customize[​](#customize "Direct link to Customize") Toggle features, tune the responsive column layout, and hook into events: ``` PlaybookSDK.init({ containerId: "gallery", organizationSlug: "your-org-slug", authToken: "your-auth-token", // Toggle built-in features enableSearch: true, enableBoards: true, enableModal: true, enableDownload: true, // Responsive columns (breakpoint in px → column count) columnBreakpoints: { default: 1, 768: 2, 1024: 3, }, // Events onAssetClick: (asset) => console.log("Clicked:", asset.title), onSearch: (query) => console.log("Searching:", query), onDownload: (asset) => console.log("Downloaded:", asset.token), }); ``` ## Control it programmatically[​](#control-it-programmatically "Direct link to Control it programmatically") `init` returns a handle you can drive from your own UI: ``` gallery.refresh(); // reload assets gallery.search("logo"); // run a search gallery.selectBoardById("brand-assets"); // switch board const assets = gallery.getAssets(); // current assets ``` ## Style it[​](#style-it "Direct link to Style it") The rendered markup uses `pb-`-prefixed classes you can override: ``` .pb-masonry-item img { border-radius: 16px !important; } .pb-board-btn.active { background-color: var(--brand-color) !important; } ``` ## Next steps[​](#next-steps "Direct link to Next steps") * **Get access:** [request an API token](/docs/getting_started.md#how-to-get-access) for your organization. * **Go deeper:** the SDK is powered by the [Playbook REST API](/docs/api.md) — use it directly for server-side or custom workflows. * **Add AI:** connect an assistant to the same workspace via the [MCP server](/docs/guides/mcp.md). --- # Search Assets in User's Organization Use the search endpoint to find assets across your entire Playbook organization by keywords, filters, and pagination. ## Prerequisites[​](#prerequisites "Direct link to Prerequisites") 1. **Access token**: a Playbook API token with search permissions, sent as a Bearer token. 2. **Organization Slug** (`slug`): Identifier for your org (e.g., `coolclient-ltd`). ## Endpoint & Authentication[​](#endpoint--authentication "Direct link to Endpoint & Authentication") Send your token in the `Authorization` header. (A token in the `access_token` query param also works, but avoid it — URLs leak into logs, browser history, and referrers.) ``` GET /v1/{slug}/search Authorization: Bearer YOUR_TOKEN ``` ## Query Parameters[​](#query-parameters "Direct link to Query Parameters") | Parameter | Type | Required | Description | | ------------------------- | ------- | -------- | ----------------------------------------------- | | query | string | yes | Free-text search term (e.g., `banner`, `logo`). | | page | integer | no | Page number (default: 1). | | per\_page | integer | no | Number of results per page (default: 20). | | filters.media\_type | string | no | Filter by media type (e.g., `image/png`). | | filters.collection\_token | string | no | Limit to a single collection. | | filters.title | string | no | Only items whose title contains this string. | ## Example Request[​](#example-request "Direct link to Example Request") ``` curl "https://api.playbook.com/v1/coolclient-ltd/search?query=banner&page=1&per_page=20" \ -H "Authorization: Bearer YOUR_TOKEN" | jq ``` ### With Filters[​](#with-filters "Direct link to With Filters") ``` curl "https://api.playbook.com/v1/coolclient-ltd/search?query=&filters[media_type]=image/png&filters[collection_token]=homepage-assets" \ -H "Authorization: Bearer YOUR_TOKEN" | jq ``` ## Example Response[​](#example-response "Direct link to Example Response") ``` { "data": [ { "id": 200, "token": "hero-jpg", "title": "Hero Banner", "media_type": "image/jpeg", "display_url": "https://cdn.playbook.com/hero.jpg" }, { "id": 201, "token": "logo-png", "title": "Logo PNG", "media_type": "image/png", "display_url": "https://cdn.playbook.com/logo.png" } ], "pagy": { "current_page": 1, "page_items": 2, "total_pages": 1, "total_count": 2 } } ``` ## Error Handling[​](#error-handling "Direct link to Error Handling") * `400 Bad Request`: Missing `query` parameter. * `401 Unauthorized`: Invalid or missing token. * `422 Unprocessable Entity`: Invalid filter format. ## Tips[​](#tips "Direct link to Tips") * Use pagination to batch-load results in your UI. * Combine filters to narrow down large datasets efficiently. --- # Sharing and Publishing Learn how to share assets and boards with external stakeholders and publish boards to the web. ## Overview[​](#overview "Direct link to Overview") Playbook offers two main ways to make your content accessible to others: * **Shared Links**: Private, trackable links for specific assets or boards * **Published Links**: Public web pages showcasing your boards *** ## Prerequisites[​](#prerequisites "Direct link to Prerequisites") * **Access Token**: API token with sharing permissions * **Organization Slug**: Your organization identifier * **Asset/Board Tokens**: IDs of content you want to share *** ## Sharing Assets[​](#sharing-assets "Direct link to Sharing Assets") Create temporary shared links for individual assets. ### Creating an Asset Shared Link[​](#creating-an-asset-shared-link "Direct link to Creating an Asset Shared Link") ``` curl -X POST "https://api.playbook.com/v1/my-org/assets/product-photo/share" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response:** ``` { "data": { "url": "https://playbook.com/s/abc123/product-photo" } } ``` ### JavaScript Example[​](#javascript-example "Direct link to JavaScript Example") ``` async function shareAsset(assetToken) { const response = await fetch( `https://api.playbook.com/v1/${ORG_SLUG}/assets/${assetToken}/share`, { method: 'POST', headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } } ); const { data } = await response.json(); return data.url; } // Usage const shareUrl = await shareAsset('product-photo'); console.log('Share link:', shareUrl); ``` *** ## Sharing Boards[​](#sharing-boards "Direct link to Sharing Boards") Create shared links for entire boards, allowing external viewers to see all assets within. ### Creating a Board Shared Link[​](#creating-a-board-shared-link "Direct link to Creating a Board Shared Link") ``` curl -X POST "https://api.playbook.com/v1/my-org/boards/main-collection/share" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response:** ``` { "data": { "url": "https://playbook.com/s/def456/main-collection" } } ``` ### Board Sharing Features[​](#board-sharing-features "Direct link to Board Sharing Features") Shared board links include: * All assets in the board * Nested subboards (if applicable) * Asset metadata (title, description, tags) * Comments (optionally) * Download capabilities (based on permissions) *** ## Publishing Boards[​](#publishing-boards "Direct link to Publishing Boards") Publish boards as public web galleries accessible to anyone with the link. ### Creating a Published Board[​](#creating-a-published-board "Direct link to Creating a Published Board") ``` curl -X POST "https://api.playbook.com/v1/my-org/boards/portfolio/publish" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response:** ``` { "data": { "url": "https://playbook.com/p/my-org/portfolio" } } ``` ### Published vs. Shared[​](#published-vs-shared "Direct link to Published vs. Shared") | Feature | Shared Link | Published Link | | ----------------------- | ------------------------- | --------------------- | | **Visibility** | Private (requires link) | Public (indexable) | | **Revocable** | Yes | Yes (can unpublish) | | **Password Protection** | Yes (via UI) | No | | **Analytics** | Detailed | Basic | | **SEO** | No | Yes | | **Best For** | Client reviews, approvals | Portfolios, galleries | *** ## Use Cases[​](#use-cases "Direct link to Use Cases") ### 1. Client Review Process[​](#1-client-review-process "Direct link to 1. Client Review Process") Share assets with clients for feedback: ``` async function sendForReview(assetTokens, clientEmail) { // Create shared links for each asset const sharedLinks = await Promise.all( assetTokens.map(token => shareAsset(token)) ); // Send email with links await emailService.send({ to: clientEmail, subject: 'Assets for Review', body: ` Please review the following assets: ${sharedLinks.map((url, i) => `Asset ${i + 1}: ${url}`).join('\n')} Reply with your feedback. ` }); return sharedLinks; } // Usage await sendForReview( ['design-v1', 'design-v2', 'design-v3'], 'client@example.com' ); ``` ### 2. Portfolio Website[​](#2-portfolio-website "Direct link to 2. Portfolio Website") Embed published boards in your website: ``` ``` ### 3. Temporary Access for Vendors[​](#3-temporary-access-for-vendors "Direct link to 3. Temporary Access for Vendors") Share boards with vendors for a limited time: ``` async function grantVendorAccess(boardToken, vendorEmail, expiryDays = 7) { // Create shared link const { url } = await shareBo ard(boardToken); // Send with expiry notice await emailService.send({ to: vendorEmail, subject: 'Asset Access Granted', body: ` You have access to our assets for the next ${expiryDays} days: ${url} This link will be revoked after ${expiryDays} days. ` }); // Schedule revocation (implement based on your system) await scheduleRevocation(url, expiryDays); return url; } ``` ### 4. Social Media Sharing[​](#4-social-media-sharing "Direct link to 4. Social Media Sharing") Share individual assets on social platforms: ``` async function shareToSocialMedia(assetToken, platform) { const shareUrl = await shareAsset(assetToken); const asset = await getAsset(assetToken); const shareText = encodeURIComponent( `Check out ${asset.title}: ${shareUrl}` ); const socialUrls = { twitter: `https://twitter.com/intent/tweet?text=${shareText}`, facebook: `https://www.facebook.com/sharer/sharer.php?u=${shareUrl}`, linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${shareUrl}` }; return socialUrls[platform]; } ``` *** ## Downloading Shared Assets[​](#downloading-shared-assets "Direct link to Downloading Shared Assets") Shared links can include download capabilities. Get download URLs for shared assets: ### For Shared Assets[​](#for-shared-assets "Direct link to For Shared Assets") ``` curl "https://api.playbook.com/v1/shared/my-org/assets/product-photo/download?sharedlinkslug=abc123" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response:** ``` { "data": { "display_url": "https://cdn.playbook.com/product-photo.jpg", "raw_url": "https://cdn.playbook.com/product-photo-original.jpg", "size": 2456789 } } ``` ### JavaScript Example[​](#javascript-example-1 "Direct link to JavaScript Example") ``` async function downloadSharedAsset(sharedOrgSlug, assetToken, sharedLinkSlug) { const response = await fetch( `https://api.playbook.com/v1/shared/${sharedOrgSlug}/assets/${assetToken}/download`, { headers: { 'sharedlinkslug': sharedLinkSlug } } ); const { data } = await response.json(); return data.raw_url; } ``` *** ## Managing Shared Links[​](#managing-shared-links "Direct link to Managing Shared Links") ### Tracking Shared Link Usage[​](#tracking-shared-link-usage "Direct link to Tracking Shared Link Usage") Implement your own tracking system: ``` class SharedLinkTracker { constructor() { this.links = new Map(); } async createTrackedLink(assetOrBoardToken, type = 'asset') { // Create the share link const url = type === 'asset' ? await shareAsset(assetOrBoardToken) : await shareBoard(assetOrBoardToken); // Store with metadata this.links.set(url, { token: assetOrBoardToken, type, createdAt: new Date(), views: 0, lastAccessed: null }); return url; } trackAccess(url) { const link = this.links.get(url); if (link) { link.views++; link.lastAccessed = new Date(); } } getAnalytics(url) { return this.links.get(url); } listActiveLinks() { const now = Date.now(); const dayAgo = now - (24 * 60 * 60 * 1000); return Array.from(this.links.entries()) .filter(([_, link]) => link.lastAccessed > dayAgo) .map(([url, link]) => ({ url, ...link })); } } ``` ### Revoking Access[​](#revoking-access "Direct link to Revoking Access") While there's no direct API to revoke shared links, you can: 1. **Delete the shared asset/board** 2. **Move asset to different board** 3. **Track and warn about expired links** ``` async function revokeSharedAccess(assetToken) { // Option 1: Move to private board await updateAsset(assetToken, { collection_token: 'private-board' }); // Option 2: Archive the asset await updateAsset(assetToken, { tags: [...asset.tags, 'archived'] }); console.log(`Access revoked for ${assetToken}`); } ``` *** ## Best Practices[​](#best-practices "Direct link to Best Practices") ### 1. Descriptive Sharing[​](#1-descriptive-sharing "Direct link to 1. Descriptive Sharing") Include context when sharing: ``` async function shareWithContext(assetToken, recipient, message) { const asset = await getAsset(assetToken); const shareUrl = await shareAsset(assetToken); await emailService.send({ to: recipient, subject: `Shared Asset: ${asset.title}`, body: ` ${message} Asset: ${asset.title} Link: ${shareUrl} Created: ${asset.created_at} Tags: ${asset.tags.join(', ')} This link will remain active until revoked. ` }); } ``` ### 2. Organize Published Content[​](#2-organize-published-content "Direct link to 2. Organize Published Content") Use consistent naming for published boards: ``` const publishedBoards = { portfolio: 'main-portfolio', client_work: 'client-showcase', team: 'team-photos' }; async function publishAll() { for (const [name, token] of Object.entries(publishedBoards)) { const { url } = await publishBoard(token); console.log(`Published ${name}: ${url}`); } } ``` ### 3. Watermark Shared Assets[​](#3-watermark-shared-assets "Direct link to 3. Watermark Shared Assets") Add watermarks to shared assets (implementation depends on your system): ``` async function shareWithWatermark(assetToken) { // Get original asset const asset = await getAsset(assetToken); // Download and watermark const watermarked = await addWatermark(asset.display_url, { text: 'CONFIDENTIAL', opacity: 0.3 }); // Upload watermarked version const watermarkedAsset = await uploadAsset(watermarked); // Share the watermarked version return await shareAsset(watermarkedAsset.token); } ``` ### 4. Analytics and Monitoring[​](#4-analytics-and-monitoring "Direct link to 4. Analytics and Monitoring") Track sharing activity: ``` class SharingAnalytics { async logShare(type, token, recipient) { await analytics.track({ event: 'asset_shared', properties: { type, token, recipient, timestamp: new Date() } }); } async getMostShared(days = 30) { const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); return await analytics.query({ event: 'asset_shared', since, groupBy: 'token', orderBy: 'count', limit: 10 }); } } ``` *** ## Complete Sharing Workflow[​](#complete-sharing-workflow "Direct link to Complete Sharing Workflow") Here's a complete implementation with email and tracking: ``` class PlaybookSharingManager { constructor(orgSlug, accessToken) { this.orgSlug = orgSlug; this.accessToken = accessToken; this.baseUrl = 'https://api.playbook.com/v1'; } async shareAsset(assetToken, options = {}) { const response = await fetch( `${this.baseUrl}/${this.orgSlug}/assets/${assetToken}/share`, { method: 'POST', headers: { Authorization: `Bearer ${this.accessToken}` } } ); const { data } = await response.json(); // Track the share if (options.trackingId) { await this.logShare('asset', assetToken, options.trackingId); } return data.url; } async shareBoard(boardToken, options = {}) { const response = await fetch( `${this.baseUrl}/${this.orgSlug}/boards/${boardToken}/share`, { method: 'POST', headers: { Authorization: `Bearer ${this.accessToken}` } } ); const { data } = await response.json(); if (options.trackingId) { await this.logShare('board', boardToken, options.trackingId); } return data.url; } async publishBoard(boardToken) { const response = await fetch( `${this.baseUrl}/${this.orgSlug}/boards/${boardToken}/publish`, { method: 'POST', headers: { Authorization: `Bearer ${this.accessToken}` } } ); return await response.json(); } async shareWithEmail(type, token, recipients, message) { // Create share link const url = type === 'asset' ? await this.shareAsset(token, { trackingId: recipients.join(',') }) : await this.shareBoard(token, { trackingId: recipients.join(',') }); // Get asset/board info const info = await this.getInfo(type, token); // Send emails for (const recipient of recipients) { await this.sendShareEmail(recipient, url, info, message); } return url; } async getInfo(type, token) { const endpoint = type === 'asset' ? 'assets' : 'boards'; const response = await fetch( `${this.baseUrl}/${this.orgSlug}/${endpoint}/${token}`, { headers: { Authorization: `Bearer ${this.accessToken}` } } ); const { data } = await response.json(); return data; } async sendShareEmail(recipient, url, info, message) { // Implement email sending console.log(`Sending to ${recipient}:`, { url, title: info.title, message }); } async logShare(type, token, trackingId) { // Log to your analytics system console.log('Share logged:', { type, token, trackingId }); } } // Usage const manager = new PlaybookSharingManager('my-org', 'your_token'); // Share asset with email await manager.shareWithEmail( 'asset', 'product-photo', ['client@example.com', 'stakeholder@example.com'], 'Please review this product photo and provide feedback.' ); // Publish board const published = await manager.publishBoard('portfolio'); console.log('Portfolio published at:', published.data.url); ``` *** ## Related API Endpoints[​](#related-api-endpoints "Direct link to Related API Endpoints") * [Share Asset](/docs/api/share-organization-asset.md) * [Share Board](/docs/api/share-organization-collection.md) * [Publish Board](/docs/api/publish-organization-collection.md) * [Download Shared Asset](/docs/api/download-shared-asset.md) *** ## Next Steps[​](#next-steps "Direct link to Next Steps") * Learn about [Asset Management](/docs/guides/asset_management.md) to organize shared content * Explore [Webhooks](/docs/guides/webhooks.md) to track sharing events * Read about [Comments](/docs/guides/comments.md) for collaboration on shared assets --- # Uploading Assets to Playbook You can add assets to Playbook either by pointing at a public URL or by using a two-step signed-upload flow. ## Prerequisites[​](#prerequisites "Direct link to Prerequisites") 1. **Access token**: an API token with asset-upload permission, sent as a Bearer token. 2. **Organization Slug** (`slug`): Your org's identifier (e.g., `coolclient-ltd`). 3. **Board Token** (`board_token`): (Optional) Where to place the asset. If omitted, asset goes to a default location (`Uploaded today` board). You can [fetch board tokens](/docs/guides/fetching_data.md#list-boards-in-an-organization) using the boards endpoint. *** ## Direct Upload from Public URL[​](#direct-upload-from-public-url "Direct link to Direct Upload from Public URL") ### Endpoint[​](#endpoint "Direct link to Endpoint") ``` POST /v1/{slug}/assets Authorization: Bearer YOUR_TOKEN Content-Type: application/json ``` ### Request Body[​](#request-body "Direct link to Request Body") | Field | Type | Required | Description | | ------------ | ------- | -------- | ---------------------------------------------------------------- | | uri | string | yes | Publicly accessible URL to fetch the file. | | filename | string | yes | Desired filename in Playbook (e.g., `image.jpg`). | | title | string | no | Display name for the asset. | | description | string | no | Optional description or notes. | | board\_token | string | no | Token of the target board. | | as\_link | boolean | no | If true, asset is stored as an external link without processing. | ### Sample Request[​](#sample-request "Direct link to Sample Request") ``` curl -X POST https://api.playbook.com/v1/coolclient-ltd/assets \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "asset": { "uri": "https://example.com/photo.jpg", "title": "User Photo", "collection_token": "homepage-assets" } }' ``` ### Sample Response[​](#sample-response "Direct link to Sample Response") ``` HTTP/1.1 200 OK Content-Type: application/json { "data": { "id": 101, "token": "assetToken123", "display_url": "https://cdn.playbook.com/photo.jpg", "media_type": "image/jpeg", "collection_token": "boardToken123", "is_skeleton": true, "is_link": false, "source_error": null, ... } } ``` > **URL ingest is asynchronous.** The response returns immediately with `is_skeleton: true`. Playbook fetches the bytes in the background. See [Async Ingest Semantics](#async-ingest-semantics) below for how to detect completion. *** ## Async Ingest Semantics[​](#async-ingest-semantics "Direct link to Async Ingest Semantics") Both `POST /v1/{slug}/assets` (with a `uri`) and `POST /v1/{slug}/assets/batch_create_from_urls` enqueue a background worker that fetches the bytes from the supplied URL. The HTTP response returns immediately with a placeholder asset (`is_skeleton: true`). To know when the upload has finished, poll `GET /v1/{slug}/assets/{asset_token}` and inspect the following fields: | Field | Type | Meaning | | -------------- | ------- | --------------------------------------------------------------------------------------- | | `is_skeleton` | boolean | `true` while the worker is still fetching. Becomes `false` when the worker is finished. | | `media_type` | string | Populated on success (e.g., `image/jpeg`). | | `source_error` | string | The latest error message from the URL-download worker. `null` on success. | | `is_link` | boolean | `true` when the asset was stored as a bare link (because `as_link: true` was set). | ### Terminal states[​](#terminal-states "Direct link to Terminal states") Treat the upload as finished when `is_skeleton: false` AND one of the following holds: * **Success** — `media_type` is populated and `source_error` is `null`. * **Failed** — `source_error` is non-null. The error message describes why the worker could not fetch or process the bytes (e.g., 404 from the source URL, unsupported file type). * **Stored as link** — `is_link: true` (only when `as_link: true` was passed in the request). The asset exists as a bare link with no fetched bytes. ### Recommended polling cadence[​](#recommended-polling-cadence "Direct link to Recommended polling cadence") Most uploads finish within a few seconds. Polling every 1–2 seconds with exponential backoff up to \~60 seconds is sufficient. If `is_skeleton` is still `true` after 60 seconds, treat it as a worker delay rather than a failure and continue polling at a slower cadence. *** ## Batch Upload from Public URLs[​](#batch-upload-from-public-urls "Direct link to Batch Upload from Public URLs") Use this endpoint to ingest up to 100 public URLs in a single request. All assets land in the same board, the entire batch is wrapped in a database transaction (so a single bad asset rolls back the whole batch), and one `UPLOAD_ASSETS` event is emitted for the batch. ### Endpoint[​](#endpoint-1 "Direct link to Endpoint") ``` POST /v1/{slug}/assets/batch_create_from_urls Authorization: Bearer YOUR_TOKEN Content-Type: application/json ``` Requires the `write` scope. ### Request Body[​](#request-body-1 "Direct link to Request Body") | Field | Type | Required | Description | | ------------------------ | ------- | -------- | -------------------------------------------------------------------- | | `batch.collection_token` | string | no | Token of the destination board, applied to every asset in the batch. | | `batch.collection_id` | integer | no | Numeric ID alternative to `collection_token`. | | `batch.assets` | array | yes | 1 to 100 asset specs (see below). | Each item in `batch.assets`: | Field | Type | Required | Description | | ------------- | ------- | -------- | -------------------------------------------------------------------------------- | | `uri` | string | yes | Public URL to ingest. | | `uuid` | string | no | Client-supplied correlation id, returned untouched on the matching response row. | | `title` | string | no | Override the title (defaults to filename derived from URL). | | `description` | string | no | Optional description. | | `as_link` | boolean | no | If `true`, store as a bare link instead of fetching bytes. | | `tags` | array | no | Manual tags to apply on creation. | | `status` | string | no | Status label to set on creation. | ### Sample Request[​](#sample-request-1 "Direct link to Sample Request") ``` curl -X POST https://api.playbook.com/v1/coolclient-ltd/assets/batch_create_from_urls \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "batch": { "collection_token": "homepage-assets", "assets": [ { "uuid": "client-1", "uri": "https://example.com/a.jpg", "title": "Hero" }, { "uuid": "client-2", "uri": "https://example.com/b.png", "as_link": true } ] } }' ``` ### Sample Response[​](#sample-response-1 "Direct link to Sample Response") ``` HTTP/1.1 200 OK Content-Type: application/json { "data": [ { "uuid": "client-1", "asset": { "token": "asset-tok-1", "title": "Hero", "is_skeleton": true, "is_link": false, "source_error": null, ... } }, { "uuid": "client-2", "asset": { "token": "asset-tok-2", "is_skeleton": false, "is_link": true, "source_error": null, ... } } ] } ``` The response is an array of `{ uuid, asset }` rows. Each asset starts as a skeleton and reaches its terminal state asynchronously — see [Async Ingest Semantics](#async-ingest-semantics) above. Poll `GET /v1/{slug}/assets/{asset_token}` for each asset. ### Limits and validation[​](#limits-and-validation "Direct link to Limits and validation") * Maximum 100 assets per request — exceeding this returns `422`. * Every asset must include a `uri` — missing or blank returns `406`. * The org's total-asset limit is enforced — exceeding it returns `422`. * All-or-nothing: if any asset fails to be created, the entire batch is rolled back. *** ## Two-Step Upload Flow (Prepare & Complete)[​](#two-step-upload-flow-prepare--complete "Direct link to Two-Step Upload Flow (Prepare & Complete)") Use this flow when you want to upload large files or have more control over the upload process. ### Step 1: Request Upload Credentials[​](#step-1-request-upload-credentials "Direct link to Step 1: Request Upload Credentials") #### Endpoint[​](#endpoint-2 "Direct link to Endpoint") ``` POST /v1/{slug}/assets/upload_prepare Authorization: Bearer YOUR_TOKEN Content-Type: application/json ``` #### Request Body[​](#request-body-2 "Direct link to Request Body") | Field | Type | Required | Description | | ----------- | ------- | -------- | ------------------------------ | | title | string | yes | File name or display label. | | media\_type | string | yes | MIME type (e.g., `video/mp4`). | | size | integer | yes | File byte size. | #### Sample Request[​](#sample-request-2 "Direct link to Sample Request") ``` curl -X POST https://api.playbook.com/v1/coolclient-ltd/assets/upload_prepare \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "asset": { "title": "Vacation Video", "media_type": "video/mp4", "size": 52428800 } }' ``` #### Sample Response[​](#sample-response-2 "Direct link to Sample Response") ``` HTTP/1.1 200 OK Content-Type: application/json { "data": { "upload_url": "https://storage.googleapis.com/playbook-uploads/...", "signed_gcs_id": "abc123def456", "file_extension": "mp4" } } ``` ### Step 2: Upload the File to Storage[​](#step-2-upload-the-file-to-storage "Direct link to Step 2: Upload the File to Storage") Use the `upload_url` (usually a pre-signed PUT URL) to send your file directly to storage. ``` curl -X PUT "https://storage.googleapis.com/playbook-uploads/..." \ -H "Content-Type: video/mp4" \ --data-binary @/path/to/Vacation.mp4 ``` ### Step 3: Complete the Upload[​](#step-3-complete-the-upload "Direct link to Step 3: Complete the Upload") #### Endpoint[​](#endpoint-3 "Direct link to Endpoint") ``` POST /v1/{slug}/assets/upload_complete Authorization: Bearer YOUR_TOKEN Content-Type: application/json ``` #### Request Body[​](#request-body-3 "Direct link to Request Body") | Field | Type | Required | Description | | --------------- | ------- | -------- | ----------------------------------- | | signed\_gcs\_id | string | yes | ID returned from the prepare step. | | title | string | no | (Optional) override title. | | description | string | no | (Optional) asset description. | | media\_type | string | yes | Same MIME type as the prepare call. | | size | integer | yes | Byte size (same as prepare). | | board\_token | string | no | Target board token. | #### Sample Request[​](#sample-request-3 "Direct link to Sample Request") ``` curl -X POST https://api.playbook.com/v1/coolclient-ltd/assets/upload_complete \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "asset": { "signed_gcs_id": "abc123def456", "title": "Vacation Video", "media_type": "video/mp4", "size": 52428800, "collection_token": "video-collection" } }' ``` #### Sample Response[​](#sample-response-3 "Direct link to Sample Response") ``` HTTP/1.1 200 OK Content-Type: application/json { "data": { "token": "vacation-video-mp4", "display_url": "https://cdn.playbook.com/vacation-video.mp4", "media_type": "video/mp4" } } ``` *** ## Batch Two-Step Upload (Prepare & Complete)[​](#batch-two-step-upload-prepare--complete "Direct link to Batch Two-Step Upload (Prepare & Complete)") For ingesting many large files in one round, the two-step flow above has a batch counterpart. It mirrors the single-asset flow exactly — `batch_upload_prepare` returns one `upload_url` per asset and a shared `batch_id`, then you `PUT` each file to its URL, then call `batch_upload_complete` once with all the `signed_gcs_id` values to materialize the asset records. Unlike `batch_create_from_urls` (which is all-or-nothing), `batch_upload_complete` creates assets individually — a single failure does **not** roll back already-created assets in the same batch. ### Step 1 — `batch_upload_prepare`[​](#step-1--batch_upload_prepare "Direct link to step-1--batch_upload_prepare") ``` POST /v1/{slug}/assets/batch_upload_prepare Authorization: Bearer YOUR_TOKEN Content-Type: application/json ``` Request: | Field | Type | Required | Description | | -------------- | ----- | -------- | --------------------------------- | | `batch.assets` | array | yes | 1 to 100 asset specs (see below). | Each item in `batch.assets`: | Field | Type | Required | Description | | ------------ | ------- | -------- | -------------------------------------------------------------------- | | `title` | string | yes | Filename of the asset. | | `size` | integer | yes | File size in bytes. | | `uuid` | string | yes | Client-generated correlation id, returned untouched in the response. | | `media_type` | string | no | MIME type (inferred from `title` extension if omitted). | Sample request: ``` curl -X POST https://api.playbook.com/v1/coolclient-ltd/assets/batch_upload_prepare \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "batch": { "assets": [ { "uuid": "client-1", "title": "photo.jpg", "size": 1024000, "media_type": "image/jpeg" }, { "uuid": "client-2", "title": "doc.pdf", "size": 5242880, "media_type": "application/pdf" } ] } }' ``` Sample response: ``` { "data": { "batch_id": "uuid-of-batch", "assets": [ { "uuid": "client-1", "upload_url": "https://storage.googleapis.com/...", "signed_gcs_id": "abc...", "file_extension": "jpg", "storage_provider": "gcs" }, { "uuid": "client-2", "upload_url": "https://storage.googleapis.com/...", "signed_gcs_id": "def...", "file_extension": "pdf", "storage_provider": "gcs" } ] } } ``` ### Step 2 — Upload each file[​](#step-2--upload-each-file "Direct link to Step 2 — Upload each file") `PUT` each file to its `upload_url`, the same as in the single-asset two-step flow. Run uploads in parallel for throughput. ### Step 3 — `batch_upload_complete`[​](#step-3--batch_upload_complete "Direct link to step-3--batch_upload_complete") ``` POST /v1/{slug}/assets/batch_upload_complete Authorization: Bearer YOUR_TOKEN Content-Type: application/json ``` Request: | Field | Type | Required | Description | | ------------------------ | ------- | -------- | --------------------------------------------------------------------------------- | | `batch.batch_id` | string | no | Value from `batch_upload_prepare`. Recommended — releases the upload reservation. | | `batch.collection_token` | string | no | Destination board for every asset in the batch. | | `batch.collection_id` | integer | no | Numeric alternative to `collection_token`. | | `batch.assets` | array | yes | 1 to 100 completion specs (see below). | Each item in `batch.assets`: | Field | Type | Required | Description | | --------------------- | ------- | -------- | ---------------------------------------------------------- | | `uuid` | string | yes | Same client uuid you sent in `batch_upload_prepare`. | | `signed_gcs_id` | string | yes | Exact value returned in the matching prepare response row. | | `title` | string | yes | Display title for the asset. | | `description` | string | no | Optional description. | | `media_type` | string | no | MIME type — same value used in prepare. | | `width` / `height` | integer | no | Image dimensions, when known client-side. | | `multipart_upload_id` | string | no | Required only for Backblaze multipart uploads. | Sample request: ``` curl -X POST https://api.playbook.com/v1/coolclient-ltd/assets/batch_upload_complete \ -H "Authorization: Bearer ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "batch": { "batch_id": "uuid-of-batch", "collection_token": "homepage-assets", "assets": [ { "uuid": "client-1", "signed_gcs_id": "abc...", "title": "photo.jpg", "media_type": "image/jpeg", "width": 1920, "height": 1080 }, { "uuid": "client-2", "signed_gcs_id": "def...", "title": "doc.pdf", "media_type": "application/pdf" } ] } }' ``` Sample response: ``` { "data": [ { "uuid": "client-1", "asset": { "token": "asset-tok-1", "media_type": "image/jpeg", ... } }, { "uuid": "client-2", "asset": { "token": "asset-tok-2", "media_type": "application/pdf", ... } } ] } ``` ### When to choose which flow[​](#when-to-choose-which-flow "Direct link to When to choose which flow") | Use case | Endpoint | | ---------------------------------------------- | --------------------------------------------------------------- | | Asset bytes already live at a public URL | `batch_create_from_urls` | | Files live on the client (browser, server, CI) | `batch_upload_prepare` + `batch_upload_complete` | | One file at a time | `assets` (URL) or `upload_prepare` + `upload_complete` (signed) | *** ## Error Handling[​](#error-handling "Direct link to Error Handling") * `400 Bad Request`: Missing or malformed JSON fields. * `401 Unauthorized`: Invalid or missing token. * `403 Forbidden`: Token lacks the `write` scope, or the user lacks `update` permission on the target board. * `404 Not Found` (batch complete): One or more `signed_gcs_id` values point to objects not present in storage — re-upload before retrying. * `406 Not Acceptable`: A required field is missing (e.g., a batch asset without `uri`). * `422 Unprocessable Entity`: File size/type mismatch, expired upload URL, batch size out of bounds (must be 1–100), or workspace asset limit exceeded. *** ## Tips[​](#tips "Direct link to Tips") * For very large files, monitor your upload progress and retry on failures. * Clean up or retry failed `signed_gcs_id`s by re-calling the prepare step. * Track asset `token` for later operations like update or delete. *** --- # Webhooks Receive real-time notifications when assets are added, updated, or deleted in your Playbook organization. ## Overview[​](#overview "Direct link to Overview") Webhooks (also called triggers) allow you to integrate Playbook with external systems by receiving HTTP callbacks when specific events occur. This enables: * Real-time synchronization with other platforms * Automated workflows triggered by asset changes * Custom notifications and alerts * Analytics and audit logging *** ## Prerequisites[​](#prerequisites "Direct link to Prerequisites") * **Access Token**: API token with webhook management permissions * **Webhook URL**: Your server endpoint to receive webhook payloads * **Organization Slug**: Your organization identifier * **Board Token**: The board you want to monitor *** ## Available Events[​](#available-events "Direct link to Available Events") Playbook supports three webhook event types: | Event | Trigger | Payload | | --------------- | ----------------- | -------------------- | | `asset_added` | New asset created | Full asset object | | `asset_updated` | Asset modified | Updated asset object | | `asset_deleted` | Asset removed | Asset ID and token | *** ## Creating a Webhook[​](#creating-a-webhook "Direct link to Creating a Webhook") ### Basic Webhook Setup[​](#basic-webhook-setup "Direct link to Basic Webhook Setup") ``` curl -X POST "https://api.playbook.com/v1/trigger" \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "hook": "asset_added", "hook_url": "https://your-server.com/webhooks/playbook", "organization_slug": "my-org", "collection_token": "main-board" }' ``` **Response:** ``` { "id": "trigger-abc123def", "expiration_date": "2025-01-09T18:00:00Z" } ``` **Note:** Webhooks expire after a set period. You'll need to recreate them periodically. *** ## Webhook Payload Format[​](#webhook-payload-format "Direct link to Webhook Payload Format") ### Asset Added Event[​](#asset-added-event "Direct link to Asset Added Event") ``` { "event": "asset_added", "timestamp": "2024-10-09T18:30:00Z", "organization": { "slug": "my-org", "name": "My Organization" }, "collection": { "token": "main-board", "title": "Main Board" }, "asset": { "id": 12345, "token": "new-asset-xyz", "title": "Product Photo", "media_type": "image/jpeg", "display_url": "https://cdn.playbook.com/product-photo.jpg", "created_at": "2024-10-09T18:30:00Z", "updated_at": "2024-10-09T18:30:00Z", "tags": ["product", "photography"], "fields": { "Approval Status": "Draft" } } } ``` ### Asset Updated Event[​](#asset-updated-event "Direct link to Asset Updated Event") ``` { "event": "asset_updated", "timestamp": "2024-10-09T19:00:00Z", "organization": { "slug": "my-org", "name": "My Organization" }, "collection": { "token": "main-board", "title": "Main Board" }, "asset": { "id": 12345, "token": "new-asset-xyz", "title": "Product Photo - Final", "updated_at": "2024-10-09T19:00:00Z", "changes": { "title": { "old": "Product Photo", "new": "Product Photo - Final" }, "fields": { "old": { "Approval Status": "Draft" }, "new": { "Approval Status": "Approved" } } } } } ``` ### Asset Deleted Event[​](#asset-deleted-event "Direct link to Asset Deleted Event") ``` { "event": "asset_deleted", "timestamp": "2024-10-09T20:00:00Z", "organization": { "slug": "my-org", "name": "My Organization" }, "collection": { "token": "main-board", "title": "Main Board" }, "asset": { "id": 12345, "token": "new-asset-xyz", "title": "Product Photo - Final" } } ``` *** ## Receiving Webhooks[​](#receiving-webhooks "Direct link to Receiving Webhooks") ### Basic Server Example (Node.js/Express)[​](#basic-server-example-nodejsexpress "Direct link to Basic Server Example (Node.js/Express)") ``` const express = require('express'); const crypto = require('crypto'); const app = express(); app.use(express.json()); app.post('/webhooks/playbook', async (req, res) => { const { event, asset, collection, organization } = req.body; console.log(`Received ${event} event for asset: ${asset.token}`); // Process the webhook try { await handleWebhookEvent(event, asset, collection, organization); // Always respond quickly with 200 res.status(200).json({ received: true }); } catch (error) { console.error('Webhook processing error:', error); // Still return 200 to prevent retries res.status(200).json({ received: true, error: error.message }); } }); async function handleWebhookEvent(event, asset, collection, organization) { switch (event) { case 'asset_added': await onAssetAdded(asset, collection); break; case 'asset_updated': await onAssetUpdated(asset, collection); break; case 'asset_deleted': await onAssetDeleted(asset, collection); break; } } async function onAssetAdded(asset, collection) { console.log(`New asset added: ${asset.title}`); // Your logic here } async function onAssetUpdated(asset, collection) { console.log(`Asset updated: ${asset.title}`); // Your logic here } async function onAssetDeleted(asset, collection) { console.log(`Asset deleted: ${asset.token}`); // Your logic here } app.listen(3000, () => { console.log('Webhook server listening on port 3000'); }); ``` ### Python (Flask) Example[​](#python-flask-example "Direct link to Python (Flask) Example") ``` from flask import Flask, request, jsonify import logging app = Flask(__name__) logging.basicConfig(level=logging.INFO) @app.route('/webhooks/playbook', methods=['POST']) def handle_webhook(): payload = request.get_json() event = payload.get('event') asset = payload.get('asset') collection = payload.get('collection') logging.info(f"Received {event} for asset: {asset.get('token')}") try: if event == 'asset_added': handle_asset_added(asset, collection) elif event == 'asset_updated': handle_asset_updated(asset, collection) elif event == 'asset_deleted': handle_asset_deleted(asset, collection) return jsonify({'received': True}), 200 except Exception as e: logging.error(f"Error processing webhook: {e}") return jsonify({'received': True, 'error': str(e)}), 200 def handle_asset_added(asset, collection): print(f"New asset: {asset['title']}") # Your logic here def handle_asset_updated(asset, collection): print(f"Updated asset: {asset['title']}") # Your logic here def handle_asset_deleted(asset, collection): print(f"Deleted asset: {asset['token']}") # Your logic here if __name__ == '__main__': app.run(port=3000) ``` *** ## Deleting Webhooks[​](#deleting-webhooks "Direct link to Deleting Webhooks") Remove webhooks when they're no longer needed: ``` curl -X DELETE "https://api.playbook.com/v1/trigger/trigger-abc123def" \ -H "Authorization: Bearer YOUR_TOKEN" ``` **Response:** HTTP 204 No Content *** ## Use Cases[​](#use-cases "Direct link to Use Cases") ### 1. Sync with External CMS[​](#1-sync-with-external-cms "Direct link to 1. Sync with External CMS") Keep your CMS in sync with Playbook assets: ``` async function onAssetAdded(asset, collection) { // Add asset reference to CMS await cmsAPI.createAsset({ id: asset.token, title: asset.title, url: asset.display_url, source: 'playbook', collectionId: collection.token }); } async function onAssetUpdated(asset, collection) { // Update CMS asset await cmsAPI.updateAsset(asset.token, { title: asset.title, tags: asset.tags, metadata: asset.fields }); } async function onAssetDeleted(asset, collection) { // Remove from CMS await cmsAPI.deleteAsset(asset.token); } ``` ### 2. Approval Notifications[​](#2-approval-notifications "Direct link to 2. Approval Notifications") Send notifications when assets are approved: ``` async function onAssetUpdated(asset, collection) { // Check if approval status changed to approved if (asset.changes?.fields?.new?.['Approval Status'] === 'Approved') { await sendNotification({ to: 'team@company.com', subject: `Asset Approved: ${asset.title}`, body: `The asset "${asset.title}" has been approved and is ready for use.`, assetUrl: asset.display_url }); } } ``` ### 3. Analytics Tracking[​](#3-analytics-tracking "Direct link to 3. Analytics Tracking") Track asset lifecycle for analytics: ``` async function handleWebhookEvent(event, asset, collection, organization) { // Log to analytics service await analyticsAPI.track({ event: `playbook_${event}`, properties: { assetId: asset.token, assetTitle: asset.title, mediaType: asset.media_type, collection: collection.token, organization: organization.slug, tags: asset.tags }, timestamp: new Date() }); } ``` ### 4. Automated Backup[​](#4-automated-backup "Direct link to 4. Automated Backup") Create backups when assets are added: ``` async function onAssetAdded(asset, collection) { if (asset.media_type.startsWith('image/') || asset.media_type.startsWith('video/')) { // Queue backup job await backupQueue.add({ assetToken: asset.token, sourceUrl: asset.display_url, backupPath: `backups/${organization}/${collection.token}/${asset.token}`, metadata: { title: asset.title, createdAt: asset.created_at } }); } } ``` *** ## Best Practices[​](#best-practices "Direct link to Best Practices") ### 1. Respond Quickly[​](#1-respond-quickly "Direct link to 1. Respond Quickly") Always respond with 200 OK immediately, then process asynchronously: ``` app.post('/webhooks/playbook', async (req, res) => { // Respond immediately res.status(200).json({ received: true }); // Process asynchronously setImmediate(async () => { try { await processWebhook(req.body); } catch (error) { console.error('Async webhook processing failed:', error); } }); }); ``` ### 2. Implement Idempotency[​](#2-implement-idempotency "Direct link to 2. Implement Idempotency") Handle duplicate deliveries gracefully: ``` const processedEvents = new Set(); async function processWebhook(payload) { const eventId = `${payload.event}_${payload.asset.token}_${payload.timestamp}`; if (processedEvents.has(eventId)) { console.log('Duplicate event, skipping'); return; } processedEvents.add(eventId); // Process the event await handleWebhookEvent(payload); // Clean up old IDs periodically if (processedEvents.size > 1000) { processedEvents.clear(); } } ``` ### 3. Handle Failures Gracefully[​](#3-handle-failures-gracefully "Direct link to 3. Handle Failures Gracefully") ``` async function handleWebhookEvent(event, asset) { try { await processEvent(event, asset); } catch (error) { console.error(`Failed to process ${event}:`, error); // Log to error tracking service await errorTracker.captureException(error, { extra: { event, assetToken: asset.token, timestamp: new Date() } }); // Queue for retry await retryQueue.add({ event, asset, attemptCount: 1 }); } } ``` ### 4. Validate Webhook Source[​](#4-validate-webhook-source "Direct link to 4. Validate Webhook Source") Verify webhooks are from Playbook (implement based on your security requirements): ``` function validateWebhook(req) { // Check IP allowlist const allowedIPs = ['52.12.34.56', '52.12.34.57']; const requestIP = req.ip; if (!allowedIPs.includes(requestIP)) { throw new Error('Invalid webhook source'); } // Verify payload structure const { event, asset, organization } = req.body; if (!event || !asset || !organization) { throw new Error('Invalid payload structure'); } return true; } app.post('/webhooks/playbook', async (req, res) => { try { validateWebhook(req); await processWebhook(req.body); res.status(200).json({ received: true }); } catch (error) { res.status(400).json({ error: error.message }); } }); ``` *** ## Complete Integration Example[​](#complete-integration-example "Direct link to Complete Integration Example") Here's a complete webhook integration with a database: ``` const express = require('express'); const { MongoClient } = require('mongodb'); class PlaybookWebhookHandler { constructor(mongoUrl, dbName) { this.mongoUrl = mongoUrl; this.dbName = dbName; this.client = null; this.db = null; } async connect() { this.client = await MongoClient.connect(this.mongoUrl); this.db = this.client.db(this.dbName); console.log('Connected to MongoDB'); } async handleAssetAdded(payload) { const { asset, collection, organization } = payload; await this.db.collection('assets').insertOne({ playbookId: asset.token, title: asset.title, mediaType: asset.media_type, displayUrl: asset.display_url, collection: collection.token, organization: organization.slug, tags: asset.tags, fields: asset.fields, createdAt: new Date(asset.created_at), syncedAt: new Date() }); console.log(`Synced new asset: ${asset.title}`); } async handleAssetUpdated(payload) { const { asset, collection } = payload; await this.db.collection('assets').updateOne( { playbookId: asset.token }, { $set: { title: asset.title, tags: asset.tags, fields: asset.fields, updatedAt: new Date(asset.updated_at), syncedAt: new Date() } } ); console.log(`Synced updated asset: ${asset.title}`); } async handleAssetDeleted(payload) { const { asset } = payload; await this.db.collection('assets').updateOne( { playbookId: asset.token }, { $set: { deleted: true, deletedAt: new Date(), syncedAt: new Date() } } ); console.log(`Marked asset as deleted: ${asset.token}`); } async processWebhook(payload) { const { event } = payload; // Log the event await this.db.collection('webhook_logs').insertOne({ event, payload, receivedAt: new Date() }); // Handle based on event type switch (event) { case 'asset_added': await this.handleAssetAdded(payload); break; case 'asset_updated': await this.handleAssetUpdated(payload); break; case 'asset_deleted': await this.handleAssetDeleted(payload); break; } } } // Express server setup const app = express(); app.use(express.json()); const handler = new PlaybookWebhookHandler( 'mongodb://localhost:27017', 'playbook_sync' ); handler.connect(); app.post('/webhooks/playbook', async (req, res) => { res.status(200).json({ received: true }); setImmediate(async () => { try { await handler.processWebhook(req.body); } catch (error) { console.error('Webhook processing failed:', error); } }); }); app.listen(3000); ``` *** ## Troubleshooting[​](#troubleshooting "Direct link to Troubleshooting") ### Webhooks Not Received[​](#webhooks-not-received "Direct link to Webhooks Not Received") **Check your webhook URL:** * Is it publicly accessible? * Does it use HTTPS? * Is the server running? **Verify the trigger is active:** ``` # List all your triggers (if such endpoint exists) # Or recreate if expired ``` ### Duplicate Events[​](#duplicate-events "Direct link to Duplicate Events") This is normal - implement idempotency: ``` // Track processed events const cache = new Map(); function isDuplicate(eventId) { if (cache.has(eventId)) { return true; } cache.set(eventId, Date.now()); return false; } ``` ### Missing Payload Data[​](#missing-payload-data "Direct link to Missing Payload Data") Check the API version - payload structure may vary. *** ## Related API Endpoints[​](#related-api-endpoints "Direct link to Related API Endpoints") * [Create Trigger](/docs/api/create-trigger.md) * [Delete Trigger](/docs/api/delete-trigger.md) *** ## Next Steps[​](#next-steps "Direct link to Next Steps") * Learn about [Custom Fields](/docs/guides/custom_fields.md) to add webhook triggers on field changes * Explore [Asset Management](/docs/guides/asset_management.md) to automate workflows * Read about [Search](/docs/guides/search.md) to find assets programmatically ---