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 installinit creates:
| File | What it is |
|---|---|
dunea.plugin.json | The manifest |
src/webhook.ts | A starter webhook handler |
package.json | With @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.
{
"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.paidneeds theorders:readpermission. The validator rejects the manifest without it.webhookUrlis required because the plugin subscribes to events.- The owner fills in
slackWebhookUrlat install. It is asecretfield, so Dunea stores it encrypted and does not show it again.
Handle the events
Replace 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 validateOK 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 publishThe 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.tsThen ask Dunea to send a signed test event to your webhookUrl:
npx dunea-plugin test-webhook order-alertsIt 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 --submitThis 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-alertsIt lists each version with its status (draft, in_review, approved, rejected or superseded), any failed automatic checks, and the reviewer's notes.
Next steps
- Let the Dunea AI use your plugin: AI tools
- Give owners a dashboard: Workbench panels
- Everything you can call: Plugin API reference
- What reviewers check: Publishing, review and guidelines
- Charge per use: Priced tools and metered usage
- Everything you can earn from: Ways to earn