Write a plugin
A plugin is a .NET class library that references the Hostpanel SDK and adds a payment gateway, a provisioning module, a domain registrar or another capability. The plugin never references the Hostpanel host directly — only the SDK.
Before you start
The SDK project lives at backend/src/Hostpanel.Sdk in the repository. Reference it from your class library, or reference the built Hostpanel.Sdk.dll directly. This guide is written for Hostpanel.Sdk 3.1.0, so build against that version or a later 3.x one: a package built against an older SDK major is refused with PLUGIN_SDK_ASSEMBLY_OUTDATED. The SDK repository also publishes a second package, Hostpanel.Sdk.Packaging, versioned in step with the SDK (3.1.0). It holds the checks the host runs on a package, so you can run them on your own build before you upload it: the signature format and verification, the plugin id and version rules, reserved assembly names, the archive limits and path checks, the icon check and the structural manifest rules. Reference it from your build or release tooling only, never from the plugin assembly itself. The bundled plugins in backend/src/Innovayse.Providers.* are the reference implementations; read their code before writing your own.
Name the project
Name a plugin project <Company>.Hostpanel.Plugins.<Name>, the .NET plugin convention <Company>.<Product>.<Type>.<Name> (as in Serilog.Sinks.Console). The project, its folder, its assembly and its root namespace all carry that name, and its test project adds .Tests. Innovayse's own plugins are Innovayse.Hostpanel.Plugins.<Name>, with tests in Innovayse.Hostpanel.Plugins.<Name>.Tests, each in its own repository innovayse/hostpanel-<name>: Innovayse Sites, for example, is Innovayse.Hostpanel.Plugins.Sites in innovayse/hostpanel-sites. The bundled Innovayse.Providers.* projects predate the convention and are renamed as they move to their own repositories.
The plugin id in plugin.json is not part of the name and never changes when a project is renamed: the host keys the plugin's stored settings and data by it.
Plugin types
The PluginType enum defines what a plugin can be:
| Type | Value | Covered in this guide |
|---|---|---|
Provisioning | 0 | Yes — see below |
Payment | 1 | Yes — see below |
Registrar | 2 | Listed only |
Identity | 3 | Listed only |
Fulfilment | 4 | Listed only |
Hosted | 5 | Listed only |
App | 6 | Listed only |
A payment plugin
Inherit PaymentGatewayBase and implement IPaymentPlugin from Hostpanel.Sdk.Plugins.Payment.
Required members
| Member | Description |
|---|---|
string CurrencyCode | ISO 4217 numeric currency code this gateway settles in (e.g. "840" for USD, "051" for AMD) |
Task<PaymentSession> CreatePaymentAsync(PaymentInstruction instruction, CancellationToken ct) | Start a payment session and return a redirect URL |
Task<GatewayPaymentStatus> GetStatusAsync(string gatewayOrderId, CancellationToken ct) | Poll the gateway for the current state |
Task<string> RefundAsync(string gatewayOrderId, long amountMinor, CancellationToken ct) | Issue a refund; return the gateway's refund reference |
Types
PaymentInstruction properties: OrderNumber, AmountMinor, ReturnUrl, Description, Language.
PaymentSession properties: GatewayOrderId, RedirectUrl.
GatewayPaymentStatus properties: State (GatewayPaymentState), TransactionId, RawStatus, AmountMinor, Currency (ISO 4217 numeric).
AcceptsCurrency(string currencyNumeric) has a default implementation that compares against CurrencyCode; override it only when the gateway accepts currencies other than its settlement currency.
GatewayPaymentState enum: Pending (0), Paid (1), Declined (2).
Reading settings
Call GetConfig(key) (from PaymentGatewayBase) to read a setting. Settings are stored as integration:{pluginId}:{key} in the application configuration and are written by the admin Integrations page.
public string? GetBaseUrl() => GetConfig("gateway_url");Keeping data between calls
CreatePaymentAsync, GetStatusAsync and RefundAsync are separate calls, and a gateway may need something from one in the next (an order id, a cursor). Take IPluginStore in the plugin's constructor, next to the IConfiguration the host passes:
public MyGateway(IConfiguration configuration, IPluginStore store) : base(configuration)
{
_store = store;
}The host provides it when it activates the plugin. It is plugin wide: scoped to your plugin id and to no client, with the same key rules and size limits as call.Store, and it is removed with the plugin's data when the plugin is removed and its data purged. It never reaches another plugin's values or any client's values; data about one client belongs in call.Store, which is bound to the call's client. The store is the same data as the call store of an admin call outside any client.
Example
using Hostpanel.Sdk.Base;
using Hostpanel.Sdk.Plugins.Enums;
using Hostpanel.Sdk.Plugins.Payment;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace MyCompany.Hostpanel.Plugins.MyGateway;
public sealed class MyGateway : PaymentGatewayBase, IPaymentPlugin
{
public MyGateway(string pluginId, IConfiguration configuration, ILogger<MyGateway> logger)
: base(pluginId, configuration, logger) { }
public string CurrencyCode => "840"; // USD (ISO 4217 numeric)
public Task<PaymentSession> CreatePaymentAsync(
PaymentInstruction instruction, CancellationToken ct)
{
var url = GetConfig("gateway_url")
?? throw new InvalidOperationException("gateway_url is required");
// ... call the gateway API and return a redirect URL
return Task.FromResult(new PaymentSession("order-123", url + "/pay?order=123"));
}
public Task<GatewayPaymentStatus> GetStatusAsync(
string gatewayOrderId, CancellationToken ct)
{
// ... query the gateway
return Task.FromResult(
new GatewayPaymentStatus(GatewayPaymentState.Paid, gatewayOrderId, "ok", null, null));
}
public Task<string> RefundAsync(string gatewayOrderId, long amountMinor, CancellationToken ct)
{
// ... call the gateway refund endpoint
return Task.FromResult("refund-ref-456");
}
}{
"id": "my-gateway",
"name": "My Gateway",
"version": "1.0.0",
"author": "Your Company",
"description": "Accept payments through My Gateway.",
"type": "Payment",
"category": "Payment Gateways",
"color": "#0369a1",
"entryPoint": "MyCompany.Hostpanel.Plugins.MyGateway.MyGateway",
"sdkVersion": "1.0",
"showInIntegrations": true,
"integrationsCategory": "Payment Gateways",
"fields": [
{ "key": "gateway_url", "label": "Gateway URL", "type": "text", "required": true },
{ "key": "api_key", "label": "API Key", "type": "secret", "required": true },
{ "key": "mode", "label": "Mode", "type": "select", "required": false }
]
}using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Hostpanel.Sdk.Plugins.Payment;
using Xunit;
public class MyGatewayTests
{
[Fact]
public async Task CreatePaymentAsync_ReturnsRedirectUrl()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["integration:my-gateway:gateway_url"] = "https://test.example.com",
["integration:my-gateway:api_key"] = "test-key",
})
.Build();
var gateway = new MyGateway("my-gateway", config, NullLogger<MyGateway>.Instance);
var instruction = new PaymentInstruction(
OrderNumber: "1001",
AmountMinor: 5000,
ReturnUrl: "https://panel.example.com/payment/result",
Description: "Invoice #1001",
Language: "en");
var session = await gateway.CreatePaymentAsync(instruction, CancellationToken.None);
Assert.False(string.IsNullOrEmpty(session.RedirectUrl));
Assert.False(string.IsNullOrEmpty(session.GatewayOrderId));
}
}A provisioning plugin
Inherit ProvisioningProviderBase and implement IProvisioningPlugin from Hostpanel.Sdk.Plugins.Provisioning. Read backend/src/Innovayse.Providers.CWP7 as the reference implementation — it shows the full hosting panel client contract and how to handle each provisioning command (create, suspend, unsuspend, terminate, change package, change password, login).
The manifest (plugin.json)
Every plugin ZIP must have plugin.json at the root.
| Name | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique identifier: lower-case letters, digits and dashes, at most 64 characters. Must be stable across versions. |
name | string | — | Display name shown in the Plugin Manager and Integrations. |
version | string | — | Semantic version string, e.g. 1.0.0. |
author | string | — | Author name shown on the plugin card. |
description | string | — | One-line description shown on the plugin card. |
type | string | — | Plugin type: Payment, Provisioning, Registrar, Identity, Fulfilment, Hosted or App. |
category | string | — | Category used to group the plugin inside the Plugin Manager. |
color | string | — | Hex colour for the plugin icon background, e.g. #0369a1. |
icon | string | — | Optional image of the plugin: a path inside the package to an SVG or PNG file, for example icon.svg or assets/icon.png. Lowercase letters, digits, dot, hyphen and underscore, forward slashes, no leading slash, no .. segment, extension .svg or .png, at most 256 KiB. Omit it to show the colour block with the plugin initial. |
entryPoint | string | — | Fully qualified class name of the class that implements the plugin interface. |
sdkVersion | string | — | Manifest SDK version. The accepted value is 1.0. The assembly SDK major is checked separately and older majors are refused. |
showInIntegrations | boolean | false | When true, the plugin appears as a card on the Integrations screen. |
integrationsCategory | string | — | Category heading under which the card appears on the Integrations screen. |
fields | array | [] | Settings shown on the integration page, in order. |
The icon file is checked when the plugin is installed and again when it is loaded. A path that leaves the package, a file that is missing (PLUGIN_ICON_MISSING), a file over the size limit, a .png that is not a PNG, and an SVG that is not well-formed XML or that holds a script, a foreignObject, an event handler attribute (onload, onclick, ...), a DOCTYPE, or a reference that leaves the image (an href that is not a #fragment, a url(...) that is not a #fragment, @import) are refused with PLUGIN_ICON_INVALID. The admin panel shows the image where it shows the colour block; the host serves it from GET /api/admin/plugins/{id}/icon with X-Content-Type-Options: nosniff and a policy that blocks script and every fetch.
The fields above cover manifest version 1. A manifest without manifestVersion is version 1; the host accepts 1 and 2 and refuses any other value (PLUGIN_MANIFEST_VERSION_UNKNOWN). version must be a strict major.minor.patch version. Manifest version 2 ("manifestVersion": 2) adds the members below.
Manifest v2 members
| Name | Type | Default | Description |
|---|---|---|---|
manifestVersion | number | 1 | Set to 2 to use the members below. |
settings | array | [] | Typed settings an administrator fills in. Supersedes fields; when settings is empty the host converts fields into settings. |
capabilities | string[] | [] | What the plugin can do, shown without activating it. Known names: storedCredentials (a payment plugin that also implements IStoredCredentialPlugin) and endpoints (the plugin class implements IPluginEndpoints; for a plugin that is not an App plugin the host serves its public callbacks only, see Callbacks from outside; an App plugin is served without declaring it). It must agree with the interfaces the class really implements. |
hostAccess | string[] | [] | Kinds of access to a client hosting the plugin asks the host for. The only kind today is ssh. The admin Plugins page shows each one a plugin declares. |
locales | object | {} | Texts by language code, then by key, for example the language en with the key menu.chat and the text Chat. A text key has at least two dot-separated parts, such as menu.chat. The en texts must define every key the manifest uses. |
api | object | {} | Routes the host forwards to the plugin: api.client and api.admin, each a list of routes. |
ui | object | {} | What the plugin adds to the panels: ui.client and ui.admin (menus and pages) and ui.slots (widgets in host slots). |
externalOrigins | string[] | [] | Web origins (scheme, host and port, such as https://example.com) that a link block of a plugin view may point at with an absolute https address. |
Wherever a label, title or description may be a text, write either a key of locales or plain text. A value shaped like a key (letters, digits, dashes and underscores in two or more parts separated by dots) is looked up in the en texts and refused when it is not defined (PLUGIN_LABEL_KEY_MISSING).
Each entry in settings:
| Name | Type | Default | Description |
|---|---|---|---|
key | string | — | Setting key read with GetConfig(key). |
label | string | — | Text shown for the setting. |
type | string | — | Input kind: Text, Url, Select, Secret, Number, Boolean or Textarea. A Secret is stored encrypted and never returned by a read. |
required | boolean | false | When true, the setting must be filled before the plugin can be enabled. |
description | string | — | Help text shown under the setting. |
default | string | — | Value used until the administrator sets one. Always written as text, for example 30 or true. |
placeholder | string | — | Hint shown in an empty input. |
min | number | — | Lowest value of a Number setting. |
max | number | — | Highest value of a Number setting. |
options | array | [] | Choices of a Select setting, each an object with value (stored) and label (shown). |
Each entry in api.client and api.admin is a route the host serves under /api/client/plugins/{id}/... and /api/admin/plugins/{id}/ext/.... The host checks sign-in, the caller's permission, CSRF and the rate limit before the plugin runs:
| Name | Type | Default | Description |
|---|---|---|---|
method | string | — | Get, Post, Put, Patch or Delete. |
path | string | — | Path below the plugin prefix: a leading slash, then lowercase letters, digits, dashes and underscores, or {parameter} segments, for example /invoices/{id}. No empty segment and no .. segment. The same method and path may be declared once per area. |
permission | string | — | Client routes only: a client permission name, or Any for every signed-in caller. An admin route declares none, because the admin area is guarded by the admin policy alone (PLUGIN_ADMIN_PERMISSION_DECLARED). |
rateLimit | string | Default | Rate limit policy: Default, Strict (costly or sensitive routes, such as sending messages) or Heavy (rare, heavy work, such as an export). |
streaming | boolean | false | When true, the route answers with a stream of server-sent events instead of one reply. A streaming route is also held to a concurrency cap. |
ui.client and ui.admin each hold menus and pages. Each page:
| Name | Type | Default | Description |
|---|---|---|---|
id | string | — | Page id, unique per area: 1 to 64 lowercase letters, digits, dashes and underscores, starting with a letter or digit. |
path | string | — | Page path below the plugin prefix in the panel, with the same rules as an api path. |
title | string | — | Page title. |
view | string | — | Path inside the package of the view document that describes the page. The host renders it with its own components, so no plugin code runs in the browser. |
permission | string | — | Client pages only: a client permission name, or Any. Not allowed on an admin page. |
Each menu entry:
| Name | Type | Default | Description |
|---|---|---|---|
id | string | — | Entry id, unique within the plugin. |
label | string | — | Text shown in the menu. |
page | string | — | Id of the page of the same area that the entry opens. |
icon | string | — | Icon name: 1 to 64 lowercase letters, digits and dashes. Omit it for the default icon of the panel. |
order | number | 0 | Position in the menu, lowest first. |
Each entry in ui.slots puts a widget into a slot the host offers. The slot list is versioned; the host offers client.dashboard.cards (no context), client.service.tabs (context key serviceId), admin.client.panels (clientId) and admin.service.panels (serviceId and clientId). A slot that is not listed, or a context key the slot does not offer, is refused.
| Name | Type | Default | Description |
|---|---|---|---|
id | string | — | Widget id, unique within the slot: lowercase letters, digits, dashes and underscores. |
slot | string | — | Slot id, one of the four above. |
view | string | — | Path inside the package of the view document that describes the widget. |
title | string | — | Title shown with the widget. |
order | number | 0 | Position among the widgets of the slot, lowest first. |
permission | string | — | Client slots only: a client permission name, or Any. |
productIds | number[] | [] | Products the widget is bound to; it shows only where the slot service belongs to one of them. Empty shows it for every product. An administrator can change the binding on the Plugins page. |
contextKeys | string[] | [] | Context the widget needs from the slot. The host verifies the keys for the caller and passes only these to the plugin. |
Callbacks from outside
A payment gateway calls the panel server to server, with no signed-in user: a card webhook, a pre check and confirmation, a bank result URL. A plugin that implements IPluginEndpoints can receive these through a public door, with no gateway code in the host. This is open to a plugin of any type: a payment plugin lists "capabilities": ["endpoints"] in its manifest and its class implements IPluginEndpoints beside IPaymentPlugin; an App plugin needs no capability. The capability opens this door only. The api.client and api.admin routes, pages, menus and slots of a plugin that is not an App plugin are not served, whatever it declares. A manifest that lists endpoints while its class does not implement IPluginEndpoints is refused at load with PLUGIN_ENDPOINTS_NOT_IMPLEMENTED, and a plugin that does not list it answers its callbacks with the same 404 PLUGIN_ROUTE_NOT_FOUND as an unknown plugin.
Declare each callback in api.callbacks, a list of routes with the same method and path rules as api.client (a permission is not allowed, and neither is streaming; rateLimit is ignored, because the door has its own limit):
{
"api": {
"callbacks": [
{ "method": "Post", "path": "/webhook" },
{ "method": "Post", "path": "/confirm/{step}" }
]
}
}The host serves them under /api/plugins/{id}/callback/..., so the second route above is POST /api/plugins/{id}/callback/confirm/result. Give that address to the gateway.
What the host does:
- It answers only a declared callback of an installed and enabled plugin. An unknown plugin, a switched-off or unconfigured one, an undeclared path and a wrong method all answer the same
404 PLUGIN_ROUTE_NOT_FOUND, so the door never tells what is installed. The routes ofapi.clientandapi.adminare not reachable through it. - There is no sign-in and no CSRF header. The plugin gets
call.Area == PluginArea.Callback, no client reference, no permissions, a fixed caller referencecallbackand the plugin-wide store. - The body is passed on byte for byte, never parsed or re-serialised, so a signature over it still verifies.
call.Headersholds an allow-list of the request headers, exactly as received (see below), andcall.Querythe query string. - A callback may answer with
PluginReply.Raw(status, body, contentType), for gateways that expect a plain answer such asOKastext/plain: the bytes, status and media type are written as given, withX-Content-Type-Options: nosniff. A raw reply is accepted on a callback only (on a client or admin route it is a plugin fault,PLUGIN_FAILED), its media type must betext/plain,application/json,application/xmlorapplication/x-www-form-urlencoded(anything else, markup and script included, isPLUGIN_FAILED), and its body is held toPluginApi__MaxBodyBytes. Every raw reply also carriesContent-Security-Policy: default-src 'none'; frame-ancestors 'none'. - Only these request headers reach
call.Headers:Content-Type,Content-Length,User-Agent,Signature,Digest,Content-Digest,Date,Idempotency-Key,Webhook-Id,Webhook-Timestamp,Webhook-Signature, and anyX-*header exceptX-Forwarded-*,X-Real-IPand the host's own (X-Language,X-Requested-With,X-Logout-Key,X-Service-Key,X-Renewed-Access-Token).Cookie,Authorization,Proxy-AuthorizationandHostare never passed on. If your gateway signs a header that is not on this list, send it as anX-*header or put it in the body. - The body is capped by
PluginApi__MaxBodyBytes(413PLUGIN_REQUEST_TOO_LARGEabove it) and every request to the door is counted per caller address againstRateLimit__PluginCallback(429 above it). - A failure is logged with the plugin id only, never with a header, a query or a body.
What the plugin must do: verify the call itself (the gateway's signature, checksum or shared secret) before it trusts anything in it, using a secret from its own settings. Answer PluginReply.Problem(400, ...) or 403 for a call that does not verify, and a success reply for one that does; keep the work idempotent, because gateways deliver the same event more than once. Nothing in the host says the call is authentic.
A manifest that breaks one of these rules is refused at install and at startup with a PLUGIN_* manifest error code that names the offending member (PLUGIN_CALLBACK_INVALID for a callback that declares a permission or streams, PLUGIN_ROUTE_PATH_INVALID and PLUGIN_ROUTE_DUPLICATE for its path and repeats). Read backend/src/Hostpanel.Sdk/Plugins/Manifest for the exact types.
Capabilities
A version 2 manifest may list capabilities, each a name from the SDK's PluginCapabilities:
| Capability | Allowed on | The plugin class must also implement |
|---|---|---|
storedCredentials | Payment only | IStoredCredentialPlugin |
endpoints | any type | IPluginEndpoints |
An App plugin answers routes and shows pages, menus and slots without declaring anything. Any other type that declares api routes or ui menus, pages or slots must declare endpoints, or the package is refused at install and skipped at startup (PLUGIN_ENDPOINTS_NOT_DECLARED). This is how one plugin, for example a Fulfilment plugin that sells credits, also gives the client a screen. An unknown name, a name declared twice, or storedCredentials on a type other than Payment is refused the same way (PLUGIN_CAPABILITY_NOT_ALLOWED).
A declared capability is checked against the class when the plugin is activated. If the class does not implement the interface a capability promises, the plugin is not activated for anything, its own type's calls included: a payment plugin that declares storedCredentials without implementing IStoredCredentialPlugin takes no payments, and a fulfilment plugin that declares endpoints without implementing IPluginEndpoints receives no fulfilment calls. The admin log names the plugin and the capability.
Each entry in fields:
| Name | Type | Default | Description |
|---|---|---|---|
key | string | — | Setting key read with GetConfig(key). |
label | string | — | Label shown next to the field on the integration page. |
type | string | text | Field type: text, secret, select or textarea. |
required | boolean | false | When true, the integration page shows Not configured until this field is filled. |
View state
A page or slot view ({ "schema": 1, "blocks": [...] }) can react to its own data without a reload. Three optional members do it; all need SDK 2.2.0 or later, and the view schema stays 1.
A condition is one shape, used by visibleWhen and by followUp.until, with exactly these three members:
{ "route": { "method": "get", "path": "/services/{serviceId}/site" }, "field": "site.status", "equals": ["ready", "live"] }routeis aGETroute your manifest declares in the same area as the page or slot.fieldis a dotted path into the JSON answer (letters, digits,_and-per segment, at most 200 characters).equalsis one JSON value (string, number,true,false,null) or a list of 1 to 20 of them. The condition holds when the field strictly equals one of them:"1"does not match1.
visibleWhen goes on any block, including header actions and blocks inside tabs. The block is shown only when the condition holds; while it is read, when the read fails, or when the field is missing, it stays hidden. It is read again when the block's own id is refreshed. Hide a create form once a site exists:
{
"kind": "form",
"id": "create-site",
"visibleWhen": { "route": { "method": "get", "path": "/services/{serviceId}/site" }, "field": "exists", "equals": false },
"fields": [
{ "key": "name", "label": "site.name", "type": "text", "required": true },
{ "key": "description", "label": "site.description", "type": "textarea" }
],
"submit": {
"route": { "method": "post", "path": "/services/{serviceId}/site" },
"label": "site.create",
"refresh": ["create-site", "site"],
"resetOnSuccess": true
}
}resetOnSuccess is true or false, only inside a form's submit. After a successful submit every field goes back to empty.
followUp goes on an action block or inside a form's submit, with exactly refresh and until. After a successful call (and its normal refresh), the panel reads until.route again and again, refreshing the listed blocks each time, until the condition holds (then once more), it runs out of attempts, or the reader leaves the page. A failed read counts as an attempt. Show a publish result without a reload:
{
"kind": "action",
"label": "site.publish",
"route": { "method": "post", "path": "/services/{serviceId}/site/publish" },
"followUp": {
"refresh": ["publish-result"],
"until": {
"route": { "method": "get", "path": "/services/{serviceId}/site" },
"field": "publish.state",
"equals": ["done", "failed"]
}
}
}Both examples assume the view also has blocks with the ids site and publish-result, and that the manifest declares the three routes in its api block and the label keys in locales.en:
{
"locales": {
"en": {
"site.name": "Site name",
"site.description": "Description",
"site.create": "Create site",
"site.publish": "Publish"
}
}
}refresh lists 1 to 20 block ids, each present in the same view. How often and how long the panel polls is the host's choice, not yours: every 3 seconds, at most 20 times (about a minute). A long job should report its own state on the until route rather than expect more time.
A view that breaks these rules is refused at install and skipped at startup: PLUGIN_VIEW_CONDITION_INVALID (a bad condition, a condition route that is not GET, visibleWhen outside a block), PLUGIN_VIEW_FOLLOW_UP_INVALID (a bad followUp or one in the wrong place, a refresh id no block carries, resetOnSuccess that is not a boolean or not in a submit), and PLUGIN_VIEW_ROUTE_UNDECLARED for a condition route the manifest does not declare.
Package it
Folder layout inside the ZIP:
plugin.json ← at the root
MyCompany.Hostpanel.Plugins.MyGateway.dll ← your assembly
plugin.sig ← signature file (required on production servers)Assembly naming. The prefix Innovayse.Hostpanel. belongs to the host, so a package carrying a DLL under it is refused. The one exception is the plugin namespace Innovayse.Hostpanel.Plugins.<Name> (for example Innovayse.Hostpanel.Plugins.Stripe.dll), which is how official plugins are named and is always allowed in a package. The dot after Plugins is the boundary: Innovayse.Hostpanel.PluginsX.dll and Innovayse.Hostpanel.Plugins.dll are still host names and are refused. No host assembly is ever named under the plugin namespace (an architecture test guards it), so a plugin can never shadow one. Third-party plugins keep their own prefix, such as MyCompany.MyGateway.
Do not include the SDK assembly (Hostpanel.Sdk.dll) or any assembly the host already provides. The server refuses packages that contain a host assembly (PLUGIN_HOST_ASSEMBLY_REFUSED). Give your assemblies names no other plugin uses: a package is also refused (PLUGIN_ASSEMBLY_CONFLICT) when one of its DLL or EXE file names, compared without regard to case, is already shipped by another installed or staged plugin, because the loader would bind both plugins to one copy. The rule covers every DLL in the package, shared third-party libraries included (a JSON or HTTP library two plugins both ship counts): if another installed plugin already ships a library of that name, pick a different assembly name or merge the library into your own assembly. Upgrading the same plugin id never conflicts with itself. A duplicate plugin id is skipped at startup without error — the first loaded version wins (built-in plugins load before uploaded ones).
Sign the package
Packages loaded from outside the Development and Testing environments must be signed. To skip signature checks, open Plugins in the admin panel and switch on Allow unsigned plugins. The PluginSigner tool is part of the Hostpanel repository (backend/tools/Innovayse.Hostpanel.PluginSigner), not the SDK. Run it on the unpacked folder before zipping. The tool uses Hostpanel.Sdk.Packaging (3.1.0) for the signature format, so the signature it writes is checked by the same code the host installs with, and your pipeline can call PluginSigner and PluginSignatureValidator from that package to verify a signed folder before release.
Generate a key pair. The key file is a base64-encoded private key; store it outside any git working tree (the tool refuses to sign if the key file is inside one):
dotnet run --project backend/tools/Innovayse.Hostpanel.PluginSigner -- keygen my-key.keySign the plugin folder:
dotnet run --project backend/tools/Innovayse.Hostpanel.PluginSigner -- sign ./MyCompany.Hostpanel.Plugins.MyGateway my-key.key my-key-idThis writes plugin.sig into the folder. Zip the folder after signing.
The operator must add the matching public key to PluginSigning:OperatorKeys in their deployment environment, or the package will be refused with PLUGIN_SIGNATURE_KEY_UNKNOWN. When you update a plugin, re-sign the new folder before re-zipping — updating re-checks the signature and the server will refuse a stale or missing plugin.sig.
Test it
Write unit tests for your gateway against a stubbed HTTP client, so no test needs the real payment provider.
For a manual end-to-end test:
Start a local Hostpanel stack (see Local development).
Build your class library in Release, sign the output folder with
PluginSigner, zip it, and upload the ZIP via Settings → Plugin Manager → Install Plugin. In Development and Testing environments unsigned packages load without any switch. On other servers, open Plugins in the admin panel and switch on Allow unsigned plugins to skip signature verification.Restart the server from the "Restart required" banner.
Go to Integrations, find your plugin card, click Configure →, enter the test credentials, save and enable.
Create a test client, raise an invoice, and pay it. Verify the payment appears in Gateway Transaction Log. If signature verification fails, the upload is rejected with
PLUGIN_UNSIGNED.
Share it
Use semantic versioning (major.minor.patch). Include a changelog so operators know what changed. Operators install via the Plugin Manager by uploading the ZIP.