Build plugins for WhizzyCommerce

A plugin is a manifest: a name, a version, the settings it asks a merchant for, and the hooks it plugs into. A manifest carries no code. A chat widget is a script template with the merchant's settings substituted in; a blog is content types, a section and pages we render. Anything that has to run, runs on your server, and we call it over signed HTTPS.

The manifest

{
  "slug": "acme-rates",
  "name": "Acme live rates",
  "version": "1.0.0",
  "category": "shipping",
  "description": "Live delivery prices from Acme.",
  "author": { "name": "Acme", "url": "https://acme.example" },
  "docsUrl": "https://acme.example/whizzy",
  "placement": "settings",
  "settings": [
    { "key": "account", "label": "Acme account", "type": "text" },
    { "key": "api_key", "label": "API key", "type": "text" }
  ],
  "secrets": ["api_key"],
  "remote": { "baseUrl": "https://acme.example/whizzy" },
  "hooks": {
    "shipping": true,
    "events": ["order.paid", "order.fulfilled"],
    "grants": ["orders.fulfil", "reports.read"]
  }
}

Categories: content, marketing, payments, shipping, messaging, integration, other. Settings use the same field types a theme's settings do; a key named in secrets is sealed at rest, masked in the form and never substituted into a script. Section and content types must be namespaced under your slug, like acme-rates.banner.

Staff register the manifest from the admin and publish a version. A new version is a new manifest with a higher number; shops keep the version they installed until their merchant chooses to update.

Hooks with no server

scripts: a JavaScript template per trigger, with {{settings.key}} substituted as an escaped literal, put on every storefront page except the payment page, after the shopper's consent where the shop asks for one. sections: the same declarative JSON a theme section is, listed in the page editor. metaobjects: content types the merchant fills in. dashboard (a screen with create also gets a row in the plus menu) and routes are for plugins that ship inside the platform only; a text setting keyed route lets the merchant move the first of those paths, which is how a blog comes to live at /journal.

placement says where the merchant finds the plugin once it is installed: settings (the default) is one row at the foot of the shop's Settings, pointing at the plugin's settings form; rail gives it an icon of its own on the dashboard rail, named by icon, with its screens in the panel beside it. The Plugins section itself never grows.

Hooks with a server

Set remote.baseUrl and we POST JSON to {baseUrl}/hooks/<name>. Every request carries three headers:

x-whizzy-timestamp: 1789000000000        milliseconds since the epoch
x-whizzy-signature: <hex>                HMAC-SHA256 with your signing secret
x-whizzy-plugin:    acme-rates

The signed string is timestamp + "\n" + "POST" + "\n" + path + "\n" + body, where path is the URL path we called (for example /whizzy/hooks/event) and body is the exact bytes. Refuse a request whose timestamp is more than 60 seconds off your clock, and compare signatures in constant time. Your signing secret is shown once, when staff register the plugin.

// Node
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, req, rawBody) {
  const ts = req.headers["x-whizzy-timestamp"];
  if (Math.abs(Date.now() - Number(ts)) > 60000) return false;
  const expected = createHmac("sha256", secret)
    .update(`${ts}\nPOST\n${req.path}\n${rawBody}`).digest("hex");
  const given = String(req.headers["x-whizzy-signature"] ?? "");
  return given.length === expected.length
    && timingSafeEqual(Buffer.from(given, "hex"), Buffer.from(expected, "hex"));
}

Answer 2xx with JSON. Anything else is a failure; events are retried, quotes are skipped.

install and uninstall

