Custom Fields
Add structured metadata to your assets with custom fields and predefined options.
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
- Access Token: API token with admin permissions
- Organization Slug: Your organization identifier
Creating Custom Fields
Basic Custom Field
This endpoint is create-or-replace, matched by name. If a field with the same name already exists, its option list is overwritten and every option missing from the request is deleted, removing that value from every asset holding it. Always send the complete intended option set, and list the existing fields first when the name may already be taken. The built-in Status field cannot be replaced this way.
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
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" }
]
}
]
}
Assigning Field Values to Assets
Field values are assigned by updating an asset. Asset creation does not accept fields —
create the asset first, then set its field values in a second request.
On Asset Update
curl -X PATCH "https://api.playbook.com/v1/my-org/assets/product-photo" \
-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"
}
}
}
On update, each supplied field replaces that asset's previous option from the same field.
Fields omitted from the fields map remain unchanged.
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
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
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
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
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
The public REST /search endpoint accepts custom-field option names through
filters[statuses][]. The search index stores the built-in Status and every custom-field
value in one flat list, so matching is case-insensitive by option name and cannot be scoped
to a specific field. If two fields both contain an option named Approved, that filter
matches assets carrying either option.
Use filters[statuses_op]=or (the default) to match any supplied option, or
filters[statuses_op]=and to require every supplied option:
Pass filters[media_type]=all as well. That filter is not empty by default — omitting
it restricts the search to images, so a video or PDF carrying the option would be missing
from the results without any error.
curl --get "https://api.playbook.com/v1/my-org/search" \
-H "Authorization: Bearer YOUR_TOKEN" \
--data-urlencode "query=" \
--data-urlencode "filters[media_type]=all" \
--data-urlencode "filters[statuses][]=High" \
--data-urlencode "filters[statuses][]=Summer" \
--data-urlencode "filters[statuses_op]=and"
Asset payloads carry custom-field values in their fields map, which names the field each
option belongs to. The built-in Status is not in that map — it is returned in the separate
status attribute, so a search matching a built-in option finds assets whose fields map
says nothing about it.
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
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
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
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
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
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
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}`,
},
// change_tags adds and removes by name; it never replaces the whole set, so send only
// what is new. A bare array here would land in params[:_json] and change nothing.
body: JSON.stringify({ add_tags: autoTags, remove_tags: [] }),
},
);
}
}
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
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.
No error: unrecognised field or option
An entry in fields whose field name or value does not match an existing field and one of its
options is skipped silently. The request still returns 200, and the asset keeps whatever
value it had.
Solution: match the names exactly as returned by the list endpoint, and read the fields map
in the response to confirm what was actually applied.
Related API Endpoints
Next Steps
- Learn about Asset Management to organize assets with custom fields
- Explore Webhooks to automate actions based on field changes
- Read about Search to filter assets by custom fields