Publishing & updating on the marketplace
Package a gateway driver as a signed marketplace extension, get it approved, and ship updates that never lose a saved key or re-charge the operator.
On this page
- Why marketplace-only
- Before you submit: get the package right
- Publish: product → version → upload → review → signed
- Pricing
- How an operator installs
- Shipping an update
- What installs see
- The atomic swap, and why nothing is lost
- Versioning in practice
- What the signature covers
- A word on settlement before you ship
- The lifecycle end to end
A finished payment-gateway extension is two things — a driver class and a salieno.json manifest — but a folder on a disk is not a distributable extension. Salieno Core only loads gateways that arrive through marketplace.salieno.com, signed. This article walks the whole lifecycle: how you package and publish a gateway, how an operator installs it, and how you ship updates without ever losing a stored credential or charging a buyer twice.
Why marketplace-only#
There is no local upload path, and no "drop a folder under payments/" escape hatch. That is deliberate. A gateway is code that moves real money with the operator's live API keys, so every time Core resolves your driver to a live instance it checks three things, and refuses unless all of them hold:
- Registered — an enabled
gateway_extensionsrow exists for the slug. A hand-copied folder has no row. - Genuine — the stored signed artifact re-verifies against Core's pinned Ed25519 marketplace key, and each on-disk file's SHA-256 still matches the hash recorded from that verified artifact at install. You cannot forge the signature, and you cannot edit the code after install.
- Entitled — the activated licence still owns the gateway, as decided by the marketplace.
There is one payment-specific relaxation, and it exists so money is never stranded: on an inbound webhook or browser return, the entitled gate is lenient — a payment the buyer already made must settle even if the licence has since lapsed — but genuine stays strict, and a disabled gateway (Gateway.status = false, the operator's containment lever for a compromised key) resolves to a NullGateway on both checkout and callback. The full model is in Trust model & security; this article is about the path that produces those signatures and entitlements.
Before you submit: get the package right#
A gateway package is code only — there are no views; Core renders every checkout and admin screen. The shape is a folder payments/<slug>/ holding exactly two things at its root, which you zip for submission:
- `salieno.json` — the manifest.
- `Gateway.php` — the driver class, namespace
Salieno\Payment\<Name>, classGateway.
Plus any helper classes under your namespace. A flat root is the clean shape. You do not sign anything locally: the marketplace holds the only signing key and signs on approval, so there is nothing on your side to leak.
Two independent validators read your manifest, and both must pass — so a few fields carry more weight at submission than they do while you develop. See The manifest for the full field reference; the ones that matter for publishing:
- `slug` — lowercase, unique, and it is both the marketplace product slug and the join key every
gateway_currenciesconfig row, every saved credential, and every settledDepositis keyed to. Choose it once. Changing it later means a new product, not an update — and it strands all the config and history keyed to the old slug. - `version` — semver. This is the number the marketplace compares between releases, and what an install shows in its Extensions library.
- `entry` — the driver file at the package root; must be
"Gateway.php"and must end in.php. Core refuses a package whose declared driver file is not actually present. - `requires_core` — a Core version window, e.g.
">=1.0.0 <2.0.0"— the range of Salieno Core your gateway supports, not your gateway's own version. It must agree with the core window you enter on the version row. - `capabilities` — Core reads this list to gate the admin and checkout surfaces without loading your driver (cheap, signature-checked). It must equal your driver's
capabilities()method, which is the authoritative runtime source. Stripe's manifest declares:
"capabilities": ["charge", "refund", "partial_refund", "webhook", "redirect", "3ds"]and its driver returns the identical set:
public function capabilities(): array
{
return [
PaymentCapability::CHARGE,
PaymentCapability::REFUND,
PaymentCapability::PARTIAL_REFUND,
PaymentCapability::WEBHOOK,
PaymentCapability::REDIRECT,
PaymentCapability::THREE_D_SECURE,
];
}The two validators split the work: the marketplace checks kind, name, version, requires_core, and entry; the Core installer checks schema ("salieno.payment/1"), kind ("payment"), slug, namespace, and driver, and confirms the declared namespace+driver file actually exists in the package. Keep both sides honest and there are no surprises at approval or at install.
Test everything against the real gateway's sandbox first — Testing your gateway covers exercising the contract, settle(), and the fail-closed branches before you hand the code over for review.
Publish: product → version → upload → review → signed#
Publishing runs on marketplace.salieno.com and needs an approved developer account — apply first; you cannot create a product until you are approved. With that in hand:
- Create the product. Set
kindto"payment", choose a pricing model ("free", or"one_time"with a price), and fill in the name, tagline, and description. Pricing and kind are properties of the product, not of any single release. - Create a version. Enter the
version(semver), the Core window ascore_minandcore_max, and a changelog. The window must agree with the manifest'srequires_core:">=1.0.0 <2.0.0"meanscore_min1.0.0 andcore_max2.0.0. - Upload the artifact — the zip. It is statically scanned, and its manifest is cross-checked against the version row:
kind,version, and the core window must match what you entered. A mismatch, or a structurally unsafe archive, is rejected on upload. - Submit for review.
- A reviewer approves. On approval the marketplace re-hashes the stored bytes, signs the artifact (Ed25519), and publishes it as the product's live version.
The signing at step 5 is the whole point of the trip: it is what Core's pinned key later verifies, minted at approval time, never on your machine.
Pricing#
| Model | What the buyer pays | Platform fee |
|---|---|---|
free | Nothing. A $0 "Get" grants the entitlement. | — |
one_time | A single paid one-off through Stripe checkout. | The platform takes 20% of the sale. |
There is no subscription and no per-version charge. Free or paid, the operator must own it — hold an active entitlement — for the gateway to appear in their Extensions library and install. Entitlement is per licence, and it is gated on ownership, not on the buyer's Core support term — a lapsed support window never disables a gateway they bought. The entitlement covers every version of the product, the rule that makes updates free forever, which we come back to below.
How an operator installs#
Installs happen from admin → Payments → Gateway Extensions (/admin/gateway/extensions). Two things about that screen shape the operator's experience:
- It lists only gateways the licence owns. An operator cannot browse to one they have not acquired and install it anyway; ownership is resolved first.
- Install is a signed, single-use path: resolve → grant → download → verify → register.
Walking that path:
- Resolve the product for this licence and confirm ownership with the marketplace.
- Grant — the marketplace issues a single-use, signed grant. It only issues to an owner, which is exactly why a copied folder from someone else's install is inert: the grant, and therefore the entitlement, is tied to the licence.
- Download the signed artifact.
- Verify the download against Core's pinned key before any file is written to disk. Nothing lands on the box until the signature checks out.
- Register — Core extracts the package, validates the manifest, records a per-file SHA-256 hash tree, writes an enabled
gateway_extensionsrow keyed by your slug, and auto-adds a config row under Auto Gateways, seeded from your manifestcredentials. The operator opens that row, fills in the keys, runs Test connection, and enables it.
From the operator's side it is one click; underneath, every step is signed and verified.
Shipping an update#
An update is just a newer release of the same product. The rule is simple: publish a package whose `version` is strictly greater than the currently published one, the same way as the first — new version row, upload, submit, approve, sign. Semver comparison decides "greater," and the marketplace rejects a re-publish that isn't actually newer.
Illustrative example: say your gateway's 1.0.0 settled only checkout.session.completed, and you ship a 1.0.1 that also handles checkout.session.async_payment_succeeded for delayed payment methods. Same slug, same entitlement, same buyers — just a greater version taken through review.
What installs see#
You do not push an update to anyone. Installs discover it: the Extensions library shows "Update available" next to the gateway. When the operator clicks Update, Core runs the same signed path — resolve → grant → download → verify → register — against the new version. The signature is verified before anything is written, identical to a first install.
The atomic swap, and why nothing is lost#
Replacing the code is an atomic swap, not an in-place overwrite. Core stages the new build fully, then moves it over the old one; the previous build is kept until the new one is in place, and restored if the swap fails. Only the code changes. The gateway_extensions row is then updated in place — same row, keyed by the same slug, with the new version, driver hashes, and signed artifact recorded.
Two properties fall out of this, and they are the reassurance you give operators:
No config is lost. A gateway is a stateless artifact — pure code. Everything with state lives in Core's database, keyed by your slug: the Auto Gateways config, every encrypted per-currency credential in gateway_currencies.gateway_parameter, every enabled currency, and every settled Deposit. Global manifest params (a webhook signing secret, a mode flag) are fanned across every currency row and read back with getParam() / ownConfig() on each call. An update changes the code behind the slug; it never changes the slug, so all of that stays wired to the new build untouched. This is also why a driver must never write state into its own folder — beyond breaking the per-file hash check, that folder is the one place an update replaces wholesale.
No one is re-charged. The entitlement covers every version of the product. Updating a paid gateway spends the ownership the buyer already has; it never triggers a new charge. That is what lets you ship fixes and features freely — a buyer who paid once for a one_time gateway gets the next version at no cost.
Versioning in practice#
Two version numbers travel with your gateway, and they answer different questions:
| Field | Question it answers | Example |
|---|---|---|
version | Which release of my gateway is this? | 1.0.1 |
requires_core | Which Core versions can run it? | >=1.0.0 <2.0.0 |
Keep three things consistent on every release: the manifest version, the marketplace version row, and requires_core (which must equal the core_min/core_max you enter). Use a single continuous core range — one >= floor and one < ceiling — and never reference a prerelease or hyphen range; the version row expects clean release semver. Bump version on every release; the marketplace enforces that it climbs. Revisit requires_core only when you actually adopt or drop compatibility with a Core version.
Keep the manifest's capabilities list honest as you evolve. If a new version starts backing recurring renewals or refund, add it to both capabilities() and the manifest — the manifest gates the UI cheaply, the driver is the runtime authority, and Core trusts the manifest without loading the driver, so a mismatch draws a control that then fails when clicked.
What the signature covers#
Core verifies two signatures against its pinned marketplace public key before a byte is installed, both produced server-side at approval:
- The artifact signature is over
"salieno.marketplace.artifact/1\0"concatenated withsha256(zip)— it binds the exact bytes you uploaded. - The descriptor signature is over the canonical descriptor JSON (schema
"salieno.marketplace.version/1") — it binds the version metadata (slug, version, core window) to those bytes.
You never hold the key, and Core trusts neither the manifest nor the descriptor unless both signatures verify. The full three-gate model — genuine, registered, entitled, plus the lenient-entitlement rule on callbacks and the disabled-gateway lever — is in Trust model & security.
A word on settlement before you ship#
Nothing about publishing changes the one rule your driver must never break: a driver never credits a balance or marks an invoice paid. In ipn() you verify authenticity, store the proof on $deposit->detail, save, and hand off to Core's single idempotent, row-locked boundary:
$secret = $this->webhookSecret();
if (! $secret) {
// An empty signing secret validates an attacker-forged event (empty-key HMAC). Fail closed.
return $this->ipnReject('Webhook signing secret is not configured');
}
// …verify the signature…
$deposit->detail = $session; // proof of payment
$deposit->save();
$this->settle($deposit); // Core's idempotent, row-locked settlement boundarysettle() credits exactly once no matter how many times a webhook and a browser-return both fire. A driver that settled itself would double-credit — and no amount of signing at the marketplace catches a logic bug you shipped. Get this right in Testing your gateway before you submit.
The lifecycle end to end#
- Build the driver + manifest; test against the real gateway's sandbox and prove
settle()is idempotent. - Zip the package with
salieno.jsonandGateway.phpat the root. - On marketplace.salieno.com (approved developer account): create the product (
payment,freeorone_time), create a version (semver + core window + changelog), upload the zip, submit. - A reviewer approves; the marketplace signs artifact + descriptor and publishes.
- Owners install from admin → Payments → Gateway Extensions via the signed resolve → grant → download → verify → register path — which auto-adds an Auto Gateways config row seeded from your credentials.
- To improve it, publish a strictly greater semver; installs see an "Update available" badge.
- The operator clicks Update; the same signed path performs an atomic swap — new code, same slug, no config lost, no re-charge.
New to the series? Start with the Payment gateway extensions overview.