Custom APIs
A custom API is a message that a solution defines: a name, how it is bound, its request parameters, its response
properties and, usually, the plug-in type that does the work. xrmverse imports the custom API definitions of a solution
exported from Dataverse, lets you create and change them directly (see Authoring), and runs them: they can
be called through the SDK (OrganizationRequest by name), the SOAP endpoint and the Web API, with the plug-in type as
the main operation. Custom actions (a custom sdkmessage row) keep working as before; see
Plug-ins. XAML actions (classic processes of category 3) are a third kind of message; see
Custom APIs, XAML actions and custom actions.
The design is in plan 15 (Global Constraints and rulings C1–C12). Microsoft's own description of the feature is on Learn: Create and use custom APIs.
What is imported
The customapis/ folder of a solution file, in the layout a Dataverse export writes:
customapis/{uniquename}/customapi.xml, one per API;customapis/{uniquename}/customapirequestparameters/{name}/customapirequestparameter.xml, one per request parameter;customapis/{uniquename}/customapiresponseproperties/{name}/customapiresponseproperty.xml, one per response property.
Every binding (global, table, table collection), actions and functions, private APIs, all 13 field types, the three
values of allowedcustomprocessingsteptype and APIs without a plug-in type are supported. The plug-in type is named by
plugintypeexportkey (or plugintypeid), which must be the id of a plug-in type in the same file or already in the
organization; a workflow activity is refused.
The import fails as a whole (400 0x80040203, naming the API and what is wrong, nothing written) for:
- a Power Fx (low-code) API, recognized by
powerfxruleidorfxexpressionid; - a function that is also enabled for workflows;
isoptionalon a response property;- a binding, step type or field type out of range;
- a bound API without
boundentitylogicalname, a global API with one, or a table that is neither in the organization nor in the file; - a
logicalentitynameon a type that takes none (only Entity, EntityReference and, for request parameters, EntityCollection take one), or naming an unknown table; - two parameters or two properties with the same name (ignoring case), or a parameter named
Targeton a table-bound API (the server addsTargetitself); - a unique name that does not start with the prefix of an existing publisher followed by
_, that has characters other than letters, digits and_, or that equals (ignoring case) a message the server handles itself or an existing custom action; - a global API whose unique name is exactly the entity set name of a table of the organization or of the file (the Web API could not reach it);
- an unknown plug-in type, or a workflow activity;
- on a re-import, a change to a property that cannot change once created:
allowedcustomprocessingsteptype,bindingtype,boundentitylogicalname,isfunctionandworkflowsdkstepenabledof the API,isoptional,logicalentitynameandtypeof a parameter,logicalentitynameandtypeof a property.
An executeprivilegename that names no privilege of the organization is imported, with a warning in the import job log:
nobody holds such a privilege, so only System Administrators and internal calls can run the API.
workflowsdkstepenabled is stored, and validated with isfunction at authoring (above), but a perform-action step does not
check it: a classic workflow's "perform action" step sends its request by message name through the pipeline, so it reaches a
custom API whatever the flag says (see Classic workflows). statecode has
no effect, as on Dataverse.
Custom APIs, XAML actions and custom actions
Three kinds of message can be called by name with an OrganizationRequest. A name is unique across them: a XAML action whose
message name is taken by a custom API, and a custom API whose name is taken by an action's message, are refused.
| Custom API | XAML action (category 3 process) | Custom action | |
|---|---|---|---|
| Defined by | the customapi tables (authoring or the customapis/ folder) |
a <Workflow> of category 3 in a solution; its message is created at import and activation |
an sdkmessage row |
| Request check | declared parameters, before stage 10 (400 0x80040203) |
the XAML's x:Members arguments, with the same rules and code |
none |
| Stage 30 | its plug-in type | the XAML, as the caller | nothing |
| Privilege | executeprivilegename |
none beyond those of the steps | none |
| Response | completed to the declared properties | the outputs the XAML set | what the steps set |
| Web API | an action or function in $metadata |
not exposed (POST /<name> is 404) |
not exposed |
See Classic workflows for the message name, the state rules and deletion of a XAML action.
The tables
| Table | Entity set | Object type code | Contents |
|---|---|---|---|
customapi |
customapis |
9990 | uniquename, name, displayname, description, bindingtype (choice customapi_bindingtype: 0 Global, 1 Entity, 2 Entity Collection), boundentitylogicalname, isfunction, isprivate, allowedcustomprocessingsteptype (choice customapi_allowedcustomprocessingsteptype: 0 None, 1 Async Only, 2 Sync and Async), executeprivilegename, workflowsdkstepenabled, plugintypeid (lookup to plugintype), sdkmessageid (lookup to sdkmessage), statecode/statuscode |
customapirequestparameter |
customapirequestparameters |
9991 | uniquename, name, displayname, description, customapiid, type (choice customapifieldtype), isoptional, logicalentityname |
customapiresponseproperty |
customapiresponseproperties |
9992 | the same columns without isoptional |
All three are user-owned. Relationships: plugintype_customapi (Delete Restrict), sdkmessage_customapi,
customapi_customapirequestparameter and customapi_customapiresponseproperty (both Delete Cascade). The single-valued
navigation property from customapi to its plug-in type is plugintypeid.
Field types (customapifieldtype) and the SDK type a value must have:
| Value | Name | SDK type | Edm type in $metadata |
|---|---|---|---|
| 0 | Boolean | bool |
Edm.Boolean |
| 1 | DateTime | DateTime |
Edm.DateTimeOffset |
| 2 | Decimal | decimal |
Edm.Decimal |
| 3 | Entity | Entity |
the entity type of logicalentityname, else crmbaseentity |
| 4 | EntityCollection | EntityCollection |
Collection(crmbaseentity) |
| 5 | EntityReference | EntityReference |
the entity type of logicalentityname, else crmbaseentity |
| 6 | Float | double |
Edm.Double |
| 7 | Integer | int |
Edm.Int32 |
| 8 | Money | Money |
Edm.Decimal |
| 9 | Picklist | OptionSetValue |
Edm.Int32 |
| 10 | String | string |
Edm.String |
| 11 | StringArray | string[] |
Collection(Edm.String) |
| 12 | Guid | Guid |
Edm.Guid |
Basic User reads the three tables at Global depth; System Customizer holds every privilege on them. Every organization has these grants: one created before custom APIs receives them once at startup (see Security).
Messages and filters. Each API owns one sdkmessage row (name = the unique name, categoryname CustomAPI,
isprivate = the API's flag) and one sdkmessagefilter row (the bound table, or none for a global API;
iscustomprocessingstepallowed is false when the API allows no steps). customapi.sdkmessageid points to the message.
Authoring
The three tables are written like any other table, over the SDK, SOAP and the Web API, by a user who holds the Create,
Write or Delete privilege on them (System Administrator, System Customizer). A custom API made this way is callable by
the next request, with no restart, and $metadata lists it. The API's sdkmessage and sdkmessagefilter rows are
created, updated and deleted by the server; writing them directly, or creating an sdkmessage with an API's name, stays
refused with 400 ("the message and filter of a custom API are maintained by the server; write the customapi row
instead"). The prefix of the unique name may be that of any existing publisher, as for tables and columns; the solution
you add the API to need not belong to that publisher.
Create the API, then each request parameter and response property with its own Create; a parameter or property names its
API in customapiid. Every check below that fails is 400 0x80040203 (InvalidArgument) naming the API, and the
parameter or property where there is one. Nothing is written when one fails.
SDK
var apiId = service.Create(new Entity("customapi")
{
["uniquename"] = "new_Ping", ["name"] = "Ping", ["displayname"] = "Ping",
["bindingtype"] = new OptionSetValue(0), // 0 Global, 1 Entity, 2 Entity Collection
["isfunction"] = false,
["allowedcustomprocessingsteptype"] = new OptionSetValue(0), // 0 None, 1 Async Only, 2 Sync and Async
["plugintypeid"] = new EntityReference("plugintype", pluginTypeId), // optional
});
service.Create(new Entity("customapirequestparameter")
{
["uniquename"] = "Text", ["name"] = "Text", ["displayname"] = "Text",
["type"] = new OptionSetValue(10), // String
["isoptional"] = false,
["customapiid"] = new EntityReference("customapi", apiId),
});
service.Create(new Entity("customapiresponseproperty")
{
["uniquename"] = "TextOut", ["name"] = "TextOut", ["displayname"] = "TextOut",
["type"] = new OptionSetValue(10),
["customapiid"] = new EntityReference("customapi", apiId),
});
SOAP. The same Create requests as the SDK sends: CreateRequest with the customapi entity, then with the
parameters and properties.
Web API. The lookups are set with @odata.bind: plugintypeid@odata.bind on the API and CustomAPIId@odata.bind
(the navigation property of the customapiid lookup) on a parameter or property. Add the header
MSCRM.SolutionUniqueName: MySolution to add the API to that solution (a parameter or property travels with its API).
POST /api/data/v9.2/customapis
{ "uniquename": "new_Ping", "name": "Ping", "displayname": "Ping", "bindingtype": 0, "isfunction": false,
"allowedcustomprocessingsteptype": 0, "plugintypeid@odata.bind": "/plugintypes(00000000-0000-0000-0000-000000000000)" }
POST /api/data/v9.2/customapirequestparameters
{ "uniquename": "Text", "name": "Text", "displayname": "Text", "type": 10, "isoptional": false,
"CustomAPIId@odata.bind": "/customapis(11111111-1111-1111-1111-111111111111)" }
POST /api/data/v9.2/customapiresponseproperties
{ "uniquename": "TextOut", "name": "TextOut", "displayname": "TextOut", "type": 10,
"CustomAPIId@odata.bind": "/customapis(11111111-1111-1111-1111-111111111111)" }
CLI. xrmd customapi create --file ping.json does the same in one command; see CLI.
Rules on Create. The same definition rules as the import (see What is imported) apply, with the same refusals; in addition:
- the unique name is unique across custom APIs, ignoring case: "Custom API '{name}' already exists." Two concurrent Creates of one name serialize, and the second gets this error;
- a field needs
uniquename,customapiid(an existing API) andtype; a field refusal reads "Custom API '{api}' cannot take the request parameter '{p}': {reason}." (or "response property"); sdkmessageidmust not be given, with any value: the server creates the message and filter itself (message id and filter id as the import derives them) in the same transaction, so a Create that fails halfway leaves no message;plugintypeidmust name an existing plug-in type that is not a workflow activity;- a column left out is stored as an import stores it:
isprivate,workflowsdkstepenabledandisfunctionas false,bindingtypeandallowedcustomprocessingsteptypeas 0, andisoptionalof a request parameter as false, so a Retrieve or GET reads back like an imported API; boundentitylogicalnameandlogicalentitynameare stored in lower case and an empty one as none; an Update that repeats the stored value in another case, or an empty one for none, is not a change;- an
executeprivilegenamethat names no privilege of the organization is accepted, with the warning the import logs in the server log (no user holds it, so only System Administrators can call the API); RelatedEntitieson the Target of the Create (deep insert) is refused: "Creating related records in the same request is not supported for {table}: create the request parameters and response properties with their own Create." A Web API body that nests the parameters in the API is refused too, 400, becausecustomapihas no collection-valued navigation properties. Create the fields with their own requests.
Cannot be changed after Create. An Update that repeats the stored value is accepted (boundentitylogicalname and
logicalentityname in any case, or empty for none); a different value, a case
change of uniquename included, is refused: "{column} of custom API '{name}' cannot be changed once created." For a
parameter or property the name reads "'{api}' request parameter '{p}'" (or "response property").
| Table | Immutable columns |
|---|---|
customapi |
uniquename, bindingtype, boundentitylogicalname, isfunction, allowedcustomprocessingsteptype, workflowsdkstepenabled, sdkmessageid |
customapirequestparameter |
uniquename, type, isoptional, logicalentityname, customapiid |
customapiresponseproperty |
uniquename, type, logicalentityname, customapiid |
You can change name, displayname, description, isprivate (also written to the API's message), executeprivilegename
and plugintypeid of an API, name, displayname and description of a field, and ownerid, statecode and
statuscode as for any user-owned row. Because customapiid cannot change, Associate and Disassociate on
customapi_customapirequestparameter and customapi_customapiresponseproperty are refused, even to the same API.
isoptional on a response property is refused as an unknown attribute (400), and so is iscustomizable on any of the
three tables: it is not a column in xrmverse (a managed file sets it, see Solutions).
Adding a required parameter to an API that callers already use works at once: the very next call without it gets the 400 of Validation and error codes.
Delete. Deleting a customapi removes its parameters and properties, then its filter and message. It is refused while
a step (sdkmessageprocessingstep) is registered on its message: "Custom API '{name}' cannot be deleted: the step
'{step name}' ({id}) is registered on its message. Delete the step first." Delete the step, then the API. (Whether
Dataverse refuses too is not verified.) A parameter or property can be deleted alone.
Managed APIs. An API that a managed solution delivered (ismanaged = 1) is not changed in place: any Update of it, and
any Create, Update or Delete of its parameters or properties, is refused: "Custom API '{name}' is managed: change it in
its solution and import an update." Deleting the API row is refused by the generic rule for managed components;
uninstall the solution instead. This is stricter than Dataverse, which lets a managed API that is marked customizable be
changed.
Solutions. With the MSCRM.SolutionUniqueName header the API
becomes a component of the solution, and an export writes the customapis/ folder back from the rows; an authored API
exports and imports into an empty organization unchanged, with the plug-in type and its assembly in the solution too.
Calls by internal code (solution import, restore, cascade delete) skip these checks and keep the import's own.
Plug-in. The plug-in type that does the work is built and registered as for any plug-in (see Plug-ins); registering it with the Plug-in Registration Tool is optional and needs that Microsoft tool, which you bring yourself.
Binding and calling
SDK and SOAP. An OrganizationRequest whose RequestName is the API's unique name (ignoring case), with one entry
per parameter; for a table-bound API, Target is an EntityReference to a record of the bound table. The response's
Results hold every declared response property, and its ResponseName is the request name as the caller spelled it.
Through ServiceClient this is an ordinary Execute:
var response = client.Execute(new OrganizationRequest("op365_ExpireAgentProposals"));
var count = (int)response.Results["Count"];
A plug-in can call any custom API through its organization service, private ones included.
Web API. The operation name is matched exactly (case-sensitive), like entity set names.
| Binding | Action | Function |
|---|---|---|
| Global | POST /api/data/v9.2/{name} |
GET /api/data/v9.2/{name}(p1=@a)?@a=… |
| Table | POST /api/data/v9.2/{set}({id})/Microsoft.Dynamics.CRM.{name} |
GET on the same path, with a parameter list |
| Table collection | POST /api/data/v9.2/{set}/Microsoft.Dynamics.CRM.{name} |
GET on the same path, with a parameter list |
- Global operations also accept the
Microsoft.Dynamics.CRM.prefix; bound operations require it. {set}must be the entity set of the bound table. Any other set, a bound operation without the prefix, aGETof an action and aPOSTof a function are all the usual 4040x80060888for an unknown segment.- For a table-bound API the record in the URL is the
Target; aTargetin the body or the parameter list is a 400. {set}({id})/{navigation property}({key})is the same 400 as{set}({id})/{navigation property}: navigation property paths are not served; use$expand.tagis passed in the query string,?tag=value, on actions and functions; the plug-ins get it as the request parametertag. Atagin the body or the parameter list, or given twice, is a 400.- Action bodies are JSON: numbers, strings, booleans, ISO dates, GUIDs as strings, string arrays as JSON arrays, Picklist
as an integer, Money and Decimal as numbers (or strings with
IEEE754Compatible=true). Records use thecrmbaseentityform:{"@odata.type":"Microsoft.Dynamics.CRM.account","name":"…"}for an Entity, an array of those for an EntityCollection, and{"@odata.type":"Microsoft.Dynamics.CRM.account","accountid":"…"}(the type and the primary key only) for an EntityReference. An Entity or a record of an EntityCollection can leave out@odata.typewhen the parameter has alogicalentityname. - Function parameters are inline literals or
@aliasvalues from the query string:'text', numbers,true/false,null, bare GUIDs and dates, and JSON (with single quotes) for records and string arrays, for example{'@odata.id':'accounts(…)'}. - Responses:
- no response property: 204;
- exactly one property, of type Entity: that record with
@odata.context$metadata#{set}/$entity(204 when the plug-in left it unset); - exactly one property, of type EntityCollection:
{ "value": [...] }with@odata.context$metadata#Collection(Microsoft.Dynamics.CRM.crmbaseentity); - otherwise: 200 with
@odata.context$metadata#Microsoft.Dynamics.CRM.{name}Responseand one JSON property per response property.
- A function without response properties cannot be called on the Web API: 404
0x8006088a.
Validation and error codes
Before anything runs (no privilege check, no step, no plug-in), the request is checked against the definition, in this
order. Every failure is 400 0x80040203 (InvalidArgument) naming the API and the parameter.
- A table-bound API needs
Target, anEntityReferenceto the bound table. The record itself is not read: its existence and the caller's access to it are not checked. - Every parameter that is not optional must be present and not null.
- Every value must already have the SDK type of the table above; nothing is converted (an
intfor a Picklist is refused, so is a string for a Guid). An Entity, EntityReference or EntityCollection parameter with alogicalentitynamemust name that table for every record. - Any other parameter name is refused, except
tag, andTargeton a table-bound API. Parameter names match exactly.
Then, when the API has an executeprivilegename, the caller must hold that privilege at any depth, or the call is 403
0x80040220 (PrivilegeDenied). System Administrators and internal calls pass.
On the Web API a JSON value or a literal that does not fit the declared type is the same 400 0x80040203, naming the
parameter; the remaining checks are the SDK's, because the Web API builds the same request.
| Case | HTTP | Code |
|---|---|---|
Missing required parameter, wrong type, undeclared parameter, wrong Target |
400 | 0x80040203 |
Caller lacks executeprivilegename |
403 | 0x80040220 |
The main operation throws InvalidPluginExecutionException |
400 | the plug-in's |
| The main operation throws anything else, or sets a response property to a value of the wrong type | 500 | 0x80040224 |
| Web API: unknown operation, wrong set, wrong verb, missing prefix on a bound operation | 404 | 0x80060888 |
| Web API: function without response properties | 404 | 0x8006088a |
The main operation and steps
The pipeline runs a custom API like any other message, with stage 30 filled in:
| Stage | What runs |
|---|---|
| 10, 20 | registered steps on the API's message, if the API allows them |
| 30 | the API's plug-in type, once, synchronously, inside the transaction: Stage 30, Mode 0, the caller as UserId, no configuration, no images, the same InputParameters the earlier stages saw. No plug-in type: nothing runs |
| 40 | registered steps (synchronous and asynchronous), if the API allows them |
PrimaryEntityName is the bound table, or none for a global API; PrimaryEntityId is the Target id for a table-bound
API and empty otherwise. A failure rolls the transaction back and follows the plug-in rules of Plug-ins.
The main operation is not a registered step: its context and trace rows carry the step id Guid.Empty.
After stage 30, and again after the synchronous stage 40 steps:
- a declared response property nobody set is returned with the default of its SDK type (
false,0,Guid.Empty,DateTime.MinValue, or null for reference types); - a declared property set to a value of another type is 500
0x80040224naming it; a null value for a value type (Boolean, DateTime, Decimal, Float, Integer, Guid) counts as another type; - an output that is not declared is dropped from the response, and the server logs one warning naming the API and the keys.
Steps on the API's message follow allowedcustomprocessingsteptype, for registrations through the SDK, the Web API
and solution import: 0 refuses every step (400); 1 accepts only asynchronous stage 40 steps; 2 accepts stages 10, 20 and
40 as for any message. Stage 30 is never registrable.
$metadata
Every API that is neither private nor a function without response properties is declared in the
Microsoft.Dynamics.CRM namespace as a non-composable Action or Function:
- bound APIs have a first parameter
entity(the bound table's entity type) orentityset(a collection of it); global ones are unbound and have anActionImportorFunctionImport; - one parameter per request parameter, with the Edm type of the table above,
Nullablewhen optional; - the return type is nothing, the entity type or
Collection(crmbaseentity)for a single Entity or EntityCollection response property, or otherwise the complex type{name}Response, with one property per response property (Entity and EntityCollection properties are navigation properties of that type).
An API is left out, with a server warning naming it, when its bound table is not in the model, when its name or its
{name}Response is already a name of the model, when it is global and its name is already an entity set of the
model, or when a request parameter has the name of its binding parameter. An
unknown logicalentityname is typed crmbaseentity, also with a warning. The model is rebuilt when an import or a delete
changes the custom APIs.
Solutions
- An API is a solution component with
componenttype9990. Its parameters and properties travel with it and are not components of their own. As in the real Dataverse export, APIs are not listed underRootComponentsofsolution.xml. - Import order: plug-in assemblies and types, then the custom APIs (with their messages and filters), then the steps, so a step exported by xrmverse on an API's message imports back (for a global API's message, see Known limits).
- A re-import updates
name,displayname,description,isprivate,executeprivilegenameand the plug-in type, and adds new parameters and properties. Parameters and properties missing from the file stay, as unmanaged imports are additive. An identical re-import changes nothing. iscustomizableand the full label sets ofdisplaynameanddescriptionare kept as opaque XML (xrmverse_componentxml) and written back on export.iscustomizableis not a column:$select=iscustomizableis the usual 400 for an unknown column.- Export writes the
customapis/folder back from the rows; the files of an imported Dataverse export come out equal to the originals after XML normalization. - Dependencies: deleting the plug-in type of an API, its assembly, or the API's bound table is refused with 400 naming the API.
- Deleting a solution (
Deleteof thesolutionrow) removes only the container, as for every component. The xrmverse-only cascade delete (xrmd solution delete --cascade,DELETE /admin/solutions/{name}?cascade=true) also removes the solution's APIs, their parameters, properties, message and filter, and the steps on the message with their images; a step that belongs to another solution blocks it with 400 naming the step, and a step registered while the delete runs ends it with 409 and nothing deleted.
CLI, MCP and web admin
The CLI authors; the MCP tool and the web admin only read. Create an API with the CLI, the SDK, the Web API or a solution import.
- CLI:
xrmd customapi listandxrmd customapi show <uniquename>(both with-o json),xrmd customapi create --fileandxrmd customapi delete <uniquename>; see CLI. - MCP: the
list_custom_apistool, read-only; see MCP. - Web admin: the Custom APIs tab of the Plug-ins page, read-only; see Web admin.
Known limits
Each item is refused with an error or stated here; none is a silent difference.
Not in this version
Xrm.WebApi.executeand the client scripting API: the model-driven UI is phase 3.- Business events and the
catalogandcatalogassignmenttables. - Background operations.
workflowsdkstepenabledis stored, and validated withisfunctionat authoring, but not checked by a perform-action step: a classic workflow's perform action step can call any custom API.- Power Fx (low-code) custom APIs: the import is refused.
- A step from a Dataverse export on a custom API's message: the step carries Dataverse's id for that message, which xrmverse cannot map, so the import fails with 400 "references the SDK message …". Steps exported by xrmverse import back.
- A step on a global API's message keeps its
nonefilter on import only when the API is in the same solution file; otherwise it imports without a filter. - Managed properties (
iscustomizableis kept as opaque XML; managed solutions are item 2.6).
Differences and choices to verify against Dataverse (see the checklist)
- The object type codes 9990–9992 and the component type 9990 are xrmverse's own. Dataverse gives these tables a code of 10000 or more that changes per environment; the real export used by the tests has no root component for its APIs.
- Web API operation names are case-sensitive, while the SDK path ignores case.
- A bound Web API operation without the
Microsoft.Dynamics.CRM.prefix is 404; aTargetin the body of a table-bound call is 400. - Of the optional parameters Dataverse accepts on every message, only
tagis accepted; others (such asSuppressDuplicateDetectionorBypassCustomPluginExecution) are refused as undeclared. - A
Targeton a global or collection-bound API is refused as an undeclared parameter. - On the Web API an entity set takes precedence over a global operation of the same name. A solution whose global API is
named like an entity set of the organization or of the file is refused at import (400 naming the table). A table
created later with an API's name as its entity set takes
POST /{name}over, and$metadataleaves the API out with a server warning. - The 403 for a global API's
executeprivilegenamenames the entitynone. - A plug-in that sets a value-type response property to null gets 500
0x80040224, not the default value. - The main operation runs as a stand-in step with id
Guid.Empty:OwningExtensionand thepluginstepidof its trace rows name no step. $metadata: an optional parameter of a collection type (StringArray, EntityCollection) is declaredNullable="false", because CSDLNullableon a collection describes its elements; the document declares noAlias="mscrm"for the namespace.- The navigation property to the plug-in type is
plugintypeid(lowercase; bind withplugintypeid@odata.bind); Learn writesPluginTypeId. - Authoring: no deep insert (create the fields with their own Creates); no
iscustomizablecolumn; a managed API is never changed in place, where Dataverse allows it when the API is customizable; a Create takes the prefix of any existing publisher, not only that of the solution; a Delete is refused while a step is registered on the API's message, and the unique-name check, the immutable columns and the refusal texts follow Learn's description and are not verified against Dataverse; a Create through the CLI is not atomic on the server (it deletes the API again when a field is refused; if the process dies in between,xrmd customapi deleteremoves the rest). customapihas no collection-valued navigation properties to its parameters and properties (CustomAPIRequestParameters,CustomAPIResponseProperties): query the two tables with a filter on_customapiid_value. The CLI and the web admin do so.- The response type check does not enforce
logicalentitynameon Entity and EntityReference response properties. - Cascade delete: the API's message and filter rows go with the API, without a separate Delete check on
sdkmessageandsdkmessagefilter. - Not verified: the relationship, choice and navigation property names,
categorynameCustomAPI, whetherplugintypeexportkeyalways equalsplugintypeid, the error codes Dataverse uses for each validation failure (it may use0x80060387for a type mismatch),PrimaryEntityNamenonefor a global API, the binding parameter namesentityandentityset, whether a private API is callable from a plug-in, and whether an output a plug-in sets without declaring it is dropped.
The manual acceptance checklists are the authoring checklist and
docs/superpowers/acceptance/custom-api-checklist.md.