Skip to main content

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",
"field_type": "select",
"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",
"field_type": "select",
"options": [
{ "name": "Draft", "token": "opt-draft-xyz" },
{ "name": "Approved", "token": "opt-approved-def" }
]
},
{
"name": "Season",
"token": "field-def456",
"field_type": "select",
"options": [
{ "name": "Spring/Summer", "token": "opt-ss-abc" },
{ "name": "Fall/Winter", "token": "opt-fw-def" }
]
},
{
"name": "Shoot Date",
"token": "field-ghi789",
"field_type": "date",
"options": []
}
]
}

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 value for the same field. Fields omitted from the fields map remain unchanged. A value written on a group also applies to its members.

Typed fields: date, person, and URL​

Besides option fields, a workspace can have typed fields that hold one value per asset instead of an option: a date, a person, or a URL. Every field in Listing Custom Fields carries a field_type — select for option fields, date, person or url for typed ones, which have an empty options list.

Create a typed field by passing field_type and no options. If a field with the same name and type already exists it is returned unchanged; a name already used by a field of another type is refused.

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": "Shoot Date",
"field_type": "date"
}
}'

Deleting a typed field removes the value every asset held for it.

Their values are read and written through the same fields map, as plain strings:

Field typeValue to sendReads back as
DateISO 8601 date, YYYY-MM-DDThe same date
PersonA workspace member's user token (see People)The user token while the account exists; the person's last name once it is deleted
URLAn http or https URLThe URL
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",
"Shoot Date": "2026-09-01",
"Owner": "usr_8fK2mQ",
"Brief": ""
}
}
}'

Sending "" or null for a typed field clears it (Brief above). Unlike an unrecognised option name, a typed value that cannot be parsed — "next week" for a date, a token that is not a workspace member, a non-http URL — rejects the whole update with 422, and nothing in it is applied.


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: A field with that name holds no options

Creating an option field with the name of an existing typed field is refused:

{
"errors": [{ "message": "A Shoot Date field already exists and does not hold options" }]
}

Solution: pick a different name — a field's type cannot change.

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.



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