Créer des plugins pour WhizzyCommerce
Un plugin est un manifeste : un nom, une version, les réglages qu'il demande à un marchand, et les hooks auxquels il se branche. Un manifeste ne contient pas de code. Un widget de chat est un gabarit de script avec les réglages du marchand substitués dedans ; un blog, ce sont des types de contenu, une section et des pages que nous dessinons. Tout ce qui doit s'exécuter s'exécute sur votre serveur, et nous l'appelons en HTTPS signé.
Le manifeste
{
"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"]
}
}Catégories : content, marketing, payments, shipping, messaging, integration, other. Les réglages utilisent les mêmes types de champ que ceux d'un thème ; une clé nommée dans secrets est scellée au repos, masquée dans le formulaire et jamais substituée dans un script. Les types de section et de contenu doivent être dans l'espace de noms de votre identifiant, comme acme-rates.banner.
L'équipe enregistre le manifeste depuis l'administration et publie une version. Une nouvelle version est un nouveau manifeste avec un numéro plus élevé ; les boutiques gardent la version installée jusqu'à ce que leur marchand décide de mettre à jour.
Hooks sans serveur
scripts : un gabarit JavaScript par déclencheur, avec {{settings.key}} substitué en littéral échappé, posé sur chaque page de la boutique sauf celle du paiement, après le consentement du client là où la boutique le demande. sections : le même JSON déclaratif qu'une section de thème, listé dans l'éditeur de pages. metaobjects : des types de contenu que le marchand remplit. dashboard (un écran avec create gagne aussi une ligne dans le menu plus) et routes sont pour les plugins livrés dans la plateforme ; un réglage texte de clé route laisse le marchand déplacer le premier de ces chemins, et c'est ainsi qu'un blog finit par vivre sur /journal.
placement dit où le marchand trouve le plugin une fois installé : settings (par défaut) est une ligne au bas des Réglages de la boutique, qui pointe vers le formulaire de réglages du plugin ; rail lui donne une icône à lui sur la barre du tableau de bord, nommée par icon, avec ses écrans dans le panneau d'à côté. La section Plugins elle-même ne grandit jamais.
Des pages qu'un thème peut composer
Un plugin qui apporte une page à la boutique, un blog, un localisateur de magasins, un lookbook, doit une chose aux thèmes : sa page en données. Sur ses propres gabarits il met tout ce que la page montre dans la portée sous plugin, en valeurs simples, et documente la forme ; un thème compose la page par-dessus avec les nœuds ordinaires, comme il compose un produit ou le panier. Ce que les données ne peuvent pas faire, un formulaire ou toute autre chose qui s'exécute, est une partie que la plateforme possède et que le thème place. Sa disposition toute faite est construite exactement avec ces données et ces parties, donc tout ce qu'elle montre, un thème peut le montrer autrement.
Le blog est le premier : ses articles, un article, sa barre latérale et ses mots sont plugin.list, plugin.post, plugin.sidebar et plugin.labels, et la seule partie dont il a eu besoin est search-form. Voir la référence pour chaque champ.
Hooks avec un serveur
Renseignez remote.baseUrl et nous envoyons du JSON en POST à {baseUrl}/hooks/<name>. Chaque requête porte trois en-têtes :
x-whizzy-timestamp: 1789000000000 milliseconds since the epoch x-whizzy-signature: <hex> HMAC-SHA256 with your signing secret x-whizzy-plugin: acme-rates
La chaîne signée est timestamp + "\n" + "POST" + "\n" + path + "\n" + body, où path est le chemin de l'URL que nous avons appelée (par exemple /whizzy/hooks/event) et body les octets exacts. Refusez une requête dont l'horodatage s'écarte de plus de 60 secondes de votre horloge, et comparez les signatures en temps constant. Votre secret de signature est affiché une fois, quand l'équipe enregistre le 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"));
}Répondez 2xx avec du JSON. Tout le reste est un échec ; les événements sont réessayés, les devis ignorés.
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_…" }Une réponse non-2xx à install refuse l'installation : le marchand voit le texte de votre réponse et rien n'est conservé. Les réglages sont renvoyés à chaque mise à jour ; un réglage secret n'est jamais envoyé.
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": { … } } }Événements : order.paid, order.fulfilled, order.refunded. Livrés au moins une fois, en une minute environ, et réessayés avec attente croissante jusqu'à un jour ; le deliveryId est le même à chaque essai, servez-vous-en pour ignorer un doublon.
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 } ] }Prix en unités mineures de la devise de la boutique. Demandé à la caisse avec trois secondes de délai ; une réponse lente vaut zéro devis, pas une caisse lente. Votre devis est proposé à côté des méthodes du marchand, et la commande enregistre celui qui a été choisi sous 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" }Déclarez "payment": { "id": "acme-pay", "label": "Pay with Acme" }. Le libellé est l'option que voit le client ; la choisir l'envoie vers votre url, qui doit être en https. Quand il revient sur returnUrl nous appelons check, et sa réponse fait foi : il n'y a pas de webhook pour un plugin. Répondez pending tant que vous ne savez pas ; le marchand peut aussi marquer la commande payée à la main.
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": "…" }Déclarez "email": true et le courrier client de la boutique, reçus et avis d'expédition, vous est confié au lieu de passer par notre serveur de messagerie, un message par appel. Une réponse non-2xx est réessayée comme le fait notre propre file. Le courrier que nous envoyons au marchand reste chez nous.
sms/send
POST /hooks/sms/send
{ "installId": "…", "tenantId": "…", "to": "+353870000000",
"text": "Kestrel Goods: order #1042 is on its way.", "kind": "order.shipped" }
200 {}Déclarez "sms": true. Envoyé quand une commande est confirmée et quand un colis part, au numéro donné par le client, au mieux : un message qui ne part pas n'est pas réessayé.
Appeler la boutique
Le accessToken de l'appel d'installation est un jeton porteur pour cette seule boutique. Il vit aussi longtemps que l'installation et meurt avec elle. Deux portes :
# 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" } }Ce que le jeton peut faire sur l'API de capacités, c'est exactement hooks.grants de votre manifeste, relu depuis la boutique à chaque appel : catalog.write, pricing.write, orders.fulfil, content.write, design.write, settings.write, customers.write, customers.read, reports.read. Un marchand qui éteint le plugin laisse le jeton sans aucune autorisation. Chaque appel de capacité affiche un aperçu et demande confirmation au marchand avant que quoi que ce soit touchant à l'argent ne se produise, comme pour n'importe quel agent.
La clé d'import fonctionne exactement comme celle du connecteur de migration : une boutique, un travail, 24 heures, révocable. Demandez-en une nouvelle à l'expiration.
Empaquetage
Envoyez un plugin sous forme de zip : manifest.json à la racine, éventuellement cover.png (ou .jpg, .webp ; 2:1, moins de 4 Mo) pour sa carte, éventuellement sections/*.json avec une section chacun, qui sont repliés dans hooks.sections. Un readme est bienvenu et ignoré : ce que lit un marchand, c'est description et docsUrl dans le manifeste. Il n'y a pas de code dans un paquet ; ce qui s'exécute, c'est votre serveur. L'équipe enregistre le zip au catalogue, et un plugin peut en être retéléchargé sous le même zip.
Thèmes
Un plugin qui change l'allure d'une boutique est d'ordinaire un thème. C'est autre chose, et plus simple : créer des thèmes.