The Milofly plugin platform lets you build third-party apps that add capabilities to stores. An app touches a store through three surfaces: the REST API (read/write store data), webhooks (subscribe to events) and its own admin UI (the plugin's multi-page app; embedded into the store admin with a short-lived token the server signs). For checkout-time calculations (commission, shipping cost), Functions (WASM running in the Milofly sandbox) are coming.
My apps → New app. The key format is app.company.name (e.g. app.acme.shipping) — separate from the core plugin namespace. On creation these secrets are returned only once; store them safely:
client_id cli_xxxxxxxx // public identity client_secret cs_xxxxxxxx // used in the token exchange webhook_signing_secret xxxxxxxx // verify the webhook signature
A draft (or in-review) app can be installed only into registered test stores — so an unapproved plugin cannot slip into real stores. That is why your first job is to open a test store: Test stores → Open a test store. A real store is provisioned (its own database, admin and storefront), it is free and no plan is charged automatically. On creation its code is written into the test list (test_store_codes) of the app you picked, and you reach the store admin in one click — with the same Milofly account, without creating a separate user.
In the store admin: Plugins → pick your app → approve the scopes. You walk through the whole consent + OAuth flow exactly as the store owner will see it.
webhook URL field on the app detail's Webhooks tab:# Cloudflare (a quick tunnel that needs no account) cloudflared tunnel --url http://localhost:3000 # or ngrok http 3000 # Write the resulting https address into your app's webhook URL: # https://<rastgele>.trycloudflare.com/webhook # The tunnel address CHANGES on every start → remember to update it.
Once the address is in, verify your signature from the Webhooks tab of the app detail with Send test event: real HMAC, real headers, real envelope. The same tab shows the state of the queue (pending / delivered / dead letter) and the last error — information that used to exist on no screen at all.
The eventId of test events starts with test:, so you can tell test traffic apart on your side. Test deliveries are not written to the queue.
An install can start from two places, and both are documented flows: (a) the store owner clicks your app in the Plugins list in the admin — the consent screen is reached with no parameters at all; (b) your app sends the user from your own page to the authorization URL. On both routes the authorization code is generated not on the “Install” click, but when the store owner clicks Approve on the consent screen.
The core has no separate /authorize API endpoint for OAuth. The authorization surface is a page inside the store admin: {Admin:BaseUrl}/admin/plugins/install/{app_key}. Today's value is https://app.milofly.com. The core does not announce this address to you — you write it into an environment variable on your own server. The path segment (/admin/plugins/install/{app_key}) is a protocol value and is pinned by core contract tests.
app.milofly.com), not the platform admin (admin.milofly.com). If you use the platform address the store owner will never reach the consent screen.# The full URL your app builds (each parameter is explained below):
https://app.milofly.com/admin/plugins/install/app.acme.kargo
?response_type=code
&client_id=cli_9f2c1a...
&scope=orders.read%20products.read # the scope separator is a SPACE (%20 in the URL) — comma/plus are not accepted
&state=<the signed CSRF token you generate>
# do NOT send redirect_uri — if you do, the install dies with 400 (see below)response_type=code is the OAuth 2.0 convention and apps do write it — but the core does not read it. The consent screen reads only three parameters from the query string: state, client_id, scope. If response_type is omitted entirely the flow works exactly the same; if you send a wrong value (like token) you get no warning either, it is silently ignored. Send it anyway: you stay standards-compliant and you will not break if the contract tightens later.
The scope= separator is a space (RFC 6749 §3.3); there is no tolerance for commas or plus signs. The scope in the URL does not narrow the granted permission set — it is only a validation input: if it contains a permission beyond the registered ceiling, the screen is never drawn at all. The set actually granted is the app registration's requested_scopes minus the optional permissions the store owner turned off. This is deliberate: if the URL narrowed the set, webhook subscriptions that depend on a scope you forgot to list would never be created, and the install would still look “successful”.
redirect_uris array in your app registration, and the server compares it against the registered list exactly (case-sensitive, including any trailing slash). If you append redirect_uri= by hand the two values diverge and the install dies with 400 "redirect_uri is not registered." For the same reason, registering multiple return addresses is useless today: the second and later entries are never selected — if you think “let me register my dev and prod addresses together”, the prod install will return to the dev address. The address also cannot carry a fragment (#).state is a signed CSRF token you generate; the core does not generate it, validate it, truncate it or re-encode it — it hands it back verbatim on return (only URL escaping is applied). Limits: at most 512 characters and no control characters (including CR/LF); on violation the approve/deny endpoints return 400 "state is invalid: it must be at most 512 characters and contain no control characters." If there is no state, the state= parameter is not appended at all to the return URL (an empty state= was causing apps to report “state arrived but cannot be validated”). A flow that starts from the store admin has no state — that is not a gap, it is documented behaviour.
?shop= does not exist here and cannot: the consent screen lives inside the store admin, and the store is resolved from the signed-in manager's session. So when you send the user off, you cannot know which store they are going to; you learn the store only from store_id / store_name / store_domain in the token response. The core gives you no other hint back, it only carries your state. A real incident: for a while the core did not echo state back — the install looked entirely successful (token issued, install active) but the app's panel session never opened, and no screen said why.When the store owner clicks Approve, the browser goes to the registered return address with code as a query parameter. If they click Cancel (and you started the flow, i.e. state is present), it returns to the same address with error=access_denied — that is the RFC 6749 §4.1.2.1 value. The server builds the return URL; the front end never assembles it itself. If redirect_uri is not registered, the deny endpoint returns 400 and no redirect happens at all (fail-closed — no open-redirect hole is left).
# APPROVAL — the store owner clicked Approve GET https://api.acme.com/oauth/callback?code=<64-hex>&state=<state> # DENIAL — the store owner clicked Cancel (you only get a return if you started the flow) GET https://api.acme.com/oauth/callback?error=access_denied&state=<state>
code is derived from 32 random bytes: 64 lowercase hex characters. Only its SHA-256 digest is stored in the database. It lives 5 minutes and is single-use — the second exchange in a race fails. Install errors on the consent screen (no plan selected, a required permission not approved, the plugin not supported in this country, the app suspended…) do not reach you: no code is generated, so there is no return either, and the store owner sees the error on their own screen.
Body fields are camelCase. If you write client_secret the field does not bind and you get 400. clientId/clientSecret go in the body — HTTP Basic is not supported, this endpoint never reads the Authorization header. There is no grant_type field (it is ignored if sent), and redirect_uri is not sent during the exchange either. This endpoint is exempt from tenant resolution: the store code is resolved from store_id, so you need neither the store domain nor a special header — you call the API base directly. The rate limit here is 30 requests/minute per IP (separate from and tighter than the general 600/min); on overflow you get 429 + Retry-After.
curl -X POST https://api.milofly.com/api/apps/oauth/token \
-H "Content-Type: application/json" \
-d '{"clientId":"cli_9f2c1a...","clientSecret":"cs_4b7e88...","code":"a3f1..."}'A successful response carries 10 fields (keys are snake_case). Region-scoped and store-wide installs are populated differently:
// REGION-SCOPED install (install_scope='region')
{
"success": true,
"access_token": "mfapp_7_5_9c1f...",
"token_type": "Bearer",
"scopes": ["orders.read", "links.write"],
"app_key": "app.acme.kargo",
"store_id": 7,
"region_id": 5,
"install_scope": "region",
"regions": null,
"store_name": "Woida",
"store_domain": "woida.milofly.com"
}
// STORE-WIDE install (install_scope='store') — only the fields that differ
{ ...,
"region_id": null,
"install_scope": "store",
"regions": [ { "id": 5, "code": "DE", "currency": "EUR", "domain": "woida.de" },
{ "id": 1, "code": "TR", "currency": "TRY", "domain": "woida.com.tr" } ]
}store_id / region_id / store_name / store_domain are given at install time because the token is opaque: there is nowhere else to learn which store the install belongs to, and this information is not deferred to the first webhook. regions is populated only for store-wide installs; for region-scoped installs it is null.
401 { "success": false, "message": "Invalid client credentials or code." } is identical in all of these seven cases and cannot be told apart from the outside: (1) clientId unknown, (2) clientSecret wrong, (3) code invalid / never existed, (4) code expired (5 minutes), (5) code already used, or the second exchange in a race, (6) code belongs to another app, (7) tenant could not be resolved, or billing provisioning was refused (e.g. no paid plan selected). This opacity is deliberate — it avoids leaking codes and secrets. Practical triage: if you get 401, check the code's 5-minute lifetime and single-use rule first. Missing-field errors are separate: 400 "clientId, clientSecret, code are required."The authority on scope is the developer, not the store: it is install_scope (region | store) in the app registration. The store admin cannot request “all regions” and thereby obtain an authority you never declared; an unrecognised value fails the install with 400. Tokens come in one of three shapes:
mfapp_{storeId}_{regionId}_{64hex} → region-scoped: locked to one region, X-Region-Id is not read
mfapp_{storeId}_all_{64hex} → store-wide: the region is pinned per request
mfapp_{storeId}_{64hex} → legacy 2-part shape: resolves to the default (home) region
# random part: 32 bytes → 64 lowercase hex charactersIn a region-scoped token the region is embedded in the token: X-Region-Id is not read on this path — an app cannot slip into an arbitrary region. In a store-wide token the region is chosen per request (X-Region-Id: <id> header or ?regionId=<id>); most endpoints require the region pin and return 400 "A region must be specified…" without it. On every request the token shape is compared with the registered install_scope; a mismatch yields 401 "The app token's scope is inconsistent."
With install_scope=region (the default), the install region is the region the store manager has currently selected — it is not taken from the request and cannot be chosen. To install into another region, the store owner switches region in the admin and repeats the install. The result: a separate install, separate authorization code, separate token and separate webhook subscription per region. A three-region store + a region-scoped app = 3 installs, 3 tokens; keep them as separate install records on your side. Removing one region does not break the other's token. With install_scope=store there is a single install, a single token, all regions — and uninstalling removes it store-wide. The same (app, store) pair cannot hold both scopes at once: an install in the opposite scope is rejected with 400 "This app is installed per region on this store. To install it store-wide, remove the existing installs first."
200 and the token is issued, but until payment completes the plugin is not activated, webhook subscriptions stay P (pending) — events are not delivered — and every REST call returns 402 "The app subscription is not active (payment pending)." The cause is not a bug, it is payment.200, but in these three cases the webhook subscription is never written (only a server-log error is emitted): no signing secret configured (unsigned delivery is blocked) · the topic is unknown · the permission the topic requires was not granted. This is the most common cause of “it installed but no events arrive”.The region pin is required on most endpoints, yet no other endpoint hands you a region id — GET /api/apps/v1/context resolves that chicken-and-egg problem: it requires no scope and no region pin (deliberately, it is the discovery endpoint). regions[] in the response is always populated — a single element for a region-scoped install, all of the store's active regions for a store-wide install — and the values you may put in X-Region-Id are exactly the ids in that array. pinnedRegionId is null when there is no pin. For all fields, see API reference → Discovery.
curl https://api.milofly.com/api/apps/v1/context \
-H "Authorization: Bearer mfapp_7_5_9c1f..."
→ 200
{ "success": true, "appKey": "app.acme.kargo",
"store": { "id": 7, "code": "woida" },
"installScope": "region", "pinnedRegionId": 5,
"regions": [ { "id": 5, "code": "DE", "countryCode": "DE", "currencyCode": "EUR",
"languageCode": "de", "isDefault": true } ],
"scopes": ["orders.read", "links.write"] }The returned access_token (prefixed mfapp_) is opaque and scoped to a store + region; it appears in this response only (the database keeps just a SHA-256 digest, which cannot be reversed — if you lose it, the only route is a reinstall). The token never expires (offline-token model) and there is no refresh: no refresh_token field, no refresh grant, no renewal call. Security comes from revocation, not from lifetime: old tokens are revoked when the app is uninstalled or reinstalled (the row is not deleted, the audit trail remains). Tokens with the older esapp_ prefix are accepted for reads only and are no longer issued. A successful exchange automatically creates the webhook subscription and the store installation record (APP_INSTALLS).
Base: /api/apps/v1. Send the access token as a Bearer token on every request:
GET /api/apps/v1/orders/123 Authorization: Bearer mfapp_... → 200 (the order; customer PII is returned only with the customers.read scope)
Insufficient permission returns 403, an invalid/expired token returns 401. PII fields (customer/address) are present only if customers.read was granted.
The topics you subscribed to are POSTed to the app's webhook_url. Every request carries the signature and these headers:
POST <webhook_url> X-Milofly-Hmac-Sha256: <signature — HEX> X-Milofly-Topic: order.created X-Milofly-Store: <storeId — NUMERIC (NOT store_code)> X-Milofly-Timestamp: <unix seconds> <raw JSON body>
The signature is NOT over the body alone. This canonical string — topic \n storeId \n timestamp \n body (newline characters in between, no spaces) — is HMAC-SHA256'd with the webhook_signing_secret and sent as hex. Because the headers are part of the signature, tampering with them breaks it.
// Node.js — CORRECT verification (use the raw body; do NOT JSON.parse and re-serialize)
const crypto = require("crypto");
const canonical = topic + "\n" + storeId + "\n" + timestamp + "\n" + rawBody;
const expected = crypto.createHmac("sha256", SIGNING_SECRET)
.update(canonical, "utf8").digest("hex");
const ok = expected.length === header.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
// Time window: REJECT if |now - timestamp| > 5 min (replay protection).eventId from the body persistently and SKIP it if you already processed it. Otherwise the same event is processed twice — a commission/cost line gets written twice. The eventId lives inside the signed body (not in a header), so nobody in the middle can change it to produce duplicate records.dead_letter.The real events you can subscribe to and their payload schemas: Bus event catalogue.
Ask only for the scopes you need when creating an app. The list comes from a single source (the backend taxonomy AppScopes — 22 scopes); detailed states are in API reference → Scopes.
| Scope | Access |
|---|---|
| orders.read | Read orders (list + detail) |
| orders.write | Import orders (marketplace import) |
| products.read | Read products |
| products.write | Update products |
| customers.read | Read customer/address data (PII) — the strictest privacy barrier |
| webhooks.manageComing soon | Manage webhook subscriptions |
| links.read | Read referral/tracking codes (affiliate/influencer) |
| links.write | Create referral/tracking codes (affiliate/influencer) |
| orders.costs.write | Write cost/commission lines on an order (profit & loss) |
| tracking.read | Subscribe to central tracking events (via webhook) |
| tracking.settings.read | Read the store settings of your own tracking provider (measurement_id, api_secret) |
| tracking.tag.write | Write the browser tag template of your own tracking provider (injects a script into the storefront) |
| coupons.read | List the store's active coupons |
| coupons.write | Create coupons on behalf of the store (influencer discount) |
| attribution.manage | Read/write the multi-link revenue attribution model |
| inventory.write | Writes stock levels (add/subtract/set; single row or bulk). |
| fulfillment.write | Writes the FULFILMENT status of a live order (processing/shipped/delivered) and its tracking details. Transitions that move money — cancellation, refund, return — are NOT possible with this scope. |
| purchases.write | Writes its own supplier accounts and purchase receipts (its own records only). |
| invoices.read | Reads the invoice document: seller/buyer tax identifiers, official billing address, per-line tax breakdown and document classification. Only documents routed to this app's provider. |
| invoices.write | Writes back the invoice SUBMISSION RESULT: status, provider document id (ETTN/UUID), provider number, PDF address and error code. Invoice totals and line items CANNOT be changed with this scope. |
| listings.write | Writes the marketplace LISTING LEDGER: the product’s id on the marketplace (ASIN/listing id), whether it is live or rejected, and the rejection reason. Does NOT touch the product card, price or stock. |
| shipping.provider | Becomes a SHIPPING CARRIER: receives shipment-creation requests, reads the shipment (including recipient name/phone/address), writes back barcode/tracking number and label, feeds carrier tracking events and applies cancellations. The provider row is opened by the PLATFORM; an app cannot declare itself a carrier and only sees shipments routed to its own provider. |
The admin UI your plugin offers the store owner is your own multi-page app — your backend plus your frontend. Milofly embeds it into the store admin as an iframe, and this is live, not “coming soon” (/admin/plugins/manage/{app_key}). Identity is not carried over postMessage: the core hands you a short-lived token that the server signs with HMAC, in the query string (?embed_token=…). The reasoning is written out under its own heading below — a panel that takes identity from postMessage will leak another store's data.
The flow (measured): the admin reads admin_manifest_json — if renderMode is not `iframe` or appUrl is empty, no iframe is opened at all. Then the core asks its own endpoint for an embed token. If no token comes back, the iframe is not opened and the store owner is shown “Could not start the app session. The plugin may not be live; refresh the page or reinstall the plugin.” Not opening without a token is deliberate: if we opened it, you would either see a blank screen or — worse — you would try to take identity from postMessage. The base URL is appUrl + the slot path if the manifest has a fullpage slot, otherwise the appUrl root; the token is appended at the end:
https://panel.eklentin.com/?embed_token=<jeton> # if the base already carries a query string, the separator becomes & https://panel.eklentin.com/yonetim?tab=siparis&embed_token=<jeton>
The endpoint that issues the token (GET /api/admin/apps/{appKey}/embed-token) is not an endpoint you call — the core admin calls it, and you only verify the token you receive. The token lives 120 seconds. The core issues no token in these cases (the endpoint returns 404 and your panel never opens): the app is not registered in central · its status is X (suspended) · its status is D/P (draft/in review) and the store's code is not in your test_store_codes list · no signing secret is defined. An unsigned or forged token is never issued; if you are saying “my panel never opens”, that list is your complete diagnostic.
Shape: base64url(payloadJSON) + "." + hex(HMAC-SHA256). The payload is base64url (RFC 4648 §5 — +→-, /→_, trailing = padding stripped) and is not encrypted: it contains no secrets, and the signature is for integrity, not confidentiality. Field names are PascalCase:
<base64url(gövde)>.<hex(HMAC-SHA256)>
{
"V": 1, // protocol version — ALWAYS 1 today; REJECT anything else
"AppKey": "app.milofly-labs.tyresystem", // the app's registered key
"StoreId": 140, // store id — this is the filter itself
"RegionId": 5, // region; may be NULL for a store-wide install
"StoreCode": "woida", // store code (diagnostics/display); may be null
"Iat": 1754500000, // issued at, Unix seconds (UTC)
"Exp": 1754500120, // expiry = Iat + 120
"Scope": "region" // "region" or "store" — use this to decide whether to show a region picker
}AppKey, StoreId). In a validator expecting camelCase, every unmatched field fell back to its default, AppKey became an empty string, and the panel said “this session belongs to another app” — the right sentence, the wrong culprit; diagnosis took hours. The fix is not “convert to PascalCase”, it is case-insensitive parsing (PropertyNameCaseInsensitive = true in .NET, the equivalent elsewhere). An external plugin should not have to know the core's serialisation settings; since the signature is verified over the raw payload, parsing leniency does not weaken security — you only reach that line after the signature holds. For the same reason, be tolerant of unknown fields: the Scope field was added later, and adding fields does not break the signature.The signature is computed over the base64url text of the payload itself (not the decoded JSON), with a domain separation string prefixed to it. The secret used is the app's webhook signing secret: a second secret was not introduced because it would have meant a second distribution and rotation channel. The risk of confusion is closed by the prefix — the webhook canonical string is topic\nstore\nts\nbody and can never begin with milofly-embed-v1, so one protocol's signature can never be valid in the other.
imza = hex_kucuk( HMAC_SHA256( sir, "milofly-embed-v1" + "\n" + govde ) )
# \n is a REAL line break (LF), not a two-character literal. Hex is lowercase, 64 characters.
# GOLDEN VALUE — verify your signer in one line (pinned in a core test):
sir = "test-secret"
govde = "eyJhIjoxfQ" // base64url({"a":1})
imza = "68134ee22dcc749c0c94214c7e35a0f78079ad5313b9535845a6c36f192c94df"esoltec-embed-v1 to milofly-embed-v1; one endpoint's constant was not updated and every panel silently failed signature verification — the incident presented itself as “the panel won't open”. That is why you should put the golden value above into your own test suite.Signature first, parsing second: parsing unverified data is needless attack surface. The steps: (1) reject an empty/whitespace token (embed_token_missing). (2) If no secret is configured, reject (embed_secret_missing) — “I could not verify, so I will accept” is the most dangerous mistake there is. (3) Split on the dot; reject if there is no dot, or it is at the start or the end (embed_token_malformed). (4) Compute the expected signature. (5) Compare in constant time (crypto.timingSafeEqual / CryptographicOperations.FixedTimeEquals); lowercase the incoming signature. Ordinary string equality returns at the first differing character, and that measurable timing difference lets an attacker guess the signature byte by byte. (6) If it does not match, reject (embed_token_bad_signature) and leak no partial result — return empty. (7) Base64url-decode the payload, parse the JSON case-insensitively, ignore unknown fields. (8) Reject if V != 1 — so that if the meaning changes later, old code does not misread a new token. (9) Reject if StoreId <= 0. (10) Reject if AppKey does not match your registered key (embed_token_other_app); if the secret is ever shared by accident, this check is the second gate and it costs one comparison. (11) Reject if Exp is in the past (embed_token_expired) or Iat is too far in the future (embed_token_not_yet_valid). (12) Once all that passes, StoreId/RegionId are now a trusted filter.
// Node.js — ORDER MATTERS: signature FIRST, parsing SECOND
const [govde, imza] = token.split(".");
if (!govde || !imza) return red("embed_token_malformed");
const beklenen = crypto.createHmac("sha256", EMBED_SECRET)
.update("milofly-embed-v1\n" + govde, "utf8").digest("hex");
const a = Buffer.from(beklenen), b = Buffer.from(imza.toLowerCase());
if (a.length !== b.length || !crypto.timingSafeEqual(a, b))
return red("embed_token_bad_signature"); // do NOT leak a partial result
const c = JSON.parse(Buffer.from(govde, "base64url").toString("utf8"));
const now = Math.floor(Date.now() / 1000);
if (c.V !== 1) return red("embed_token_malformed");
if (!(c.StoreId > 0)) return red("embed_token_malformed");
if (c.AppKey?.toLowerCase() !== APP_KEY.toLowerCase())
return red("embed_token_other_app");
if (now > c.Exp + 60) return red("embed_token_expired");
if (now + 60 < c.Iat) return red("embed_token_not_yet_valid");
// From here on, c.StoreId / c.RegionId are a TRUSTED filter — convert them to your own session right away.Allow no more than ±60 seconds of clock skew (the two plugins in production use that allowance). Do not ignore an invalid token: ignoring an invalid token is the doorway to accepting a forged one. Use the token once and convert it to your own short-lived session immediately — the token travels in the address bar and can leave traces in browser history and in the referrer. That is exactly why its lifetime is 120 seconds.
"tyresystem" while the Milofly registration was app.milofly-labs.tyresystem — the panel would not open and the error said “this session belongs to another app”. The key is not yours, it is the registry's data: whoever registers the app picks it, and app_key cannot be changed afterwards (it is an identity).The common rule: read it from the URL → strip it from the URL (`history.replaceState`) → convert it to your own session → never use the token again. Two patterns are live, and both are correct. (a) Cookie session: you post the token to your own endpoint, the server verifies it and sets your own signed session cookie. That cookie must be HttpOnly, Secure, `SameSite=None`, Path=/: the panel runs as an iframe on a different origin, which is a third-party context, and the browser silently drops a SameSite=Lax/Strict cookie — the user ends up in an endless “no session” loop; and None requires Secure (HTTPS). Sign the cookie value, but it does not have to be encrypted: it contains no secrets, what you need is tamper resistance. (b) Custom header: you keep the token in memory and attach it to every subsequent XHR as X-Milofly-Embed-Token; your server looks at the header first and then at the ?embed_token= query (the first page request cannot carry a header, XHRs do not carry the query), and the header must be in your CORS Allow-Headers list.
sessionStorage/localStorage. A stale stored token breaks the panel in exactly the case where it should work without one: when the user navigates directly to the panel URL, the app insists on the old token it still holds and falls into a “session could not be verified” loop.No token and a token that is present but invalid are different states and deserve different screens; treating an invalid token as “no token” opens the door to forged tokens. Measured states: if the panel URL was opened directly (there is no parent frame), show a full welcome page — the user came here on purpose, show them the way, do not leave a dead end. Inside an iframe with no token, a short notice is enough (“This panel must be opened from the store admin”); do not put a marketing pitch or a step list there — you would be telling the store owner how to enter their own admin. On a signature/lifetime error: “Session could not be verified. Please reopen it from the admin.”; on a network or 5xx error, a message plus Retry. Keep the error codes machine-readable: embed_token_missing · embed_secret_missing · embed_token_malformed · embed_token_bad_signature · embed_token_expired · embed_token_not_yet_valid · embed_token_other_app · embed_store_not_installed.
(StoreId, RegionId) on your side; if there is none, return a distinct state and say what to do (“Installation is incomplete — remove and reinstall the plugin” plus an install link in a new tab; the install/consent flow is not suited to being opened inside the frame). Do not attach the token of some other install you happen to hold: you would be handing this store's panel another store's identity. Do not ignore the region either — the token carries the region signed, and that region enters the session; ignore it and a region B manager sees region A's data.You do not need an extra request to Milofly to resolve the store/region from a signed token: the information is already inside the token and it is signed. Asking /context would also mean re-asking, over an unverified path, something you already verified — and it adds a round trip to every screen load.
🔴 Do not take identity from postMessage. The core's reasoning, verbatim: even with an origin check, the direction is wrong — a malicious page can put your panel in its own iframe and send a milofly:init message with any storeId it likes; if you trust that, you display another store's data. Identity must travel with the server's signature, not with a message from the browser. The origin check on the admin side protects the admin, not you: you have no cryptographic basis on which to prove the attacker's page is not Milofly. A signature, on the other hand, is something nobody without the secret can produce — the core's test for this is literally named FORGED_token_is_rejected_the_reason_this_test_exists.
The bridge does exist, but only for layout and preferences: milofly:ready (app → admin: I am loaded, waiting for init) · milofly:init (admin → app: pluginKey, locale, storeContext; once only) · milofly:resize (app → admin: height). The rule: locale is a preference, not an identity — if it is wrong, the worst case is a UI in the wrong language. The storeId inside storeContext is not used for authorization. If you never send milofly:ready, the admin sends init once anyway after 1200 ms and removes the loading overlay (so you cannot get stuck on an infinite “loading”).
// the postMessage bridge — for layout/preferences ONLY; NOT for identity
app → parent : { type: "milofly:ready" }
parent → app : { type: "milofly:init", pluginKey, locale, storeContext }
app → parent : { type: "milofly:resize", height }fullpage slot receives a signed token. The URLs of order-detail / customer-detail / product-detail widgets get no token; context arrives only via the postMessage storeContext (surface, entityType, entityId, storeId, regionId, currencyCode, uiLocale, contentLocale). Therefore the `storeId` a detail-slot widget receives cannot be used for authorization. The right way: the widget also fetches its data from your own server, using that store's own OAuth token; it takes entityId merely as a hint for “which record should I show”, and verifies on your side that the record really belongs to that install. (Note: in the fullpage slot, storeContext arrives as an empty object — the identity is already in the token.)The admin learns what to mount where from admin_manifest_json: renderMode (the only valid value for an external plugin is `iframe`), appUrl, installScope and slots[]. The valid slot types are: fullpage · settings · order-detail · customer-detail · product-detail · storefront-block; an unknown type is silently dropped. slot.path is relative to the app root; an absolute path or one starting with // is rejected (origin-escape protection), and if the resolved origin differs from appUrl it is rejected as well. Without appUrl no iframe is opened at all — the page says “No admin UI found for this plugin”. installScope is mirrored into the token's Scope field: that is how the embedded panel knows whether to show its own region picker.
{
"renderMode": "iframe",
"appUrl": "https://panel.eklentin.com",
"installScope": "region",
"slots": [ { "type": "fullpage", "path": "/yonetim", "label": "Kargo Takip" } ]
}appUrl must be HTTPS (the only exception is http://localhost | 127.0.0.1 | [::1], for development only); it cannot be same-origin with the admin (same-origin would bypass the sandbox); data: / blob: / javascript: and opaque origins are rejected. The sandbox is allow-scripts allow-forms allow-same-origin — popups and downloads are not granted to third parties. The clipboard is delegated for writing only (clipboard-write; without the delegation, navigator.clipboard.writeText() would be silently refused cross-origin), there is no read access. The referrer policy is strict-origin. In full-page mode the container has a fixed height and milofly:resize is ignored (otherwise you would get a double scrollbar) — inner scrolling is your responsibility; in detail-slot mode auto-height is on (starting at 320px, clamped to [120, 1600]) and changes under 24px are ignored to prevent oscillation loops.There are two measured routes. (a) Point the admin's iframe at your local address — in the store admin's .env.local, NEXT_PUBLIC_PLUGIN_APP_URL_OVERRIDES={"app.acme.kargo":"http://localhost:3010"}. If the variable is undefined, the catalogue's production URL is used; behaviour is identical in a production build, there is no silent default, and malformed JSON is not swallowed — it logs an error to the console. Changing app_url in the catalogue is not a solution: that row also redirects live stores' panels. (b) Mint a token by hand — read the secret from an environment variable, never embed it in code; it is a good habit to compare your generator's domain-separation string against the verifier's source (if they diverge, the token you mint would fail the signature anyway). On both routes, a valid token alone is not enough: the store/region you put in must belong to a real install in the database, otherwise you land in the “not installed” state. To try a draft/in-review app in the real admin, the store code must be in your test_store_codes list — otherwise no token is issued at all.
# into the store admin's .env.local file (points at your local app without breaking production)
NEXT_PUBLIC_PLUGIN_APP_URL_OVERRIDES={"app.acme.kargo":"http://localhost:3010"}webhook_signing_secret, 64 lowercase hex characters, no prefix). The honest answer: no endpoint that rotates this secret could be measured — rotate-secret only regenerates client_secret. So today you cannot roll it yourself; keep it in a secret manager.On the app detail page, fill in the store listing (icon, screenshots, category, description), then Publish from the Publishing tab. The tab shows the required and recommended items and what the publish will do. A public app's first publish goes to platform review (In review); once Milofly approves it (Published), store owners can discover and install it in the plugin store. You push listing changes by publishing again; if a published app's admin URL or extension slots change, it goes back to review.
Webhook/OAuth app (this document): runs on your own backend and integrates over REST + webhooks. Function (coming soon): for deterministic checkout-time calculations (commission, shipping cost) you upload your code as WASM and Milofly runs it in its own sandbox in under 100 ms — no I/O, hard timeout. Synchronous checkout maths belongs to this model.