POST /hooks/install
{ "installId": "ipl_…", "tenantId": "ten_…", "shopUrl": "https://shop.example",
  "pluginVersion": "1.0.0", "settings": { "account": "…" },
  "accessToken": "…"    // first install only, shown once }

POST /hooks/uninstall
{ "installId": "ipl_…", "tenantId": "ten_…" }

A non-2xx answer to install refuses the install: the merchant sees your response text and nothing is kept. Settings are sent again at every update; a secret setting is never sent.

event

POST /hooks/event
{ "installId": "ipl_…", "tenantId": "ten_…", "deliveryId": "pdl_…",
  "event": "order.paid", "occurredAt": "2026-09-15T21:00:00.000Z",
  "payload": { "orderId": "…", "orderNumber": 1042, "grossAmount": 8400,
               "grossCurrency": "EUR", "order": { … } } }

Events: order.paid, order.fulfilled, order.refunded. Delivered at least once, within about a minute, and retried with backoff for up to a day; deliveryId is the same on every retry, so use it to ignore a duplicate.

shipping-quote

POST /hooks/shipping-quote
{ "installId": "…", "tenantId": "…",
  "input": { "destination": { "country": "IE", "province": null, "postalCode": "D02" },
             "subtotal": 8400, "weightGrams": 1200 },
  "lines": [ { "sku": "TOTE-NAT", "quantity": 1, "weightGrams": 400 } ] }

200 { "quotes": [ { "id": "next-day", "name": "Next day", "description": "By 1pm", "price": 795 } ] }

Prices in minor units of the shop's currency. Asked at checkout with a three second timeout; a slow answer is no quotes, not a slow checkout. Your quote is offered beside the merchant's own methods, and the order records which one was chosen as plugin:acme-rates:next-day.

payment/start and payment/check

POST /hooks/payment/start
{ "installId": "…", "tenantId": "…", "orderId": "ord_…", "orderNumber": 1042,
  "amount": 8400, "currency": "EUR", "email": "ana@example.com", "shopName": "Kestrel Goods",
  "returnUrl": "https://shop.example/checkout/return?order=ord_…",
  "cancelUrl": "https://shop.example/checkout?cancelled=1", "webhookUrl": "…" }
200 { "url": "https://pay.example/p/abc", "providerPaymentId": "abc" }

POST /hooks/payment/check
{ "installId": "…", "tenantId": "…", "providerPaymentId": "abc" }
200 { "status": "paid" | "pending" | "failed", "reason": "Declined" }

Declare "payment": { "id": "acme-pay", "label": "Pay with Acme" }. The label is the option a shopper sees; choosing it sends them to your url, which must be https. When they come back to returnUrl we call check, and its answer is the authority: there is no webhook for a plugin. Answer pending until you know; the merchant can also mark the order paid by hand.

email/send

POST /hooks/email/send
{ "installId": "…", "tenantId": "…",
  "email": { "to": "ana@example.com", "toName": "Ana Silva", "fromName": "Kestrel Goods",
             "from": "…", "replyTo": "…", "subject": "…", "html": "…", "text": "…" } }
200 { "messageId": "…" }

Declare "email": true and the shop's customer mail, receipts and dispatch notes, is handed to you instead of our mail server, one message per call. A non-2xx answer is retried the way our own queue retries. Mail we send the merchant stays on our side.

sms/send

POST /hooks/sms/send
{ "installId": "…", "tenantId": "…", "to": "+353870000000",
  "text": "Kestrel Goods: order #1042 is on its way.", "kind": "order.shipped" }
200 {}

Declare "sms": true. Sent when an order is confirmed and when a parcel is dispatched, to the number the shopper gave, best effort: a text that does not go out is not retried.

Calling the shop

The accessToken from the install call is a bearer token for that one shop. It lives as long as the install and dies with it. Two doors:

# The shop's capability API, the same MCP server merchants connect Claude to
POST https://app.whizzycommerce.com/mcp/{tenantId}
Authorization: Bearer <accessToken>

# A product import key, for a plugin with "hooks": { "import": true }
POST https://app.whizzycommerce.com/api/plugins/{tenantId}/import-key
Authorization: Bearer <accessToken>
200 { "key": "wz_…", "expiresAt": "…", "header": "X-Whizzy-Import-Key",
      "endpoints": { "hello": "…/import/hello", "batch": "…/import/batch", "finish": "…/import/finish" } }

What the token may do on the capability API is exactly hooks.grants from your manifest, read from the shop on every call: catalog.write, pricing.write, orders.fulfil, content.write, design.write, settings.write, customers.write, customers.read, reports.read. A merchant who switches the plugin off leaves the token with no grants at all. Every capability call previews and asks the merchant to confirm before anything with money in it happens, the way it does for any agent.

The import key works exactly as the migration connector's does: one shop, one job, 24 hours, revocable. Ask for a new one when it expires.

Packaging

Send a plugin as a zip: manifest.json at the root, optionally cover.png (or .jpg, .webp; 2:1, under 4 MB) for its card, optionally sections/*.json with one section each, which are folded into hooks.sections. A readme is welcome and ignored: what a merchant reads is description and docsUrl in the manifest. There is no code in a package; what runs is your server. Staff register the zip in the catalogue, and a plugin can be downloaded back out of it as the same zip.

Themes

A plugin that changes how a shop looks is usually a theme. That is a different, and simpler, thing: build themes.

Build plugins · WhizzyCommerce