Manage custom objects with extensions
Custom objects let you add your own kinds of records to Vendasta CRM, such as appointments, vehicles or memberships, next to the standard ones like contacts and companies.
This guide shows how to create a custom object once, and then give it β with the same fields β to every SMB you choose. You can do all of it through the API, so you do not need to ask Vendasta to set it up for you.
The big pictureβ
You bundle your custom objects into an extension. Think of it as a package that you build and own. When you turn the extension on for an SMB, the SMB gets everything in the package. Turn it off, and it disappears for that SMB again, but the SMB's data is kept.
| Term | What it means | Example |
|---|---|---|
| Extension | A package you own. It holds custom object types and their fields. | "Acme Scheduling" |
| Custom object type | A kind of record you want to track. | "Appointment" |
| Field | One detail you track on that kind of record. | "Appointment Status" |
| SMB | One of your small and medium business customers. In the API it is an account group, with an ID like AG-β¦. | A salon |
| Record | One real item of that kind, in one SMB. | Jane's 10:30 appointment |
A few things are worth knowing before you start:
- Everyone gets the same type. Every SMB that has the extension sees the type with the same ID. That means your own system can use a single ID for all of them.
- SMBs can look, but not change. An SMB cannot rename or delete a type that comes from your extension. Only you, as the owner, can.
- Records are kept. Turning an extension off for an SMB hides its types, but the SMB's records are kept. Turn it back on and they return, with the same IDs.
Which ID goes whereβ
The API uses a field called namespace in almost every call. It is simply the ID of whoever owns the data you are working with. Which ID to use depends on the step:
| When you are⦠| Use as the namespace |
|---|---|
| Creating, renaming or deleting an extension | Your partner ID, for example ABC |
| Defining the types and fields inside an extension | The extension ID, for example Extension-1a2b3c⦠(you get it back when you create the extension) |
| Turning an extension on or off for an SMB, or working with that SMB's records | The SMB's ID, for example AG-EXAMPLE |
If you do not have your SMBs' IDs yet, you can list them with the List Business Locations API. To learn more about namespaces, see Understanding Namespaces.
Before you startβ
You need a Vendasta service account for your partner, and an access token that carries the right scopes.
- Create a service account in Partner Center and download its key file.
- Obtain an access token for it. Include the scopes below in the
scopeof your request.
A service account can work with your own partner and your own SMBs. If a call names another partner's ID, it fails with a 403 error.
Scopes to requestβ
A scope is a permission your token carries. Each API method accepts certain scopes, and your token needs at least one of them. For everything in this guide, request all four:
crm.schema crm.schema:read crm.custom-object crm.custom-object:read
| Scope | What it allows |
|---|---|
crm.schema | Create, change and delete extensions, types and fields, and turn extensions on and off for SMBs. |
crm.schema:read | Look at them. Ask for this one as well: the calls that list fields (field-schema/list and field-schema/get-multi) accept crm.schema:read but not crm.schema. |
crm.custom-object | Create, change and delete records. |
crm.custom-object:read | Look at records. |
Each method's page in the API reference lists the scopes it accepts. If your token has none of them, the call fails with a 403 that tells you which scopes would have worked.
Calling the APIβ
Every call is a POST with a JSON body. Send your token in the Authorization header.
| Environment | Base URL |
|---|---|
| Production | https://prod.apigateway.co/grpc |
| Demo | https://demo.apigateway.co/grpc |
Authorization: Bearer <access token>
Content-Type: application/json
You can send these calls with curl, any programming language's HTTP library, or an app like Postman or Insomnia. For Postman or Insomnia, open the CRM page in the API reference, use the Download link for the OpenAPI spec, and import it. Then set your token as the bearer token.
The examples below use curl against production. Use the demo address to practise first. Tokens only work in the environment they were made for.
Set up a custom object for your SMBsβ
The steps below create an "Appointment" custom object with a dropdown field, give it to one SMB, and create a record.
1. Create your extensionβ
This creates the empty package. Use your partner ID as the namespace.
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/field-extension/create' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"namespace": "PARTNER_ID",
"extension": {
"name": "Acme Scheduling",
"description": "Custom objects and fields from Acme Scheduling"
}
}'
What you send
| Field | What it is |
|---|---|
namespace | Your partner ID. You become the owner of the extension. |
extension.name | A short name for the package, usually the product or system the data comes from, such as "Acme Scheduling". This is required. |
extension.description | A note about what it is for. Optional. |
What you get back
{ "extensionId": "Extension-1a2b3c4d-1111-2222-3333-444455556666" }
extensionId is the ID of your new package. Keep it: you need it in every step below.
2. Add a custom object typeβ
Now add the kind of record you want to track. This time the namespace is the extension ID from step 1.
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/custom-object-type/create' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"namespace": "Extension-1a2b3c4d-1111-2222-3333-444455556666",
"customObjectType": {
"singularObjectName": "Appointment",
"pluralObjectName": "Appointments"
}
}'
What you send
| Field | What it is |
|---|---|
namespace | The extension ID. The type is created inside that extension. |
customObjectType.singularObjectName | What one record is called, such as "Appointment". |
customObjectType.pluralObjectName | What several are called, such as "Appointments". |
What you get back
{ "customObjectTypeId": "CustomObjectTypeID-9f8e7d6c-aaaa-bbbb-cccc-ddddeeeeffff" }
customObjectTypeId identifies your type. Every SMB that gets the extension sees this same ID.
An extension can hold up to 20 types.
3. Add fieldsβ
Fields are the details you track on each record. Create them in the extension, and tell the API which type they belong to.
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/field-schema/create' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"namespace": "Extension-1a2b3c4d-1111-2222-3333-444455556666",
"crmObjectType": "CustomObject",
"crmObjectSubtype": "CustomObjectTypeID-9f8e7d6c-aaaa-bbbb-cccc-ddddeeeeffff",
"fieldSchema": {
"fieldName": "Appointment Status",
"fieldDescription": "Where the appointment stands",
"fieldType": "FIELD_TYPE_DROPDOWN",
"externalId": "appointment_status",
"dropdownOptions": [
{ "label": "Booked", "value": "booked" },
{ "label": "Cancelled", "value": "cancelled" }
]
}
}'
What you send
| Field | What it is |
|---|---|
namespace | The extension ID. |
crmObjectType | The kind of CRM object the field belongs to. For custom objects this is always CustomObject. |
crmObjectSubtype | The ID of your type from step 2, so the field is attached to "Appointment". |
fieldSchema.fieldName | The label people see. |
fieldSchema.fieldDescription | A short explanation of the field. Optional. |
fieldSchema.fieldType | What kind of value the field holds. This example is a dropdown. See Field types you can use for the full list. |
fieldSchema.externalId | Your own identifier for the field. Optional. |
fieldSchema.dropdownOptions | For a dropdown: the choices. label is what people see, and value is what gets stored. |
What you get back
{ "fieldId": "FieldID-0a1b2c3d-1111-2222-3333-444455556666" }
fieldId is the ID of the new field. You use it when you create records.
Field types you can useβ
The fieldType you choose decides what kind of value the field holds, and which property carries that value when you create a record in step 6.
Show all field types
fieldType | What it holds | How you send the value in a record |
|---|---|---|
FIELD_TYPE_STRING | Free text | stringValue |
FIELD_TYPE_INTEGER | A whole number | integerValue, sent as text, for example "5" |
FIELD_TYPE_FLOAT | A number with decimals | floatValue, for example 1.5 |
FIELD_TYPE_BOOLEAN | Yes or no | booleanValue, either true or false |
FIELD_TYPE_DATE | A date | dateValue, as a full timestamp, for example "2026-10-21T00:00:00Z". A date on its own, such as "2026-10-21", is rejected. |
FIELD_TYPE_DATETIME | A date and time | dateValue, for example "2026-10-21T10:30:00Z" |
FIELD_TYPE_DROPDOWN | One choice from a list you define | stringValue, set to the value of one of the options |
FIELD_TYPE_CURRENCY | An amount of money | currencyValue, for example {"currencyCode": "USD", "value": 1234}. Also set currencyCode on the field when you create it. |
FIELD_TYPE_EMAIL | An email address | stringValue |
FIELD_TYPE_PHONE | A phone number | stringValue, for example "+13065551234" |
FIELD_TYPE_TAG | A set of tags | stringValues, for example {"values": ["vip", "new"]} |
FIELD_TYPE_STRING_LIST | A list of text values | stringValues, for example {"values": ["a", "b"]} |
FIELD_TYPE_GEOPOINT | A location on a map | geopointValue, for example {"lat": 52.13, "lon": -106.67} |
FIELD_TYPE_TIMER | A countdown to a deadline | timerValue. See the Create Field Schema reference for its shape. |
Dropdown fields also need dropdownOptions, and currency fields need a currencyCode. The other types do not need any extra settings.
4. Turn the extension on for an SMBβ
This is the step that gives the package to an SMB. Use the SMB's ID as the namespace. Repeat the call for every SMB that should have it.
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/added-field-extension/add' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"namespace": "AG-EXAMPLE",
"extensionId": "Extension-1a2b3c4d-1111-2222-3333-444455556666"
}'
What you send
| Field | What it is |
|---|---|
namespace | The ID of the SMB, such as AG-EXAMPLE. |
extensionId | The ID of your extension from step 1. |
What you get back
An empty response, {}. It means the extension is now on for that SMB.
5. Check what the SMB seesβ
List the SMB's custom object types. Your extension's types appear next to the SMB's own, with the same IDs.
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/custom-object-type/list' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{ "namespace": "AG-EXAMPLE" }'
You get back a list called customObjectTypes. Look for your "Appointment" type and its customObjectTypeId.
To see the type's fields for that SMB, list its field schemas:
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/field-schema/list' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"namespace": "AG-EXAMPLE",
"crmObjectType": "CustomObject",
"crmObjectSubtype": "CustomObjectTypeID-9f8e7d6c-aaaa-bbbb-cccc-ddddeeeeffff",
"pagingOptions": { "pageSize": 50 }
}'
pagingOptions.pageSize is how many results to return in one go. The response has a list called fieldSchemas. It contains your field as well as the standard fields every custom object has.
Find the field whose externalId is standard__customobject_name, and note its fieldId. This is the record's name, and every record needs one. You use it in the next step.
6. Create a record for an SMBβ
Records belong to the SMB, so use the SMB's ID as the namespace. Give each record a name and fill in your own fields.
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/custom-object/create' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"namespace": "AG-EXAMPLE",
"crmObject": {
"crmObjectSubtype": "CustomObjectTypeID-9f8e7d6c-aaaa-bbbb-cccc-ddddeeeeffff",
"externalId": "your-system-appointment-1042",
"fields": [
{ "fieldId": "<fieldId of standard__customobject_name>", "stringValue": "Jane Smith β 10:30" },
{ "fieldId": "FieldID-0a1b2c3d-1111-2222-3333-444455556666", "stringValue": "booked" }
]
}
}'
What you send
| Field | What it is |
|---|---|
namespace | The ID of the SMB the record belongs to. |
crmObject.crmObjectSubtype | The ID of your type, so the API knows this is an "Appointment". |
crmObject.externalId | Your own system's ID for this record. It is optional, and it lets you find the record again by your own ID. |
crmObject.fields | The values on the record. Each entry pairs a fieldId with its value. Include the name field, plus any of your own fields. Which property carries a value depends on the field's type: see Field types you can use. For a dropdown, send the choice's value, such as booked. |
What you get back
{ "crmObjectId": "CustomObjectID-5a4b3c2d-1111-2222-3333-444455556666" }
crmObjectId is the ID of the new record. Read records back with custom-object/get-multi, and delete them with custom-object/delete.
Change things laterβ
Rename the extension or a type. An update only changes the fields you list in fieldMask.paths. Everything else is left as it is. The example below renames the type, and every SMB that has the extension sees the new names.
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/custom-object-type/update' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"namespace": "Extension-1a2b3c4d-1111-2222-3333-444455556666",
"customObjectType": {
"customObjectTypeId": "CustomObjectTypeID-9f8e7d6c-aaaa-bbbb-cccc-ddddeeeeffff",
"singularObjectName": "Booking",
"pluralObjectName": "Bookings"
},
"fieldMask": { "paths": ["singularObjectName", "pluralObjectName"] }
}'
For the extension itself, call field-extension/update with "paths": ["name", "description"].
Add a field. Repeat step 3. SMBs that already have the extension see the new field.
Retire a field. Call field-schema/archive with the extension ID as the namespace, plus the crmObjectType, crmObjectSubtype and fieldId.
Remove an extensionβ
Work in the order below, because each step depends on the one before it. Each removal call returns an empty response, {}, when it succeeds.
1. Delete the records you no longer wantβ
Records belong to an SMB, so use the SMB's ID as the namespace. Repeat for each record.
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/custom-object/delete' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"namespace": "AG-EXAMPLE",
"crmObjectId": "CustomObjectID-5a4b3c2d-1111-2222-3333-444455556666"
}'
What you send
| Field | What it is |
|---|---|
namespace | The ID of the SMB the record belongs to. |
crmObjectId | The ID of the record, which you got back when you created it. |
What you get back
An empty response, {}.
2. Turn the extension off for each SMBβ
Use the SMB's ID as the namespace. Repeat for every SMB that has the extension turned on.
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/added-field-extension/remove' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"namespace": "AG-EXAMPLE",
"extensionId": "Extension-1a2b3c4d-1111-2222-3333-444455556666"
}'
What you send
| Field | What it is |
|---|---|
namespace | The ID of the SMB. |
extensionId | The ID of your extension. |
What you get back
An empty response, {}. The SMB no longer sees the extension's types, and its records are kept.
Not sure which SMBs still have it? Ask each SMB which extensions it has turned on:
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/added-field-extension/list' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"namespace": "AG-EXAMPLE",
"pagingOptions": { "pageSize": 50 }
}'
The response lists the IDs of the extensions that SMB has on. If yours is not in the list, it is off for that SMB. The list is paged, so if an SMB has many extensions, see Paging.
{
"extensionIds": ["Extension-1a2b3c4d-1111-2222-3333-444455556666"],
"pagingMetadata": { "nextCursor": "β¦" }
}
3. Archive the extension's fieldsβ
Fields live in the extension, so the namespace is the extension ID. Repeat for each field.
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/field-schema/archive' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"namespace": "Extension-1a2b3c4d-1111-2222-3333-444455556666",
"crmObjectType": "CustomObject",
"crmObjectSubtype": "CustomObjectTypeID-9f8e7d6c-aaaa-bbbb-cccc-ddddeeeeffff",
"fieldId": "FieldID-0a1b2c3d-1111-2222-3333-444455556666"
}'
What you send
| Field | What it is |
|---|---|
namespace | The extension ID. |
crmObjectType | The kind of CRM object the field belongs to. For custom objects this is always CustomObject. |
crmObjectSubtype | The ID of the type the field belongs to. |
fieldId | The ID of the field to archive. |
What you get back
An empty response, {}.
4. Delete the extension's typesβ
The namespace is the extension ID. Repeat for each type.
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/custom-object-type/delete' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"namespace": "Extension-1a2b3c4d-1111-2222-3333-444455556666",
"customObjectTypeId": "CustomObjectTypeID-9f8e7d6c-aaaa-bbbb-cccc-ddddeeeeffff"
}'
What you send
| Field | What it is |
|---|---|
namespace | The extension ID. |
customObjectTypeId | The ID of the type to delete. |
What you get back
An empty response, {}.
5. Delete the extensionβ
Use your partner ID as the namespace, the same as when you created the extension.
curl -X POST 'https://prod.apigateway.co/grpc/v1/crm/field-extension/delete' \
-H 'Authorization: Bearer <access token>' \
-H 'Content-Type: application/json' \
-d '{
"namespace": "PARTNER_ID",
"extensionId": "Extension-1a2b3c4d-1111-2222-3333-444455556666"
}'
What you send
| Field | What it is |
|---|---|
namespace | Your partner ID. |
extensionId | The ID of the extension to delete. |
What you get back
An empty response, {}.
The API will not delete an extension while any SMB still has it turned on. Instead you get an error, with HTTP status 400 and code 9, that tells you to turn it off for those SMBs first. Use the list call in step 2 to find them.
{
"code": 9,
"message": "extension Extension-1a2b3c4d-1111-2222-3333-444455556666 is still turned on for one or more accounts: remove it from those accounts before deleting it"
}
If the extension was turned on for another partner's SMBs, only Vendasta or that partner can turn it off.
Delete the types and fields before you delete the extension. Once the extension is gone, you can no longer change its types and fields. Deleting a type is also permanent: there is no way to restore it, and it is not blocked when records of that type still exist. Delete those records first.
Any partner can turn an extension on for its own SMBs if it knows the extension ID. Only the owner can change or delete it.
Limitsβ
- An extension can hold up to 20 custom object types.
- An SMB can have up to 3 custom object types of its own. Types that come from extensions do not count toward that limit.
Which API for which jobβ
| I want to⦠| Call | Scopes (any one) |
|---|---|---|
| Create an extension | POST /v1/crm/field-extension/create | crm, crm.schema |
| Look up extensions by ID | POST /v1/crm/field-extension/get-multi | crm, crm.schema, crm.schema:read |
| Rename an extension | POST /v1/crm/field-extension/update | crm, crm.schema |
| Delete an extension | POST /v1/crm/field-extension/delete | crm, crm.schema |
| Turn an extension on for an SMB | POST /v1/crm/added-field-extension/add | crm, crm.schema |
| Turn it off for an SMB | POST /v1/crm/added-field-extension/remove | crm, crm.schema |
| See which extensions an SMB has | POST /v1/crm/added-field-extension/list | crm, crm.schema, crm.schema:read |
| Check whether an SMB has specific extensions | POST /v1/crm/added-field-extension/get-multi | crm, crm.schema, crm.schema:read |
| Add a custom object type | POST /v1/crm/custom-object-type/create | crm, crm.schema |
| See custom object types | POST /v1/crm/custom-object-type/list, β¦/get-multi | crm, crm.schema, crm.schema:read |
| Rename a custom object type | POST /v1/crm/custom-object-type/update | crm, crm.schema |
| Delete a custom object type | POST /v1/crm/custom-object-type/delete | crm, crm.schema |
| Add, change or retire a field | POST /v1/crm/field-schema/create, β¦/update, β¦/archive | crm, crm.schema |
| See fields | POST /v1/crm/field-schema/list, β¦/get-multi | crm, crm.schema:read |
| Create, change or delete a record | POST /v1/crm/custom-object/create, β¦/update, β¦/delete | crm, crm.custom-object |
| See records | POST /v1/crm/custom-object/get-multi, β¦/list | crm, crm.custom-object, crm.custom-object:read |
When something goes wrongβ
A failed call returns an HTTP status and a JSON body with a code, a message and sometimes details. See Errors for the full shape.
| Status | code | What it usually means | What to do |
|---|---|---|---|
| 401 | 16 | The token is missing, wrong or has expired. | Get a new token. |
| 403 | 7 | The message says missing one of the required scopes. Your token does not carry a permission this call needs. | Get a new token with one of the scopes the message lists. |
| 403 | 7 | The message says Permission Denied. The token is fine, but your service account is not allowed to act on that ID. For example, the ID belongs to another partner, or the type belongs to an extension you do not own. | Check the ID you used as the namespace, and who owns the extension. |
| 400 | 3 | The request was not accepted. It might have a missing or misspelled field, or you may have reached a limit (3 own types per SMB, 20 types per extension). | Read the message and details. They name the field or the limit. |
| 400 | 9 | You tried to delete an extension that SMBs still have turned on. | Turn it off for those SMBs, then delete it. |
| 404 | 5 | The extension, type, field or record was not found. | Check the ID, and that you are using the right environment. |