Criar plugins para a WhizzyCommerce
Um plugin é um manifesto: um nome, uma versão, as definições que pede ao comerciante, e os hooks em que se liga. Um manifesto não leva código. Um widget de chat é um modelo de script com as definições do comerciante substituídas lá dentro; um blogue são tipos de conteúdo, uma secção e páginas que nós desenhamos. Tudo o que tem de correr, corre no seu servidor, e nós chamamo-lo por HTTPS assinado.
O manifesto
{
"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"]
}
}Categorias: content, marketing, payments, shipping, messaging, integration, other. As definições usam os mesmos tipos de campo das definições de um tema; uma chave nomeada em secrets é selada em repouso, mascarada no formulário e nunca substituída num script. Os tipos de secção e de conteúdo têm de estar no espaço de nomes do seu identificador, como acme-rates.banner.
O pessoal regista o manifesto a partir da administração e publica uma versão. Uma versão nova é um manifesto novo com um número mais alto; as lojas ficam com a versão que instalaram até o comerciante decidir atualizar.
Hooks sem servidor
scripts: um modelo de JavaScript por gatilho, com {{settings.key}} substituído como literal escapado, posto em todas as páginas da loja exceto a de pagamento, depois do consentimento do comprador quando a loja o pede. sections: o mesmo JSON declarativo que uma secção de tema é, listado no editor de páginas. metaobjects: tipos de conteúdo que o comerciante preenche. dashboard (um ecrã com create também ganha uma linha no menu de mais) e routes são para plugins que vão dentro da plataforma; uma definição de texto com a chave route deixa o comerciante mover o primeiro desses caminhos, que é como um blogue passa a viver em /journal.
placement diz onde o comerciante encontra o plugin depois de instalado: settings (o padrão) é uma linha no fundo das Definições da loja, a apontar para o formulário de definições do plugin; rail dá-lhe um ícone próprio na barra do painel, nomeado por icon, com os ecrãs dele no painel ao lado. A secção de Plugins em si nunca cresce.
Páginas que um tema pode arranjar
Um plugin que traz uma página à loja, um blogue, um localizador de lojas, um lookbook, deve aos temas uma coisa: a página dele como dados. Nos modelos dele põe tudo o que a página mostra em âmbito como plugin, em valores simples, e documenta a forma; um tema faz o arranjo da página por cima disso com os nós normais, tal como faz com um produto ou com o carrinho. O que os dados não conseguem fazer, um formulário ou qualquer outra coisa que corra, é uma parte que a plataforma possui e que um tema coloca. O arranjo já feito dele é construído exatamente com esses dados e essas partes, por isso tudo o que ele mostra, um tema pode mostrar de outra maneira.
O blogue é o primeiro: os artigos, um artigo, a barra lateral e as palavras são plugin.list, plugin.post, plugin.sidebar e plugin.labels, e a única parte de que precisou é search-form. Veja a referência para cada campo.
Hooks com servidor
Defina remote.baseUrl e nós fazemos POST de JSON para {baseUrl}/hooks/<name>. Cada pedido leva três cabeçalhos:
x-whizzy-timestamp: 1789000000000 milliseconds since the epoch x-whizzy-signature: <hex> HMAC-SHA256 with your signing secret x-whizzy-plugin: acme-rates
A cadeia assinada é timestamp + "\n" + "POST" + "\n" + path + "\n" + body, onde path é o caminho do URL que chamámos (por exemplo /whizzy/hooks/event) e body são os bytes exatos. Recuse um pedido cujo carimbo esteja mais de 60 segundos fora do seu relógio, e compare assinaturas em tempo constante. O seu segredo de assinatura é mostrado uma vez, quando o pessoal regista o 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"));
}Responda 2xx com JSON. Qualquer outra coisa é uma falha; os eventos são repetidos, as cotações são ignoradas.
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_…" }Uma resposta não-2xx ao install recusa a instalação: o comerciante vê o texto da sua resposta e nada fica guardado. As definições são enviadas outra vez em cada atualização; uma definição secreta nunca é enviada.
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": { … } } }Eventos: order.paid, order.fulfilled, order.refunded. Entregues pelo menos uma vez, dentro de cerca de um minuto, e repetidos com recuo até um dia; o deliveryId é o mesmo em cada repetição, use-o para ignorar um duplicado.
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 } ] }Preços em unidades menores da moeda da loja. Pedido no checkout com três segundos de tempo limite; uma resposta lenta são zero cotações, não um checkout lento. A sua cotação é oferecida ao lado dos métodos do próprio comerciante, e a encomenda regista qual foi escolhida como 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" }. A etiqueta é a opção que o comprador vê; escolhê-la manda-o para o seu url, que tem de ser https. Quando volta a returnUrl nós chamamos check, e a resposta dele é a autoridade: não há webhook para um plugin. Responda pending até saber; o comerciante também pode marcar a encomenda como paga à mão.
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 e o correio para clientes da loja, recibos e notas de expedição, passa a ser-lhe entregue em vez de ir pelo nosso servidor de correio, uma mensagem por chamada. Uma resposta não-2xx é repetida como a nossa própria fila repete. O correio que enviamos ao comerciante fica do nosso lado.
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. Enviado quando uma encomenda é confirmada e quando uma encomenda é despachada, para o número que o comprador deu, com o melhor esforço: uma mensagem que não sai não é repetida.
Chamar a loja
O accessToken da chamada de instalação é um token portador para essa loja apenas. Vive enquanto a instalação viver e morre com ela. Duas portas:
# 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" } }O que o token pode fazer na API de capacidades é exatamente hooks.grants do seu manifesto, lido da loja em cada chamada: catalog.write, pricing.write, orders.fulfil, content.write, design.write, settings.write, customers.write, customers.read, reports.read. Um comerciante que desligue o plugin deixa o token sem permissão nenhuma. Cada chamada de capacidade pré-visualiza e pede confirmação ao comerciante antes de acontecer qualquer coisa com dinheiro, tal como faz para qualquer agente.
A chave de importação funciona exatamente como a do conector de migração: uma loja, um trabalho, 24 horas, revogável. Peça uma nova quando expirar.
Empacotar
Envie um plugin como zip: manifest.json na raiz, opcionalmente cover.png (ou .jpg, .webp; 2:1, abaixo de 4 MB) para o cartão dele, opcionalmente sections/*.json com uma secção cada, que são dobrados em hooks.sections. Um readme é bem-vindo e ignorado: o que o comerciante lê é description e docsUrl no manifesto. Não há código num pacote; o que corre é o seu servidor. O pessoal regista o zip no catálogo, e um plugin pode ser descarregado de volta como o mesmo zip.
Temas
Um plugin que muda o aspeto de uma loja é normalmente um tema. Isso é uma coisa diferente, e mais simples: criar temas.