Build for Dunea
Checkout functions
Run your own discount, shipping, payment-method, validation, tax and offer-price logic in the middle of a Dunea checkout, with a hard time budget and a safe fallback.
A checkout function is logic Dunea calls while it prices a cart: a bundle discount, live carrier rates, "no cash on delivery over ₹10,000", "max 2 per customer", a tax engine, or a lower funnel price for returning visitors.
Your code never runs inside Dunea. A function is an HTTPS endpoint on your own service. Dunea sends it a signed request, waits a fixed time for the answer, validates and clamps it, and carries on. If your service is slow, down or answers something invalid, Dunea uses the target's fallback and the buyer still checks out.
Declare them in the manifest
{
"permissions": ["checkout:functions", "shop:read", "orders:read"],
"functions": [
{
"target": "shop.discount",
"url": "https://bundles.example.com/dunea/fn",
"title": "Bundle discounts",
"order": 100
},
{
"target": "shop.shipping-rates",
"url": "https://bundles.example.com/dunea/fn",
"title": "Weight-based shipping"
}
]
}| Field | Rules |
|---|---|
target | One of the targets below. One function per target per plugin. |
url | A public https:// URL. One URL for every target is fine: the request says which target it is. |
title | 2 to 60 characters. Shown to the owner. |
description | Optional, up to 300 characters. |
order | 0 to 1000, default 100. Lower runs first; it decides who wins where answers compete (see composition). |
A plugin can declare up to 10 functions.
Targets
| Target | Input | May return | Fallback | Permission |
|---|---|---|---|---|
shop.discount | cart | discounts (never more than the subtotal) | no discount | shop:read |
shop.shipping-rates | cart + address | shipping options that replace or add to the defaults | the shop's default rates | orders:read |
shop.payment-methods | cart | providers to hide, or an order for them | every enabled method | shop:read |
shop.cart-validation | cart | errors that block checkout | checkout allowed | shop:read |
shop.tax | cart + address | tax lines (exclusive-tax shops only) | the shop's tax settings | orders:read |
funnels.offer-price | offer + session | a lower price for this visitor | the offer price | shop:read |
Every function needs checkout:functions and its target's permission, granted by the owner at install. Without both, Dunea never calls it (validate warns you).
Shipping and tax functions only run for shops with the shipping or tax module switched on.
The request
Dunea POSTs JSON to your url:
POST /dunea/fn
content-type: application/json
dunea-signature: t=1793782800,v1=5f0c…
dunea-event: function.run
dunea-install-id: 6b1e…interface FunctionRequest {
target: 'shop.discount' | 'shop.shipping-rates' | 'shop.payment-methods'
| 'shop.cart-validation' | 'shop.tax' | 'funnels.offer-price';
installId: string;
appId: string;
mode: 'test' | 'live';
cart: {
currency: string; // "INR"
subtotal: number; // minor units (paise, cents)
lines: {
variantId: string;
productId: string;
sku: string | null;
title: string;
quantity: number;
unitPrice: number;
lineTotal: number;
tags: string[]; // product tags
metafields?: Record<string, unknown>; // the product's storefront-public metafields, "namespace.key" (absent when it has none)
}[];
discountCode?: string;
buyer?: { email?: string; country?: string };
shippingAddress?: { country: string; region?: string; postalCode?: string; city?: string };
attribution?: Record<string, string>; // utm_source, utm_campaign…
};
offer?: { id: string; amount: number; title: string }; // funnels.offer-price only
}Only the lines that can be sold are sent. All amounts are integer minor units. buyer.email is only known at checkout, not when the cart is quoted.
The signature is the same scheme as webhooks: HMAC-SHA256 of "<t>.<raw body>" with your client secret, checked over the raw bytes, rejected after 5 minutes.
The response
Answer 200 with JSON for the target:
// shop.discount
{ discounts: { title: string; target: 'order' | 'shipping' | { variantIds: string[] };
value: { type: 'percent'; percent: number } | { type: 'amount'; amount: number } }[] } // ≤ 10
// shop.shipping-rates
{ mode: 'replace' | 'add';
rates: { id: string; title: string; amount: number; description?: string; minDays?: number; maxDays?: number }[] } // ≤ 10
// shop.payment-methods (provider ids: 'stripe', 'razorpay', 'cod', …)
{ hide?: string[]; order?: string[] }
// shop.cart-validation (empty list = allowed)
{ errors: { message: string; variantId?: string }[] }
// shop.tax
{ lines: { title: string; amount: number }[] }
// funnels.offer-price
{ amount: number }Percent discounts are read to two decimals (12.5 → 12.50%) and round down. Shipping rate ids should use letters, digits and _ . : / -: the storefront sends the buyer's pick back as shippingRateId. Anything that isn't a 200 with a body matching the schema is treated as "no answer" and the fallback applies.
Time budget and fallbacks
Each target gets 1.5 seconds (FUNCTION_TIMEOUT_MS) in total. All functions for that target are called in parallel within that budget; the targets of one cart run in parallel too. When the budget runs out, the functions that haven't answered fall back; the ones that did still count.
A fallback is never an error for the buyer: no discount, the shop's own shipping rates, every payment method, checkout allowed, the shop's tax, the offer's own price. Dunea logs each fallback with its reason (timeout, HTTP status, bad JSON, wrong shape) and counts fallbacks per install per day; the site owner sees "failed N times today" next to your plugin. Redirects are not followed and private or internal addresses are refused.
Caching: the price they saw is the price they pay
Answers are cached for 10 minutes (CART_FUNCTION_CACHE_SECONDS) per install, version, target and cart, in a cache shared by every Dunea server. When the storefront quotes a cart and the buyer checks out the same cart, checkout reuses the quoted answers, so totals can't change in between.
The cart key is: mode, currency, lines (variant, quantity, prices, tags, metafields), discount code, shipping address and buyer country. Email and attribution are passed for context but are not part of the key. A failed call is cached for 30 seconds so a struggling service isn't hammered.
Your function must be deterministic for the same cart. Don't randomise, and don't count uses inside it (it is called for quotes too).
How answers combine
Several plugins can answer the same target. Dunea combines them in order (lowest first):
| Target | Rule |
|---|---|
shop.discount | Discounts add up. Items discounts (order, variantIds) are applied after the shop's own discount code, each capped at what is left, so the total discount never exceeds the subtotal. shipping discounts never exceed the shipping. |
shop.shipping-rates | The lowest-order function answering replace replaces the shop's rates; every add answer is appended (after the shop's flat rate). Duplicate ids keep the first. The buyer picks one; the first is the default. |
shop.payment-methods | hide is the union of every answer. order comes from the first function that gave one. The buyer's own pick and the owner's currency override still come first; hidden providers are never used. |
shop.cart-validation | Errors are the union. Any error blocks checkout with a 400 and validationErrors, which the storefront shows. |
shop.tax | The first answer's lines replace the shop's tax, only for shops whose prices exclude tax. Tax-inclusive shops keep their own tax. |
funnels.offer-price | The lowest answer wins, never above the offer price. Funnel offers can't be free, so an answer of 0 is ignored; keep answers at or above your payment provider's minimum charge. The funnel page shows the same price the buyer is charged (the list price becomes the compare-at price). |
Dunea's own defaults are built-in functions of the same targets: flat-rate shipping (shop.shipping-rates, free over the shop's threshold) and the shop's tax settings (shop.tax). Your answers combine with them under the rules above. When no plugin answers, checkout prices exactly as it did without functions.
The order records which plugins' answers were applied (adjustments.functions), the discount and tax lines they produced, and the shipping option charged (shippingRate).
Example: buy 3, get 10% off, and weight-based shipping
import { createFunctionHandler, toFetchHandler } from '@dunea/plugin-sdk';
const handler = createFunctionHandler({
secret: process.env.DUNEA_CLIENT_SECRET!,
handlers: {
'shop.discount': ({ cart }) => {
const count = cart.lines.reduce((n, l) => n + l.quantity, 0);
return {
discounts:
count >= 3
? [{ title: 'Buy 3, get 10% off', target: 'order', value: { type: 'percent', percent: 10 } }]
: [],
};
},
// ₹40 base + ₹10 per started 500 g. Weight from a public metafield, 500 g when unset.
'shop.shipping-rates': ({ cart }) => {
const grams = cart.lines.reduce(
(g, l) => g + l.quantity * Number(l.metafields?.['shipping.grams'] ?? 500),
0,
);
return {
mode: 'replace',
rates: [{ id: 'weight', title: 'Standard', amount: 4_000 + Math.ceil(grams / 500) * 1_000, maxDays: 5 }],
};
},
},
onError: (err, req) => console.error(req.target, err),
});
// Cloudflare Workers, Next.js route handlers, Hono, Bun, Deno:
export default { fetch: toFetchHandler(handler) };
// Express: app.post('/dunea/fn', express.raw({ type: '*/*' }), toExpressMiddleware(handler));createFunctionHandler verifies the signature (401 when it doesn't match), dispatches on target (404 when you have no handler, so Dunea falls back), and validates your result before sending it: an invalid result becomes a 500 and reaches onError, instead of a silent fallback you'd never notice.
To verify by hand, use verifyFunctionRequest(rawBody, headers, secret).
In the storefront
Generated storefronts show function results without any work from you: automatic discount lines under the subtotal, a shipping choice when there's more than one option, plugin validation messages next to the pay button, and payment methods you hid are not offered. The storefront quotes the cart with the buyer's country and postcode as soon as they're entered, so your shipping and tax functions see the address before checkout.
Admin extensions
Put your plugin's cards and actions inside Dunea's own panels: on a product, an order, a selection of orders, a funnel, a payment, a review or the app overview.
Automations
Add triggers and actions that site owners use in their automations: send events from your service, run steps Dunea calls on your webhook.