# 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")

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[​](#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",

      "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[​](#assigning-field-values-to-assets "Direct link to 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[​](#on-asset-update "Direct link to 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[​](#typed-fields-date-person-and-url "Direct link to 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](#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 type | Value to send                                                          | Reads back as                                                                      |
| ---------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| Date       | ISO 8601 date, `YYYY-MM-DD`                                            | The same date                                                                      |
| Person     | A workspace member's user token (see [People](/docs/guides/people.md)) | The user token while the account exists; the person's last name once it is deleted |
| URL        | An `http` or `https` URL                                               | The 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[​](#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")

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[​](#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}`,

        },

        // 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[​](#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: 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.

***

## 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
