Build for Dunea
Plugin API reference
Every Plugin API v1 route: method, path, required permission, request and response types.
The Plugin API lets your service read and change an owner's app. Every call is made with an install access token and is limited to the permissions the owner granted that install, for that one app.
Base URL and authentication
https://api.dunea.ai/api/plugin-api/v1Send the install's access token, which you received once in plugin.installed, as a bearer token:
curl https://api.dunea.ai/api/plugin-api/v1/app \
-H "Authorization: Bearer $DUNEA_ACCESS_TOKEN"Request bodies are JSON (Content-Type: application/json).
Getting a new access token
plugin.installed carries the token once, and only plugins with a webhookUrl receive it. If you lost the token, or your plugin is panel-only, your server can exchange your listing's client secret for a new one:
curl -X POST https://api.dunea.ai/api/plugin-api/v1/token \
-H "Authorization: Bearer $DUNEA_CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{ "installId": "6f1c2a54-3b1e-4f6e-9a55-0d5f8f0e2b11" }'Name the install in one of two ways:
{ "installId": "…" }: an install of your listing that you already know about, for example from a webhook or tool call.{ "sessionToken": "…" }: the panel session token your panel received. Dunea checks it was signed with the same client secret and has not expired (10 minutes).
interface AccessTokenGrant {
accessToken: string; // dat_…
installId: string;
appId: string;
grantedPermissions: Permission[];
}Each call issues a new token and revokes the install's previous one, so store the result and reuse it rather than exchanging on every request. Only active installs of your listing can be exchanged: 401 means the client secret doesn't belong to that install's listing (or the install doesn't exist), 409 means the install isn't active (uninstalled, suspended, or awaiting payment), and 400 means the body didn't name exactly one of installId and sessionToken. Never call this from a browser: the client secret must stay on your server.
With the SDK:
import { DuneaClient, exchangeAccessToken } from '@dunea/plugin-sdk';
declare const sessionToken: string; // sent to your server by your panel
const { accessToken, installId } = await exchangeAccessToken({
clientSecret: process.env.DUNEA_CLIENT_SECRET!,
sessionToken, // or: installId
});
const dunea = new DuneaClient({ accessToken });Using DuneaClient
DuneaClient from @dunea/plugin-sdk wraps every route with types, and retries rate-limited (429) and failed (5xx, network) requests where it is safe to:
import { DuneaClient, DuneaApiError } from '@dunea/plugin-sdk';
declare const accessToken: string; // the token from plugin.installed
const dunea = new DuneaClient({ accessToken });
try {
const [latest] = await dunea.orders.list({ limit: 1 });
if (latest) {
await dunea.orders.fulfil(latest.id, { trackingNumber: '1Z999AA10123456784' });
}
} catch (error) {
if (error instanceof DuneaApiError && error.status === 403) {
console.warn('This install has not granted', error.missingPermission);
}
}| Option | Default | |
|---|---|---|
accessToken | (required) | The install's token. |
baseUrl | DUNEA_API_URL, or https://api.dunea.ai/api/plugin-api/v1 | DUNEA_API_URL may be the API origin (https://api.dunea.ai) or the full Plugin API URL. |
maxRetries | 3 | Retries for 429, 5xx and network errors. Writes that are not safe to repeat (creating a product, a stock delta) are retried only on 429. |
fetch | global fetch | Your own fetch implementation. |
Failed calls throw DuneaApiError with status, code (the API's error field, or http_<status> / network_error), message, and missingPermission on 403s.
Routes
| Method | Path | Permission | Body | Returns | DuneaClient |
|---|---|---|---|---|---|
GET | /app | app:read | ApiApp | app() | |
GET | /settings | always allowed | settings values | settings() | |
GET | /shop/products?q=&status= | shop:read | ApiProduct[] | products.list({ q, status }) | |
POST | /shop/products | shop:write | ProductCreate | ApiProduct | products.create(product) |
PATCH | /shop/products/:id | shop:write | ProductPatch | ApiProduct | products.update(id, patch) |
POST | /shop/variants/:id/stock | shop:write | { set } or { delta, note? } | ApiVariant | variants.setStock(id, set), variants.adjustStock(id, delta, note?) |
GET | /shop/orders?status=&limit= | orders:read | ApiOrder[] | orders.list({ status, limit }) | |
GET | /shop/orders/:id | orders:read | ApiOrder | orders.get(id) | |
POST | /shop/orders/:id/fulfil | orders:write | { trackingNumber?, trackingUrl? } | ApiOrder | orders.fulfil(id, tracking?) |
GET | /payments?limit= | payments:read | ApiPayment[] | payments.list({ limit }) | |
GET | /reviews?status= | reviews:read | ApiReview[] | reviews.list({ status }) | |
POST | /reviews/:id/approve | reviews:write | ApiReview | reviews.approve(id) | |
POST | /reviews/:id/reject | reviews:write | ApiReview | reviews.reject(id) | |
POST | /reviews/:id/reply | reviews:write | { reply } | ApiReview | reviews.reply(id, reply) |
POST | /usage | manifest declares usage | UsageReport | UsageReceipt | see Usage |
GET | /usage?month=YYYY-MM | manifest declares usage | UsageSummary | see Usage | |
POST | /token | client secret, not an access token | { installId } or { sessionToken } | AccessTokenGrant | exchangeAccessToken(...), see above |
Buyer names, emails and addresses are only included when the install has customers:read.
Money amounts (price, total, amount and so on) are integers in the currency's minor unit, for example cents: 2900 is 29.00 in the app's currency.
GET /app
interface ApiApp {
appId: string;
name: string;
siteUrl: string | null; // null until the app is published
paymentsMode: 'test' | 'live' | 'paused';
currency: string;
}GET /settings
Returns the install's current settings values, Record<string, string | number | boolean>, keyed by the key of each field in your manifest's settings. Secret fields are included. No permission is needed.
Products
interface ApiProduct {
id: string;
slug: string;
title: string;
description: string | null;
status: 'draft' | 'active' | 'archived';
tags: string[];
options: { name: string; values: string[] }[];
variants: ApiVariant[];
images: { url: string; alt: string | null }[];
}
interface ApiVariant {
id: string;
sku: string;
options: string[];
price: number;
compareAtPrice: number | null;
stock: number | null; // null = stock not tracked
}
interface ProductCreate {
title: string;
price: number;
description?: string;
status?: 'draft' | 'active';
tags?: string[];
options?: { name: string; values: string[] }[];
stock?: number | null;
images?: { url: string; alt?: string }[];
}
type ProductPatch = Partial<Pick<ProductCreate, 'title' | 'description' | 'status' | 'tags'>>;GET /shop/products filters by a search string q and by status. PATCH changes only the title, description, status and tags. Stock is set per variant (below); v1 has no route for changing a variant's price.
Stock
POST /shop/variants/:id/stock takes one of two bodies:
{ "set": 12 }{ "delta": -2, "note": "Damaged in warehouse" }set replaces the stock level (null stops tracking stock for the variant). delta adds or removes units, with an optional note. A delta is not safe to repeat, so retry it only when you know the first request was not processed.
Orders
interface ApiOrder {
id: string;
number: number;
status: string;
mode: 'test' | 'live';
currency: string;
subtotal: number;
shipping: number;
discount: number;
tax: number;
total: number;
items: { sku: string; title: string; variantTitle: string | null; quantity: number; unitPrice: number }[];
/** Present only with customers:read. */
buyer?: { email: string | null; name: string | null };
/** Present only with customers:read. */
shippingAddress?: Record<string, unknown> | null;
trackingNumber: string | null;
trackingUrl: string | null;
createdAt: string;
paidAt: string | null;
}POST /shop/orders/:id/fulfil marks the order fulfilled, with optional trackingNumber and trackingUrl, and returns the updated order.
Payments
interface ApiPayment {
id: string;
purpose: string;
status: string;
mode: 'test' | 'live';
amount: number;
refundedAmount: number;
currency: string;
provider: string;
attribution: Record<string, unknown> | null; // campaign attribution, when known
createdAt: string | null;
}Reviews
interface ApiReview {
id: string;
subject: string;
rating: number;
title: string | null;
body: string | null;
authorName: string;
verified: boolean;
status: 'pending' | 'approved' | 'rejected';
ownerReply: string | null;
createdAt: string;
}POST /reviews/:id/reply takes { "reply": "Thanks for the photos!" }.
Usage
Report metered usage for the install, and read it back. Only plugins whose manifest declares usage can call these. Each report is charged to the owner's credits at once, idempotent on your idempotencyKey, within the monthly cap the owner set. A 402 means the cap or the owner's balance can't cover it, and nothing was charged.
interface UsageReport {
units: number; // > 0, in your manifest's usage.unit
creditsPerUnit: number; // ≤ usage.maxCreditsPerUnit
description: string; // on the owner's statement, ≤ 120 chars
idempotencyKey: string; // ≤ 100 chars; a retry with the same key is charged once
}See Priced tools and metered usage for the receipt, the refusal body and a full example.
Errors
Errors return a non-2xx status with this body:
interface ApiError {
error: string; // machine-readable code
message: string; // human-readable explanation
/** Present on 403s: the permission the install lacks. */
missingPermission?: Permission;
}| Status | Meaning |
|---|---|
| 401 | The token is missing or not valid, for example because the plugin was uninstalled. |
| 403 | The install has not granted the permission this route needs. missingPermission says which. |
| 402 | POST /usage only: the owner's spend cap (spend_cap_reached) or credit balance (insufficient_credits) can't cover the charge. Nothing was charged. |
| 429 | Too many requests. Wait (respect Retry-After when present) and try again. |
| 5xx | A problem on Dunea's side. Retry safe requests with backoff. |
When an owner uninstalls your plugin, you receive plugin.uninstalled and the install's token stops working. Delete the token and any data you no longer need. If data.reason is "payment" (the owner's subscription lapsed), keep them instead: the same token works again if the owner renews. See plugin.uninstalled.