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/v1

Send 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);
  }
}
OptionDefault
accessToken(required)The install's token.
baseUrlDUNEA_API_URL, or https://api.dunea.ai/api/plugin-api/v1DUNEA_API_URL may be the API origin (https://api.dunea.ai) or the full Plugin API URL.
maxRetries3Retries for 429, 5xx and network errors. Writes that are not safe to repeat (creating a product, a stock delta) are retried only on 429.
fetchglobal fetchYour 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

MethodPathPermissionBodyReturnsDuneaClient
GET/appapp:readApiAppapp()
GET/settingsalways allowedsettings valuessettings()
GET/shop/products?q=&status=shop:readApiProduct[]products.list({ q, status })
POST/shop/productsshop:writeProductCreateApiProductproducts.create(product)
PATCH/shop/products/:idshop:writeProductPatchApiProductproducts.update(id, patch)
POST/shop/variants/:id/stockshop:write{ set } or { delta, note? }ApiVariantvariants.setStock(id, set), variants.adjustStock(id, delta, note?)
GET/shop/orders?status=&limit=orders:readApiOrder[]orders.list({ status, limit })
GET/shop/orders/:idorders:readApiOrderorders.get(id)
POST/shop/orders/:id/fulfilorders:write{ trackingNumber?, trackingUrl? }ApiOrderorders.fulfil(id, tracking?)
GET/payments?limit=payments:readApiPayment[]payments.list({ limit })
GET/reviews?status=reviews:readApiReview[]reviews.list({ status })
POST/reviews/:id/approvereviews:writeApiReviewreviews.approve(id)
POST/reviews/:id/rejectreviews:writeApiReviewreviews.reject(id)
POST/reviews/:id/replyreviews:write{ reply }ApiReviewreviews.reply(id, reply)
POST/usagemanifest declares usageUsageReportUsageReceiptsee Usage
GET/usage?month=YYYY-MMmanifest declares usageUsageSummarysee Usage
POST/tokenclient secret, not an access token{ installId } or { sessionToken }AccessTokenGrantexchangeAccessToken(...), 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;
}
StatusMeaning
401The token is missing or not valid, for example because the plugin was uninstalled.
403The install has not granted the permission this route needs. missingPermission says which.
402POST /usage only: the owner's spend cap (spend_cap_reached) or credit balance (insufficient_credits) can't cover the charge. Nothing was charged.
429Too many requests. Wait (respect Retry-After when present) and try again.
5xxA 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.

On this page