Build for Dunea
Automations
Add triggers and actions that site owners use in their automations: send events from your service, run steps Dunea calls on your webhook.
Site owners build automations in the Automations panel (or by asking the Dunea AI): when something happens, check some conditions, then run steps in order.
trigger shop.order.paid, forms.submitted… or yours: acme-labels.label.printed
conditions order.total is at least 500000, buyer.paidOrders is 1…
steps email the owner, POST a webhook, create a discount… or yours: acme-labels.print_labelYour plugin can contribute both ends:
- Triggers: events your service sends to Dunea with
POST /events/:trigger. Owners start automations on them. - Actions: steps Dunea calls on your
webhookUrlas a signedautomation.actionrequest, exactly like an AI tool call.
Both need the automations:extend permission. Actions also need a webhookUrl.
Declare them in the manifest
{
"permissions": ["automations:extend", "orders:read"],
"webhookUrl": "https://labels.example.com/dunea",
"automation": {
"triggers": [
{
"id": "label.printed",
"title": "Label printed",
"description": "A shipping label was bought and printed.",
"fields": [
{ "key": "label_id", "label": "Label id", "type": "single_line_text", "required": true },
{ "key": "carrier", "label": "Carrier", "type": "single_line_text" },
{ "key": "cost", "label": "Cost (minor units)", "type": "integer" }
]
}
],
"actions": [
{
"id": "print_label",
"title": "Print a shipping label",
"description": "Buys and prints a label for the order in the event.",
"input": [
{
"key": "carrier",
"label": "Carrier",
"type": "select",
"required": true,
"options": [
{ "value": "dhl", "label": "DHL" },
{ "value": "ups", "label": "UPS" }
]
}
]
}
]
}
}- Fields and inputs use the same typed fields as custom data (
single_line_text,integer,select,url…). Owners build conditions on trigger fields and fill action inputs with a generated form. - Ids are namespaced with your listing slug: owners see
acme-labels.label.printedandacme-labels.print_label. A namespaced id that collides with a built-in one is never used, so don't name a trigger after a Dunea event. - Up to 20 triggers and 20 actions per plugin.
Send a trigger
import { DuneaClient } from '@dunea/plugin-sdk';
const dunea = new DuneaClient({ accessToken });
await dunea.sendTrigger(
'label.printed',
{ label_id: 'L-1042', carrier: 'dhl', cost: 45000 },
{ idempotencyKey: 'label-L-1042' },
);
// → { accepted: true, runs: 2 }The raw request:
POST /api/plugin-api/v1/events/label.printed
Authorization: Bearer <install access token>
Content-Type: application/json
{ "data": { "label_id": "L-1042", "carrier": "dhl", "cost": 45000 }, "idempotencyKey": "label-L-1042" }| Answer | Meaning |
|---|---|
202 { accepted: true, runs } | Queued. runs is how many of the owner's automations will run (0 is normal). |
400 invalid_input | data doesn't match the trigger's fields (unknown keys are refused), or a bad idempotencyKey. errors has a message per field. |
403 forbidden | The install wasn't granted automations:extend (missingPermission). |
404 not_found | The trigger isn't in the installed version's manifest. |
413 | data is over 16 KB. |
429 rate_limited | More than 120 events a minute for this install. |
Idempotency. Each automation runs at most once per event. With an idempotencyKey (1–128 of A-Z a-z 0-9 _ . : -), a retried send with the same key is the same event. Without one, Dunea hashes data: two sends with identical data count as one event. Send a key whenever two genuinely different events could carry the same data.
The trigger is sent with your install's access token, so it only ever reaches the automations of the site that installed you.
Run actions
Dunea POSTs a signed request to your webhookUrl when an owner's automation reaches your step:
{
"type": "automation.action",
"action": "print_label",
"installId": "6d0c…",
"appId": "k3j2…",
"runId": "0b9e…",
"input": { "carrier": "dhl" },
"event": {
"type": "shop.order.paid",
"data": { "order": { "id": "…", "number": 1042, "total": 249900, "currency": "INR" }, "buyer": { "email": "…" } }
}
}Headers are the same as for webhooks: dunea-signature (signed with your client secret), dunea-event: automation.action, dunea-delivery-id: <runId>:<step>, dunea-install-id. input is the owner's input with {{placeholders}} already filled in; event.data is the trigger's data.
Handle it with onAutomationAction in the webhook handler you already use for events and tool calls:
import { createWebhookHandler, toFetchHandler } from '@dunea/plugin-sdk';
const handler = createWebhookHandler({
clientSecret: process.env.DUNEA_CLIENT_SECRET!,
onAutomationAction: {
print_label: async (input, request) => {
const order = request.event.data.order as { id: string };
const label = await buyLabel(order.id, String(input.carrier));
return { ok: true, message: `Label ${label.id}`, output: { labelId: label.id, trackingUrl: label.url } };
},
},
});
export default { fetch: toFetchHandler(handler) };Answer within 15 seconds:
| Your answer | What happens |
|---|---|
200 { ok: true, output? } | The step succeeded. output (a JSON object, max 4 KB) is available to later steps as {{steps.<index>.<key>}}, e.g. {{steps.0.trackingUrl}}. |
200 { ok: false, message } | The step failed and is not retried. The owner sees message in the run log. Use it for "this can't work" (no address, carrier refused). |
| Non-2xx, timeout, network error | Retried with backoff: 1 m, 5 m, 30 m, 2 h, 12 h, then the run fails. The SDK answers 500 when your handler throws. |
Steps that already succeeded are never re-run, but your step can be called more than once (a retry after a timeout). Dedupe on runId + action. Redirects are not followed.
Built-in triggers and actions
Owners can mix your triggers and actions with Dunea's own:
| Triggers | |
|---|---|
| Shop | shop.order.paid, shop.order.fulfilled, shop.order.refunded, shop.stock.low |
| Payments | payments.payment.succeeded, payments.payment.refunded, payments.payment.failed |
| Leads | forms.submitted, funnels.lead.captured, auth.user.created |
| Other | reviews.submitted, core.app.published |
| Actions | |
|---|---|
email.owner, email.customer | Plain-text email (rate-limited per site per hour; customer emails only on order, lead and account triggers) |
webhook.post | Signed JSON POST to the owner's https URL |
data.set_metafield | Write a custom field on the order, product, variant or site |
shop.order.add_note, shop.discount.create | Note on the order; single-use code (output code) |
email-marketing.subscribe | Add to the connected email list (subscribed only with an opt-in) |
flow.wait | Pause the run up to 30 days |
Owner webhooks (webhook.post)
When an owner's step posts to their own URL, the body is:
{ "type": "automation.webhook", "id": "<runId>:<step>", "automation": { "id": "…", "name": "…" }, "trigger": "funnels.lead.captured", "appId": "…", "createdAt": "…", "data": { … } }It's signed like every Dunea request (dunea-signature: t=…,v1=…, HMAC-SHA256 of `${t}.${body}`), but with the site's automations signing secret (shown in the panel when a step posts to a webhook), not a plugin client secret. verifyWebhook checks the signature; the body type is automation.webhook, so use verifySignedBody to verify and parse it.
Review checklist
- Triggers carry only the data owners need for conditions and messages; no secrets.
- Actions answer fast and idempotently; long work is queued on your side and reported with a trigger later.
messageonok: falsetells the owner what to fix.