Build for Dunea

Quickstart: your first plugin

Build a plugin that posts paid orders to Slack: scaffold, handle shop.order.paid, validate, publish and submit for review.

In this guide you build Order alerts, a plugin that posts a message to a Slack channel whenever an order is paid in the owner's shop. Along the way you use every core piece: the manifest, owner settings, the install token, a signed webhook and the Plugin API.

You need Node.js 18 or later, a Dunea account, and a way to expose a local server over HTTPS (any tunnel works, or deploy straight to your host).

Scaffold the project

npx -p @dunea/plugin-sdk dunea-plugin init order-alerts
cd order-alerts
npm install

init creates:

FileWhat it is
dunea.plugin.jsonThe manifest
src/webhook.tsA starter webhook handler
package.jsonWith @dunea/plugin-sdk and validate / publish scripts
README.md, .gitignore

Describe the plugin

Replace dunea.plugin.json with this. Use your own developer details, icon and privacy policy URL, and point webhookUrl at the HTTPS address your server will have.

dunea.plugin.json
{
  "schemaVersion": 1,
  "kind": "plugin",
  "id": "order-alerts",
  "version": "0.1.0",
  "name": "Order alerts for Slack",
  "tagline": "Get a Slack message the moment an order is paid.",
  "description": "Order alerts posts a short message to the Slack channel of your choice for every paid order in your Dunea shop: the order number, the total and the number of items. Paste a Slack incoming webhook URL when you install it and you are done.",
  "icon": "https://example.com/order-alerts/icon.png",
  "developer": {
    "name": "Your Company",
    "website": "https://example.com",
    "supportEmail": "support@example.com",
    "privacyUrl": "https://example.com/privacy"
  },
  "pricing": { "model": "free" },
  "categories": ["sales", "productivity"],
  "permissions": ["orders:read"],
  "subscribes": ["plugin.installed", "plugin.uninstalled", "plugin.settings.updated", "shop.order.paid"],
  "webhookUrl": "https://order-alerts.example.com/dunea/webhook",
  "settings": [
    {
      "key": "slackWebhookUrl",
      "label": "Slack incoming webhook URL",
      "type": "secret",
      "required": true,
      "help": "In Slack, create an incoming webhook for the channel and paste its URL here."
    }
  ]
}

A few things to notice:

  • shop.order.paid needs the orders:read permission. The validator rejects the manifest without it.
  • webhookUrl is required because the plugin subscribes to events.
  • The owner fills in slackWebhookUrl at install. It is a secret field, so Dunea stores it encrypted and does not show it again.

Handle the events

Replace src/webhook.ts:

src/webhook.ts
import { createServer } from 'node:http';
import { createWebhookHandler, DuneaClient } from '@dunea/plugin-sdk';

interface Install {
  accessToken: string;
  slackWebhookUrl: string;
}

// Use a real database in production, and encrypt the access token.
// Maps keep this example short, and are lost when the server restarts.
const installs = new Map<string, Install>();
const processed = new Set<string>();

const handler = createWebhookHandler({
  clientSecret: process.env.DUNEA_CLIENT_SECRET!,
  onEvent: {
    // Sent once per install, with the only copy of the access token.
    'plugin.installed': async (env) => {
      installs.set(env.installId, {
        accessToken: env.data.accessToken,
        slackWebhookUrl: String(env.data.settings.slackWebhookUrl ?? ''),
      });
    },

    // The owner changed a setting: read the current values.
    'plugin.settings.updated': async (env) => {
      const install = installs.get(env.installId);
      if (!install) return;

      const settings = await new DuneaClient({ accessToken: install.accessToken }).settings();
      install.slackWebhookUrl = String(settings.slackWebhookUrl ?? '');
    },

    'plugin.uninstalled': async (env) => {
      installs.delete(env.installId);
    },

    'shop.order.paid': async (env) => {
      // Deliveries are at-least-once: skip events you have already handled.
      if (processed.has(env.id)) return;

      const install = installs.get(env.installId);
      const orderId = String(env.data.orderId ?? '');
      if (!install || !orderId) return;

      const dunea = new DuneaClient({ accessToken: install.accessToken });
      const order = await dunea.orders.get(orderId);

      const total = (order.total / 100).toFixed(2); // amounts are in minor units
      const prefix = env.mode === 'test' ? '[test] ' : '';
      const response = await fetch(install.slackWebhookUrl, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          text: `${prefix}Order #${order.number} paid: ${total} ${order.currency}, ${order.items.length} item(s)`,
        }),
      });

      // Throwing returns a 500, so Dunea retries the delivery later.
      if (!response.ok) throw new Error(`Slack returned ${response.status}`);

      processed.add(env.id);
    },
  },
  onError: (error, body) => console.error('Dunea webhook failed', body.id, error),
});

const server = createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/dunea/webhook') {
    res.writeHead(404).end();
    return;
  }

  // Read the raw body: the signature is over these exact bytes.
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk as Buffer);

  const result = await handler({ rawBody: Buffer.concat(chunks), headers: req.headers });
  res.writeHead(result.status, { 'content-type': 'application/json' }).end(JSON.stringify(result.body));
});

server.listen(Number(process.env.PORT ?? 3000));

createWebhookHandler checks the dunea-signature header against your client secret, rejects anything older than 5 minutes, and calls the handler for the event. Events you have no handler for get a 200 and are ignored.

On a platform with fetch-style handlers (Cloudflare Workers, Bun, Deno, Next.js route handlers), replace the node:http server with export default { fetch: toFetchHandler(handler) }. See Webhooks and signatures.

Validate

npx dunea-plugin validate
OK dunea.plugin.json: plugin "order-alerts" v0.1.0
  permissions: orders:read
  events: plugin.installed, plugin.uninstalled, plugin.settings.updated, shop.order.paid
  agent tools: (none)

This runs exactly the schema checks the marketplace runs. Fix anything it reports before you publish.

npx dunea-plugin check runs the rest of the review checklist too, and npx dunea-plugin dev --app <appId> installs your plugin on one of your own apps so you can try it before review (it needs DUNEA_DEV_TOKEN, see the next step).

Get a developer token

Create your developer profile and a developer token in the Dunea developer portal. The token is shown once. Export it in your shell:

export DUNEA_DEV_TOKEN=...

Publish a draft

npx dunea-plugin publish

The first publish creates the listing and uploads version 0.1.0 as a draft. It also prints your client secret, once.

Copy the client secret into your server's environment as DUNEA_CLIENT_SECRET straight away. Dunea cannot show it again. If you lose it, rotate it in the developer portal.

Run it and send a test delivery

Start your server with the secret set, and make it reachable at your webhookUrl:

DUNEA_CLIENT_SECRET=... npx tsx src/webhook.ts

Then ask Dunea to send a signed test event to your webhookUrl:

npx dunea-plugin test-webhook order-alerts

It prints the delivery's ID, the event it sent, its status and the HTTP status your server returned. delivered with HTTP 200 means your endpoint is wired up. A 401 means the signature check failed: check DUNEA_CLIENT_SECRET, and that nothing parses the body before the handler sees it.

Submit for review

npx dunea-plugin publish --submit

This submits the 0.1.0 draft for review. If you changed the manifest since the draft was uploaded, bump version first, then run publish --submit to upload and submit the new version in one go.

Follow the review with:

npx dunea-plugin status order-alerts

It lists each version with its status (draft, in_review, approved, rejected or superseded), any failed automatic checks, and the reviewer's notes.

Next steps

On this page