Milofly is region-centric. Your plugin can be global (every region) or limited to specific countries (scoped — e.g. TR only); you choose this when creating the app. A store installs your plugin into its regions in the supported countries.
🔒 The token's shape tells you its scope. mfapp_{store}_{region}_… is region-scoped (locked to one region) · mfapp_{store}_all_… is store-wide (the region is chosen per request) · the legacy two-part shape resolves to the store's default (home) region. The token is still opaque — do not parse it yourself; GET /api/apps/v1/context tells you the scope.
🚫 With a region-scoped token, X-Region-Id and ?regionId are not read (silently ignored) — you cannot change the context. ⚠️ This does NOT apply to a store-wide token: there the region is pinned by the request and on most endpoints it is required.
📌 Pinning a region with a store-wide token: the X-Region-Id: <id> header or ?regionId=<id>. If you send both and the values differ: 400 "X-Region-Id conflicts with ?regionId; send only one."; an invalid format or another store's region gives 401. Without a pin, region-required endpoints return 400 "A region must be specified…"; on region-optional endpoints, a call with no pin means all regions. The id values you may pin are the regions[] array in the GET /api/apps/v1/context response.
🌍 Multi-region: if a store installs your app into several regions, you get a separate token per region. Data from different regions cannot be combined in one request.
💱 Currency: each region has a single currency; process amounts in the currency they arrive in and NEVER SUM ACROSS currencies.
📨 Webhooks: every envelope carries storeId + regionId; you only receive events from the region you are installed in.
When a store owner installs the plugin, an authorization code is generated (when they click **Approve** on the consent screen — not on the “Install” click). Your server exchanges that code for an access token; the code is **64 hex characters**, lives **5 minutes** and is **single-use**. The token is opaque (prefixed `mfapp_`), bound to a store + region + scopes and NEVER EXPIRES (offline token): security comes from REVOCATION, not from lifetime — old tokens are revoked when the app is uninstalled or reinstalled; there is NO refresh token. Tokens with the older `esapp_` prefix are accepted for reads and are no longer issued — never parse the prefix yourself, the token is opaque. Rate limit: 600 requests/min per token (429 above that). ⚠️ The token exchange endpoint is SEPARATE and tighter: **30 requests/min per IP**. For the whole install flow (authorization URL, `state`, the denial path, the error table), see [Docs → Installation](/docs#install).
/api/apps/oauth/tokenGet an access token
Exchanging an authorization code for an access token. Your identity is verified with client_id + client_secret.
clientIdrequired | string | The public identity of your app (cli_…). |
clientSecretrequired | string | Your app secret (cs_…). Keep it server-side only. |
coderequired | string | The single-use authorization code generated at install time. |
# Gövde CAMELCASE'tir. "client_secret" yazarsan alan bağlanmaz → 400.
# HTTP Basic DESTEKLENMEZ (Authorization başlığı bu uçta hiç okunmaz).
# grant_type YOKTUR (gönderilse yok sayılır) · redirect_uri takasta GÖNDERİLMEZ.
# Bu uç tenant çözümlemesinden muaftır: mağaza alan adına gerek yok.
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…"
}'// BÖLGE-KAPSAMLI kurulum — 10 alan döner (hepsi snake_case):
{
"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"
}
// MAĞAZA-GENELİ kurulum — yalnız farklı alanlar:
{ …, "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" } ] }
// NEDEN 10 ALAN: token OPAKTIR. Kurulumun hangi mağaza/bölge olduğunu BAŞKA hiçbir yerden
// öğrenemezsin; store_id/region_id/store_name/store_domain kurulum ANINDA verilir ki kurulum
// satırını mağazaya bağlayabilesin — ilk webhook'a bırakılmaz. "regions" YALNIZ mağaza-geneli
// kurulumda doludur, bölge-kapsamlıda null'dır.
//
// 🔴 TEK 401, YEDİ SEBEP — { "success": false, "message": "Geçersiz client kimliği veya kod." }
// (1) clientId tanınmıyor · (2) clientSecret yanlış · (3) kod geçersiz ·
// (4) kodun SÜRESİ DOLMUŞ (5 dk) · (5) kod ZATEN KULLANILMIŞ · (6) kod BAŞKA app'e ait ·
// (7) tenant çözülemedi / faturalama provizyonu reddedildi.
// Bilinçli gizleme (kod/secret sızdırmamak için). 401 alırsan ÖNCE kodun 5 dakikalık ömrünü
// ve tek kullanımlığını kontrol et. Eksik alan AYRI: 400 "clientId, clientSecret, code zorunlu."
//
// 429: bu uçta hız sınırı IP başına 30/dk'dır (genel 600/dk DEĞİL), Retry-After başlığıyla.Store orders. Customer name/email is returned only if the customers.read scope was granted as well (privacy barrier).
/api/apps/v1/orders orders.readList orders
The orders of the region, paginated. Use totalCount for the total.
page | int | Page (default 1). |
pageSize | int | Page size (1–100, default 20). |
status | string | Filter by status (e.g. paid, shipped). |
curl https://api.milofly.com/api/apps/v1/orders?page=1&pageSize=20 \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"items": [
{
"id": 1042,
"orderNumber": "SO-TR-2026-00001042",
"status": "paid",
"grandTotal": 349.90,
"currency": "TRY",
"itemCount": 2,
"orderSource": "web",
"customer": { "name": "Ayşe Yılmaz", "email": "ayse@example.com" }
},
{
"id": 1187,
"orderNumber": "SO-DE-2026-00001187",
"status": "paid",
"grandTotal": 129.00,
"currency": "EUR",
"itemCount": 1,
"orderSource": "web",
"customer": { "name": "Lukas Weber", "email": "lukas@example.de" }
}
],
"totalCount": 128,
"page": 1,
"pageSize": 20
}/api/apps/v1/orders/{id} orders.readOrder detail
The line items and amounts of a single order. Customer/billing address only with customers.read.
curl https://api.milofly.com/api/apps/v1/orders/1042 \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"id": 1042,
"orderNumber": "SO-TR-2026-00001042",
"status": "paid",
"subtotal": 299.90,
"taxTotal": 50.00,
"grandTotal": 349.90,
"currency": "TRY",
"createdAt": "2026-06-26T09:14:00Z",
"customer": { "name": "Ayşe Yılmaz", "email": "ayse@example.com" },
"billingAddress": { "…": "customers.read yoksa null" },
"shippingAddress": { "…": "customers.read yoksa null" },
"items": [
{ "productId": 55, "variantId": 981,
"productName": "Kablosuz Kulaklık", "sku": "WH-100",
"quantity": 1, "unitPrice": 249.90, "totalPrice": 249.90 }
]
}/api/apps/v1/orders orders.writeImport an order (marketplace import)
Writes a past, already-paid order as a SNAPSHOT (price/currency/shipping/status come FROM OUTSIDE; there is NO recalculation). Which REGION it is written to comes FROM THE TOKEN (no region in the body); if currency is empty, the region's currency is used. Idempotent: sending the same (app, externalOrderId) a second time returns the existing order instead of creating a new record.
externalOrderIdrequired | string | The order id in the source system — the idempotency key. |
provider | string | Source label (trendyol…) → order_source = marketplace_<provider>. |
items[].productIdrequired | long | Milofly product id (products.read), > 0. |
items[].quantityrequired | int | ≥ 1. |
items[].variantId | long | Variant (optional). |
items[].unitPrice | decimal | The REAL unit price in the source (snapshot). If empty, the Milofly regional list price is used. |
currency | string | ISO (TRY…). If empty, the region's currency; if it differs, it is ACCEPTED and logged. |
externalStatus | string | shipped/delivered/processing → Milofly status. The order is ALWAYS payment_status=paid. |
trackingNumber | string | Tracking number (snapshot). |
cargoProvider | string | Carrier → shipping_method (not re-resolved). |
shippingCost | decimal | The real shipping cost (in the region's currency) — the server does NOT override it. |
shippingAddress | object | The delivery address as raw JSON (the schema follows the region's ADDRESS_TEMPLATES). |
deductStock | bool | true → deduct from Milofly stock (even if it oversells; it is not rejected). false (default) → no stock movement (inventory is synced outside). |
curl -X POST https://api.milofly.com/api/apps/v1/orders \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" \
-d '{
"externalOrderId": "TY-112-7788990",
"provider": "trendyol",
"items": [ { "productId": 55, "quantity": 1, "unitPrice": 80.00 } ],
"currency": "TRY",
"externalStatus": "shipped",
"trackingNumber": "1234567890",
"cargoProvider": "Yurtiçi Kargo",
"customerName": "Ayşe Yılmaz",
"shippingAddress": { "fullName": "Ayşe Yılmaz", "city": "İstanbul", "country": "TR" }
}'{ "success": true, "orderId": 1043, "idempotent": false }
// Aynı externalOrderId tekrar gönderilirse (webhook retry):
{ "success": true, "orderId": 1043, "idempotent": true } // yeni sipariş YOK/api/apps/v1/orders/{id}/costs orders.costs.writeRead order costs
The cost lines **you wrote** on this order. Lines entered by the store by hand, or by another app, are not visible. Even though this is a read endpoint, the scope is `orders.costs.write` — there is no separate read permission. ⚠️ The response is a **bare array**, not an envelope (`{items:[…]}`).
curl https://api.milofly.com/api/apps/v1/orders/1187/costs \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
// ⚠️ ZARFSIZ DİZİ — bu grubun diğer uçlarından farklı ({ items: […] } DEĞİL).
[
{ "costType": "influencer_commission",
"description": "AYSE20 · %15",
"amount": 52.49,
"currency": "EUR",
"externalRef": "AYSE20-1187",
"createdAt": "2026-07-29T21:04:00Z" }
]
// 404 "Sipariş bulunamadı."
//
// BÖLGE İZOLASYONU BURADA TOKEN'IN KAPSAMIYLA YAPILIR, istekle pinlenen bölgeyle DEĞİL:
// · bölge-kapsamlı token → başka bölgenin siparişi 404 (okuma/yazma/silme, üçünde de)
// · mağaza-geneli token → pin göndermesen bile mağazadaki HER siparişin masrafına erişirsin.
// Bu bilinçlidir: masraf VAR OLAN bir siparişe iliştirilir, bölge uydurulmaz — siparişin
// KENDİ bölgesi ve KENDİ para birimi otoritedir.
// · siparişin bölgesi NULL ise (eski sipariş) bölge-kapsamlı token için de engellenmez./api/apps/v1/orders/{id}/costs orders.costs.writeWrite an order cost (upsert)
The key is (order, app, `externalRef`). On a webhook redelivery the same commission is not written twice, it is **updated** — `updated:true` tells you an existing line was updated. Why it is persisted: commission used to be produced as an on-the-fly virtual line, so when the plugin was removed the profit/loss of **past** orders changed.
externalRefrequired | string | **Required — the idempotency key.** Send the same value on every request that represents the same cost. |
costTyperequired | string | **Required**, at most 30 characters (e.g. `influencer_commission`; use `cod_fee` for the carrier's cut from cash-on-delivery collections). |
amountrequired | decimal | **Required**, `≥ 0`. 🔴 **Negative amounts are forbidden** — a negative cost is a disguised revenue entry. To reverse one, **delete** the line or write `0`. |
description | string | The description the store owner will see on the profit/loss screen. |
currency | string | Left empty, the **order's own** currency is used. If sent, it **must match** the order's, otherwise `400`. The reason: adding different currencies into one number (100 EUR + 1000 TRY = “1100”) makes the profit/loss summary meaningless. |
curl -X PUT https://api.milofly.com/api/apps/v1/orders/1187/costs \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" \
-d '{
"externalRef": "AYSE20-1187",
"costType": "influencer_commission",
"description": "AYSE20 · %15",
"amount": 52.49
}'{ "success": true, "updated": false }
// updated:true → mevcut satır GÜNCELLENDİ, yeni satır AÇILMADI.
// Anahtar (sipariş, uygulama, externalRef) olduğu için webhook retry'ında komisyon çift yazılmaz.
//
// 400 "amount negatif olamaz (geri alım için satırı silin)."
// 400 "Para birimi (TRY) siparişin para biriminden (EUR) farklı olamaz."
// 400 "externalRef zorunlu (idempotency anahtarı)." · "costType zorunlu." · "costType en fazla 30 karakter."
// 404 "Sipariş bulunamadı."/api/apps/v1/orders/{id}/costs/{externalRef} orders.costs.writeDelete an order cost
Deletes a single line by `externalRef`. It is **idempotent**: if the line does not exist you still get `200` with `deleted:0`. If the order is not reachable, `404 "Order not found."`
curl -X DELETE https://api.milofly.com/api/apps/v1/orders/1187/costs/AYSE20-1187 \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{ "success": true, "deleted": 1 }
// İdempotent: satır yoksa { "success": true, "deleted": 0 } ile yine 200.The store product catalogue (per region and language). List and detail return the same shape.
/api/apps/v1/products products.readList products
A paginated product list with search/status/category filters.
page | int | Page (default 1). |
pageSize | int | Page size (1–100, default 20). |
search | string | Search by name/SKU. |
status | string | Product status (e.g. active). |
categoryId | long | Filter by category. |
curl "https://api.milofly.com/api/apps/v1/products?search=kulaklik&pageSize=20" \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"items": [
{
"id": 55,
"name": "Kablosuz Kulaklık",
"sku": "WH-100",
"status": "active",
"productType": "simple",
"price": 249.90,
"currency": "TRY",
"stockQuantity": 84,
"categoryId": 12,
"imageUrl": "https://cdn.milofly.com/p/wh-100.jpg",
"createdAt": "2026-05-01T00:00:00Z"
}
],
"totalCount": 312,
"page": 1,
"pageSize": 20
}/api/apps/v1/products/{id} products.readProduct detail
A single product — the same contract as the list (AppProductDto).
curl https://api.milofly.com/api/apps/v1/products/55 \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"id": 55,
"name": "Kablosuz Kulaklık",
"sku": "WH-100",
"status": "active",
"productType": "simple",
"price": 249.90,
"currency": "TRY",
"stockQuantity": 84,
"categoryId": 12,
"imageUrl": "https://cdn.milofly.com/p/wh-100.jpg",
"createdAt": "2026-05-01T00:00:00Z"
}/api/apps/v1/products/reconciliation products.readDiscover products for reconciliation
Paginated identity fields plus an ownership class. Another app's key/reference is never exposed. No title-based identity inference is performed.
curl "https://api.milofly.com/api/apps/v1/products/reconciliation?page=1&pageSize=100" \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5"
{
"totalCount": 3500, "page": 1, "pageSize": 100,
"items": [{ "id": 55, "status": "A", "name": "Example tyre",
"category": { "id": 12, "name": "Tyres" }, "sku": "SKU-55",
"gtin": "4012345678901", "mpn": "MPN-55", "ownership": "unmanaged" }]
}/api/apps/v1/products/reconciliation/{id}/adopt products.writeAdopt an existing product
Claims an unmanaged product using unique exact GTIN/SKU+MPN or user-approved, digested structural evidence. AI cannot write directly. Other-app ownership and reference conflicts fail loudly; an identical replay is idempotent.
curl -X POST https://api.milofly.com/api/apps/v1/products/reconciliation/55/adopt \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5" -H "Content-Type: application/json" \
-d '{"externalRef":"supplier-55","proof":{"type":"exact_gtin","gtin":"4012345678901"}}'{ "success": true, "productId": 55, "externalRef": "supplier-55", "adopted": true, "idempotent": false }/api/apps/v1/products/reconciliation/{id}/refresh-context products.readRead product refresh evidence
Returns current fields, field locks and the short-lived evidence digest that explicit mutation requests must carry unchanged.
curl "https://api.milofly.com/api/apps/v1/products/reconciliation/55/refresh-context?externalRef=supplier-55" \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5"
{
"success": true, "productId": 55, "productType": "simple",
"current": { "sku": "supplier-55", "gtin": "4012345678901" },
"fieldLocks": [], "evidenceDigest": "<64-hex>"
}/api/apps/v1/products/reconciliation/{id}/sku/regenerate products.writeRegenerate the central product SKU
Atomically applies the store's core SKU formula to an app-owned simple product with fresh evidence and explicit user confirmation. No target SKU is accepted.
curl -X POST https://api.milofly.com/api/apps/v1/products/reconciliation/55/sku/regenerate \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5" -H "Content-Type: application/json" \
-d '{"externalRef":"supplier-55","userConfirmed":true,"evidenceDigest":"<64-hex-from-refresh-context>","idempotencyKey":"refresh-884-sku"}'{
"success": true, "productId": 55, "externalRef": "supplier-55",
"previousSku": "supplier-55", "sku": "STORE-14055", "changed": true,
"fieldLockTransferred": true, "auditId": 901, "idempotent": false
}/api/apps/v1/products products.writeCreate / update product
**Idempotent** via `externalRef`: calling it twice with the same reference does not create a second product, it updates the existing one. The `created` field in the response tells you which happened.
X-Region-Idrequired | header | **REGION PIN REQUIRED.** Price is interpreted in the currency of the region the installation belongs to. The currency is not sent in the body — the region decides it. If no region is pinned the request returns **400**. (`?regionId` is also accepted.) |
externalRefrequired | string | Your own stable identifier — the idempotency key. Always send the same value for the same product. |
sku | string | The store stock keeping unit — optional. When omitted, Milofly applies the store's dynamic SKU formula; it is not the `externalRef`. |
titlerequired | string | Product title — **required**. |
price | decimal | Sale price. The currency comes from the store's region. |
stockQuantity | decimal | Initial stock. To change it later use the inventory endpoints (`inventory.write`). |
status | string | `A` (published) · `P` (draft). |
categoryId | long | Store category id. |
description | string | Product description. |
gtin | string | Barcode (EAN/UPC). It is the matching key in marketplace and price-comparison feeds. |
mpn | string | Manufacturer part number. |
weightGram | decimal | Weight in grams. Shipping cost calculation reads this. |
lengthCm | decimal | Dimensions: length / width / height (the store's unit, today `cm`). ALL THREE are required — if any is missing no volume is computed and the product never enters the packing engine's volume check; the box is chosen by weight alone. No dimension is ever invented. On PATCH, omitting them keeps the existing dimensions. |
widthCm | decimal | Dimensions: length / width / height (the store's unit, today `cm`). ALL THREE are required — if any is missing no volume is computed and the product never enters the packing engine's volume check; the box is chosen by weight alone. No dimension is ever invented. On PATCH, omitting them keeps the existing dimensions. |
heightCm | decimal | Dimensions: length / width / height (the store's unit, today `cm`). ALL THREE are required — if any is missing no volume is computed and the product never enters the packing engine's volume check; the box is chosen by weight alone. No dimension is ever invented. On PATCH, omitting them keeps the existing dimensions. |
harmonizedCode | string | HS / customs tariff code. |
countryOfOrigin | string | Country of origin (ISO-2, e.g. `DE`). |
variants[] | array | Variant list for a variant product. Each variant carries `combination` (axis → value), `sku`, `price`, `stock` and so on. **`productType` and `variantAxes` are DERIVED from this** — do not send them separately. The same `combination` **cannot be sent twice** (the combination is the variant's identity). If the category has required variant axes, those axes **must** appear in `combination`. ⚠️ **Not accepted on update** (PATCH → 400, and an existing `externalRef` is rejected on the bulk path too): structural fields are preserved from the existing row, so the variants you sent would be written **nowhere** — the classic "I sent it but it was not applied" trap. |
# Almanya bölgesi (id 5): para birimi EUR, vergi MwSt %19 — İKİSİ DE GÖVDEDE YAZILMAZ.
curl -X POST https://api.milofly.com/api/apps/v1/products \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" \
-H "X-Region-Id: 5" \
-d '{
"externalRef": "tyre-1234567",
"title": "Continental EcoContact 6 205/55 R16 91V",
"price": 89.90,
"stockQuantity": 12,
"gtin": "4019238045673",
"countryOfOrigin": "DE",
"harmonizedCode": "4011100000"
}'
# Türkiye bölgesi (id 1): AYNI gövde, tek fark bölge başlığı → fiyat TRY yorumlanır.
curl -X POST https://api.milofly.com/api/apps/v1/products \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" \
-H "X-Region-Id: 1" \
-d '{"externalRef":"kulaklik-wh100","sku":"WH-100","title":"Kablosuz Kulaklık","price":249.90}'{
"success": true,
"id": 942,
"created": true
}/api/apps/v1/products/{id} products.writeUpdate product (PATCH)
ONLY products the app itself created. You cannot touch the store's own products or another app's products — those return `404` (not a permission error: their existence is not even disclosed).
# NOT: bu uç PATCH ile çağrılır
curl -X PATCH https://api.milofly.com/api/apps/v1/products/942 \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" \
-d '{"price":139.00}'{
"success": true,
"id": 942
}/api/apps/v1/products/delta products.readChanges (delta)
Products changed since your last sync. Use this instead of pulling the whole catalogue every round. Either `updatedAfter` **or** `cursor` is required — without both you get **400**.
updatedAfter | ISO-8601 | ISO-8601 UTC. Use it **only on the first call**; afterwards use `cursor`. |
cursor | string | The `nextCursor` from the previous response. It is **composite** (`updatedAt|id`) — never build it yourself, take it from the response. |
pageSize | int | 500 at most. |
# İlk çağrı: zaman damgasıyla. Sonrakiler: HER ZAMAN nextCursor ile. curl "https://api.milofly.com/api/apps/v1/products/delta?updatedAfter=2026-07-29T00:00:00Z&pageSize=100" \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx" curl "https://api.milofly.com/api/apps/v1/products/delta?cursor=<önceki yanıtın nextCursor'ı>" \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"items": [
{ "id": 942, "status": "A", "updatedAt": "2026-07-29T18:20:00Z", "product": { "...": "..." } },
{ "id": 517, "status": "D", "updatedAt": "2026-07-29T18:41:00Z", "product": null }
],
"nextCursor": "2026-07-29T18:41:00Z|517",
"hasMore": true
}
// ⚠️ İMLEÇ BİLEŞİKTİR (updatedAt|id) — saf zaman damgası DEĞİL. Sebebi: aynı milisaniyede
// güncellenmiş iki ürün sayfa sınırına denk gelirse saf zaman imleci İKİNCİSİNİ ATLAR ve
// hiçbir yerde hata vermez. Sessiz eksik senkronun en sinsi kaynağı budur.
//
// ⚠️ nextCursor'ı YANITTAN al, KENDİ SAATİNİ kullanma: sunucu saatiyle aranızdaki en küçük
// kayma, o aralıkta değişen kayıtları sessizce atlatır.
//
// ⚠️ hasMore=true ise AYNI TURDA devam et — bir sonraki tetiği bekleme.
//
// SİLME: status="D" + product=null. Bozuk kayıt DEĞİL, "kataloğundan düşür" sinyalidir.
// Silme kayıtları bölgeye göre SÜZÜLMEZ (silinen ürünün bölge bağı da silinir; süzülseydi
// silme sinyali hiç görünmezdi). Sonucu: hiç görmediğin bir ürünün silme kaydını görebilirsin —
// onu düşürmeye çalışman zararsız bir no-op'tur.
//
// Bozuk imleç 400 döner, sessizce başa SARMAZ (sarsaydı tüm katalog yeniden gönderilirdi)./api/apps/v1/prices products.writeWrite price (single row)
Updates the regional price of one product or variant without rewriting the whole product. Scope **`products.write`**, no new permission: price can already be written through the product write body; defining a separate permission would **bind the same capability to two gates** and let an app that skips one do the same job through the other. Do not loop this endpoint for many rows — `POST /prices/bulk` writes 500 rows in a single round.
# NOT: tek kayıt PATCH ile çağrılır.
curl -X PATCH https://api.milofly.com/api/apps/v1/prices \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" -H "X-Region-Id: 5" \
-d '{"productId":8842,"variantId":null,"price":89.90}'
# Toplu (en fazla 500):
curl -X POST https://api.milofly.com/api/apps/v1/prices/bulk \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" -H "X-Region-Id: 5" \
-d '{"items":[{"productId":8842,"price":89.90},{"productId":8843,"price":109.00}]}'{ "success": true }
// PARA BİRİMİ GÖVDEDE YOK — bölge belirler (X-Region-Id zorunlu).
//
// ⚠️ 0 SATIR GÜNCELLEMEK "YAZDIM" DEĞİLDİR: ürün o bölgeye eklenmemişse 404 döner.
// Bunu bilmeyen çağıran "PATCH 200 aldım" sanıp fiyatın yazılmadığını fark etmez —
// ya da tersine, 404'ü "ürün yok" sanıp yeniden oluşturmaya kalkar.
//
// Yalnız SENİN ürünlerin: mağazanın kendi ürününe ya da başka bir uygulamanın ürününe
// fiyat yazılamaz./api/apps/v1/products/bulk products.writeBulk product write
Many products in one request. Essential for catalogue imports: writing 10,000 products one by one takes ~17 minutes at the 600 requests/minute limit and breaks halfway with a `429`. Each row reports its own result — one failure does not stop the rest.
curl -X POST https://api.milofly.com/api/apps/v1/products/bulk \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" \
-H "X-Region-Id: 5" \
-d '{"items":[
{"externalRef":"tyre-205-55-16","title":"Winterreifen 205/55 R16","price":129.00},
{"externalRef":"tyre-225-45-17","sku":"SR-225-45-17","title":"Sommerreifen 225/45 R17","price":149.00}
]}'{
"success": true,
"total": 2,
"failed": 0,
"results": [
{ "externalRef": "tyre-205-55-16", "id": 942, "success": true },
{ "externalRef": "tyre-225-45-17", "id": 943, "success": true }
]
}/api/apps/v1/media products.writeUpload an image (into the store's storage)
🔴 **Why this exists:** `images[].url` in the product write body is a **URL**, and the core does not download the image. If you host the image on your own domain, every product image turns into a `404` the day the store owner removes your plugin. The store's images belong in the store's storage. Files are always written under `apps/{appKey}` — **the folder does not come from the client**, you cannot write anywhere else in the store's media library.
filerequired | multipart | **Required** — the `multipart/form-data` field name is `file`. At most **8 MB**. Allowed types: `image/jpeg` · `image/png` · `image/webp` · `image/gif` · `image/svg+xml` · `image/avif`. HTML/executables are **rejected** (HTML served from the store's domain = stored XSS). |
fileHash | query | SHA-256 of the content, as a **query** parameter. 🔴 It is vital: **you** compute the hash, the server does not re-hash the content. Without it, every call writes a separate file and storage bloats for nothing. |
# multipart/form-data — dosya alanının adı "file". Hash QUERY'dedir. curl -X POST "https://api.milofly.com/api/apps/v1/media?fileHash=9f2c…" \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5" \ -F "file=@kart.png;type=image/png"
{ "success": true,
"url": "https://cdn.milofly.com/apps/app.acme.kargo/kart-9f2c.png",
"uploaded": true, "deduped": false }
// Aynı hash daha önce yüklenmişse: { …, "uploaded": false, "deduped": true }
//
// 🔴 fileHash HAYATİ: hash'i EKLENTİ hesaplar (içeriğin SHA-256'sı) — sunucu içeriği yeniden
// hash'lemez. Vermezsen HER çağrı ayrı dosya yazar ve mağazanın deposu boşuna şişer.
//
// 🔒 KLASÖR İSTEMCİDEN GELMEZ: dosya daima apps/{appKey} altına yazılır.
//
// 400 "Dosya 8 MB sınırını aşıyor (… KB)."
// 400 "'text/html' kabul edilmiyor. İzinli tipler: …" ← depolanmış XSS kapısı
// Bilinen davranış: medya kütüphanesi kaydı başarısız olsa bile YÜKLEME GEÇERLİDİR (dosya
// depoda, adres elde) — yalnız o dosya için dedup bir daha çalışmaz; sunucu WARNING loglar./api/apps/v1/media/generate products.writeGenerate an image with AI
Generates the image, writes it into **the store's** storage and returns its URL. Model/provider choice is **the platform's** decision; you do not know which model was used, and you should not. `503` is **not a transient failure** — it is a platform setting (no provider or no API key); in that case fall back to your own placeholder image. `502` means the generation itself failed.
promptrequired | string | **Required**, at most 2000 characters. |
fileHash | string | A stable content key. 🔴 It is checked **BEFORE** generation: if the same key was generated before, the AI is never called. Generation costs money and time — a call without a hash regenerates every single time. |
curl -X POST https://api.milofly.com/api/apps/v1/media/generate \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" -H "X-Region-Id: 5" \
-d '{"prompt":"Studio product card: Continental winter tyre, EU label A/B/72dB",
"fileHash":"9f2c…"}'{ "success": true,
"url": "https://cdn.milofly.com/apps/app.acme.kargo/ai-9f2c.png",
"generated": true, "deduped": false }
// Dedup isabetinde: { …, "generated": false, "deduped": true }
//
// 🔴 fileHash ÜRETİMDEN ÖNCE bakılır: aynı anahtar daha önce üretildiyse AI'ya HİÇ GİDİLMEZ.
// Üretim para ve zamandır; hash'siz çağrı her seferinde yeniden üretir.
//
// 503 "AI görsel üretimi yapılandırılmamış (sağlayıcı ya da API anahtarı yok). Bu geçici bir
// arıza DEĞİL — platform ayarı. Kendi yedek görselinizi kullanın."
// 502 "Görsel üretilemedi: <sebep>"**Read this first.** Product and inventory endpoints require region, warehouse and category IDs; this section tells you where those IDs come from. If there is a rule (e.g. "region pin is mandatory"), there must also be a way to satisfy it — these endpoints exist for exactly that.
/api/apps/v1/contextInstallation context
The token's own world: which store, which regions, which scopes. **No scope required, no region pin required** — making the discovery endpoint require permissions would just move the chicken-and-egg one level up. Take the value for `X-Region-Id` from here.
# Scope İSTEMEZ, bölge pini İSTEMEZ — ilk çağrın bu olmalı. curl https://api.milofly.com/api/apps/v1/context -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"appKey": "app.acme.kargo",
"store": { "id": 106, "code": "ornekmagaza" },
"installScope": "region",
"pinnedRegionId": null,
"regions": [
{ "id": 5, "code": "de", "countryCode": "DE", "currencyCode": "EUR",
"languageCode": "de", "isDefault": true }
],
"scopes": ["products.write", "inventory.write", "fulfillment.write"]
}
// regions[] HER ZAMAN doludur: bölge-kapsamlı kurulumda tek eleman, mağaza-geneli kurulumda
// mağazanın tüm aktif bölgeleri. X-Region-Id'ye yazacağın değer BURADAN gelir./api/apps/v1/warehouses products.readWarehouses
The store's warehouses and which one is **assigned to you**. When writing stock use only the one with `isMine=true`. Do NOT hard-code the warehouse id in your configuration: a warehouse is an **installation fact** (the store owner assigns it in the panel), not your operational decision — the moment the two diverge you write to the wrong warehouse.
curl https://api.milofly.com/api/apps/v1/warehouses -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"items": [
{ "id": 12, "name": "Ana Depo", "code": "main", "countryCode": "DE",
"regionId": 5, "isDefault": true, "isVirtual": false, "isMine": false },
{ "id": 31, "name": "TyreSystem", "code": "tyre", "countryCode": "DE",
"regionId": 5, "isDefault": false, "isVirtual": true, "isMine": true }
],
"myWarehouseCount": 1,
"hint": null
}
// isMine = bu depo SENİN kurulumuna atanmış (sanal depo, işleteni sensin)
// isVirtual = bir uygulama işletiyor — HANGİSİ olduğu söylenmez (başka eklentinin anahtarını
// bilmene gerek yok)
// myWarehouseCount = 0 ise "hint" dolar ve ne yapman gerektiğini söyler./api/apps/v1/categories products.readCategories
The `categoryId` values you can use when writing products. A category is **not required**; if left empty the product is created without one and the store owner assigns it from the panel.
curl https://api.milofly.com/api/apps/v1/categories -H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5"
{ "items": [ { "id": 12, "name": "Reifen", "parentId": null } ] }/api/apps/v1/categories products.writeCreate a category
Creates a category in the region language. It is idempotent for the same name under the same parent and never overwrites the store owner's existing record.
# PARENT_ID bu kurulumun GET /categories yanıtından seçilir; uygulamaya sabit yazılmaz.
PARENT_ID="<store-specific-category-id>"
curl -X POST https://api.milofly.com/api/apps/v1/categories \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5" -H "Content-Type: application/json" \
-d "{ "name": "Motorradreifen", "parentId": ${PARENT_ID}, "sortOrder": 20 }"{ "success": true, "id": 37, "created": true, "slug": "motorradreifen", "status": "A" }
// Aynı üst + aynı ad tekrar gönderildiğinde yeni satır açılmaz: created=false.
// Dönen id yalnız bu mağaza/kurulum için saklanır; başka tenant'ta tekrar keşfedilir.
// sortOrder zorunludur. parentId gönderilecekse sıfırdan büyük ve bu bölgede mevcut olmalıdır./api/apps/v1/categories/{id}/parent products.writeMove a category
Moves a category discovered from the live tree under another parent, with explicit user confirmation and current-location evidence. The returned audit record includes the evidence digest; names, products and attributes are untouched.
# Kimliklerin üçü de AYNI X-Region-Id ile GET /categories yanıtından keşfedilir.
SOURCE_ID="<move-this-category-id>"
TARGET_PARENT_ID="<new-parent-id>"
curl -X PUT https://api.milofly.com/api/apps/v1/categories/${SOURCE_ID}/parent \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5" -H "Content-Type: application/json" \
-d "{
"target": { "id": ${TARGET_PARENT_ID} },
"expectedCurrent": { "root": true },
"userConfirmed": true,
"evidence": "Store owner approved preview: <current path> -> <target path>"
}"{
"success": true,
"categoryId": 901,
"previousParentId": null,
"targetParentId": 900,
"moved": true,
"idempotent": false,
"audit": {
"id": 81,
"evidenceDigest": "<sha256>",
"sourceApp": "app.example.catalog",
"regionId": 5,
"confirmedAt": "2026-08-09T00:00:00Z"
}
}
// Kök hedefi için target: { "root": true }.
// Önizlemede mevcut üst kökse expectedCurrent: { "root": true }; değilse keşfedilen parent id.
// Önizlemeden sonra konum değişirse 409 category_parent_changed; yeniden keşfetmeden tekrarlama.
// Kendi altına taşıma ve aynı adlı kardeş çakışması 409; hiçbir durumda sessiz rename/reparent yok./api/apps/v1/categories/{id}/content products.readRead category content
Returns category rich content, SEO title and SEO description for the selected region language together with the current content digest. The id must come from category discovery for the same install.
CATEGORY_ID="<GET /categories yanıtından seçilen id>"
curl https://api.milofly.com/api/apps/v1/categories/${CATEGORY_ID}/content \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5"{
"categoryId": 37,
"regionId": 5,
"languageCode": "de",
"descriptionHtml": "<section><h2>Motorradreifen</h2><p>...</p></section>",
"seoTitle": "Motorradreifen online kaufen",
"seoDescription": "Motorradreifen nach Größe, Saison sowie Last- und Geschwindigkeitsindex auswählen.",
"contentDigest": "<sha256>"
}/api/apps/v1/categories/{id}/content products.writeWrite category content and SEO
Writes previewed category content with explicit approval, the fresh content digest and audit evidence. Concurrent merchant edits are protected with 409; the category tree and products are untouched.
CATEGORY_ID="<GET /categories yanıtından seçilen id>"
curl -X PUT https://api.milofly.com/api/apps/v1/categories/${CATEGORY_ID}/content \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5" -H "Content-Type: application/json" \
-d '{
"descriptionHtml": "<section><h2>Motorradreifen</h2><p>...</p></section>",
"seoTitle": "Motorradreifen online kaufen",
"seoDescription": "Motorradreifen nach Größe, Saison sowie Last- und Geschwindigkeitsindex auswählen.",
"expectedCurrentDigest": "<GET yanıtındaki contentDigest>",
"userConfirmed": true,
"evidence": "Store owner approved category content preview"
}'{
"success": true,
"categoryId": 37,
"contentDigest": "<new-sha256>",
"changed": true,
"audit": { "id": 82, "evidenceDigest": "<sha256>" }
}
// İçerik önizlemeden sonra değişirse 409 category_content_changed.
// Bu uç kategori adı, üstü, şablonu, özellik bağı veya ürünleri değiştirmez./api/apps/v1/categories/{id}/attributes products.readCategory attributes
The attributes a category expects and which of them are **variant axes**. If a required attribute is marked `isVariantAxis`, you cannot write a simple product into that category — it must be written with variants.
CATEGORY_ID="<GET /categories yanıtından seçilen id>"
curl https://api.milofly.com/api/apps/v1/categories/${CATEGORY_ID}/attributes \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5"{
"categoryId": 12,
"requiredVariantAxes": ["size"],
"items": [
{ "key": "size", "label": "Größe", "attrType": "variant",
"isVariantAxis": true, "isRequired": true, "isMulti": false,
"sortOrder": 10, "allowedValues": null }
]
}
// isVariantAxis=true olan zorunlu bir öznitelik varsa, o kategoriye BASİT ürün yazamazsın:
// varyantlı yazman gerekir (ürün yazma gövdesinde variants[])./api/apps/v1/attributes products.readAttribute dictionary
Lists the store's canonical attribute keys and category bindings. Labels come only from the selected region language; another language is never used as a fallback.
curl "https://api.milofly.com/api/apps/v1/attributes?search=mark" \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5"
{
"totalCount": 1,
"items": [
{ "id": 7, "key": "marke", "label": "Marke", "attrType": "attribute",
"labels": { "de": "Marke", "tr": "Marka", "en": "Brand" },
"isVariantAxis": false, "valueCount": 29, "categoryIds": [12, 18],
"isRequired": null, "isMulti": null, "sortOrder": null }
],
"hint": null
}
// label yalnız seçilen bölgenin dilinden gelir. O dilde çeviri yoksa null'dır;
// başka bir dildeki etiket sessizce kullanılmaz./api/apps/v1/attributes/{key}/values products.readAttribute values
Returns a key's canonical values with pagination. Write the returned `valueKey`, not the visible `label`, into the product payload.
curl "https://api.milofly.com/api/apps/v1/attributes/marke/values?limit=200&offset=0&includeUsage=true" \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5"
{
"attrKey": "marke", "totalCount": 29, "offset": 0, "usageCountsIncluded": true,
"items": [
{ "id": 81, "valueKey": "bridgestone", "label": "Bridgestone",
"labels": { "de": "Bridgestone", "tr": "Bridgestone", "en": "Bridgestone" },
"displayConfig": null, "sortOrder": 10, "usageCount": 324 }
]
}
// usageCount yalnız tokenın mağaza+bölgesindeki aktif ürünleri sayar. Sözlükteki yalnız kalmış
// eski bir kopyayı yerleşik kanonik değerden ayırmak için eşleme kanıtıdır; anlamı tek başına belirlemez.
// Sadece seçim listesi gereken hızlı önizleme includeUsage=false gönderebilir; bu durumda
// usageCountsIncluded=false olur ve dönen 0 değerleri gerçek kullanım kanıtı sayılmaz.
// Ürünün attributes alanına label değil valueKey yazılır. Olmayan key boş liste değil 404 döner./api/apps/v1/attributes products.writeCreate attributes and values
Idempotently finds or creates attributes and their values. Optional `labels` can carry known languages without overwriting merchant translations. Missing fields or translations are never guessed, and callers must inspect every item result.
curl -X POST https://api.milofly.com/api/apps/v1/attributes \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5" -H "Content-Type: application/json" \
-d '{ "items": [
{ "key": "marke", "label": "Marke",
"labels": { "de": "Marke", "tr": "Marka", "en": "Brand" },
"attrType": "attribute", "sortOrder": 10,
"values": [
{ "key": "bridgestone", "label": "Bridgestone",
"labels": { "de": "Bridgestone", "tr": "Bridgestone", "en": "Bridgestone" },
"sortOrder": 10 }
] }
] }'{
"success": true, "total": 1, "failed": 0,
"results": [
{ "sent": "marke", "success": true, "id": 7, "key": "marke",
"attrType": "attribute", "created": false, "matchedBy": "Birebir",
"values": [ { "sent": "bridgestone", "id": 81,
"valueKey": "bridgestone", "created": false, "matchedBy": "Birebir" } ],
"warnings": [] }
]
}
// key, label, attrType ve sortOrder zorunludur; değerlerde de key, label ve sortOrder zorunludur.
// labels key ve her value üzerinde opsiyoneldir. Bilinmeyen dil atlanır; çeviri uydurulmaz.
// Bölge dilindeki labels değeri label ile aynı olmalıdır. Mevcut dolu mağaza çevirisi farklıysa
// ezilmez; ilgili sonuç code="translation_conflict" ile başarısız olur.
// labels yoksa eski sözleşme aynen çalışır. HTTP 200 tek başına yeterli değildir:
// kısmi başarı için failed ve her results[].success alanını okuyun./api/apps/v1/categories/{id}/attributes products.writeBind attributes to a category
Idempotently binds existing dictionary attributes to a category. It neither removes existing bindings nor assumes omitted decisions.
CATEGORY_ID="<GET /categories yanıtından seçilen id>"
curl -X POST https://api.milofly.com/api/apps/v1/categories/${CATEGORY_ID}/attributes \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5" -H "Content-Type: application/json" \
-d '{ "items": [
{ "key": "marke", "attrType": "attribute", "isRequired": true,
"isMulti": false, "sortOrder": 10 }
] }'{
"success": true, "categoryId": 12, "total": 1, "failed": 0,
"results": [ { "sent": "marke", "success": true,
"key": "marke", "attrType": "attribute" } ]
}
// Bu uç öznitelik AÇMAZ ve listede olmayan mevcut bağları SİLMEZ.
// key, attrType, isRequired, isMulti ve sortOrder zorunludur./api/apps/v1/store-info links.readStore identity information
The store's name, domain, **logo** and contact e-mail. Why it exists: name and domain already arrive in the token response, but the logo and e-mail could not be read from any endpoint — apps were forced to show “Store #7 + an initials badge” and to skip store notifications. The scope is **`links.read`**: a new `store.read` scope was deliberately not added, because the scope list freezes at install time and a new scope would drop existing tokens to `403`. The contract: **every unset field comes back `null`, nothing is invented**; empty/whitespace values are normalised to `null`. If `logoUrl` is stored relative, it is absolutised with the region's canonical domain — and if there is no domain either, no URL is invented, it returns `null` (an honest “no logo” instead of a broken `<img>`).
# Bölge pini ZORUNLU: ayarlar STORE_SETTINGS'te BÖLGESEL yaşar. curl https://api.milofly.com/api/apps/v1/store-info -H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5"
{
"success": true,
"storeName": "Woida",
"storeDomain": "woida.de",
"logoUrl": "https://woida.de/uploads/logo.png",
"contactEmail": "info@woida.de"
}
// Ayarlanmamış her alan null gelir — UYDURMA YOK. Boş/whitespace değerler null'a normalize
// edilir (app'e "" sızmaz). logoUrl mağazada GÖRELİ kaydedilmiş olabilir; bölgenin kanonik
// domaini biliniyorsa mutlaklaştırılır, domain de yoksa adres UYDURULMAZ → logoUrl: null
// (kırık <img> yerine dürüst "logo yok")./api/apps/v1/storefront-targets products.readStorefront targets (choosing a link target)
The storefront targets a tracking code can point to: product, category, blog content, static page. Why it exists: the product endpoints list only **products**; without this endpoint the only targets you could pick for an influencer code were products and the home page. `targetPath` **always starts with `/`** and goes straight into the `targetUrl` field of the `POST /links` body. A row whose `targetPath` cannot be produced is **not returned at all** (so attribution/redirect never breaks). `url` = `https://{region-domain}{targetPath}`; if the region domain is undefined it is `null` — never invented. **There is no paging**: `totalCount` is the number of returned items (not the total catalogue); narrowing is what `search` is for.
type | string | `product` · `category` · `content` · `page`. Left empty, all four. An unrecognised value returns `400` — it does not silently fall back to “all”. |
search | string | Title search. Titles resolve in the region's language and paths in the region's slug (which is why the region pin is required). |
curl "https://api.milofly.com/api/apps/v1/storefront-targets?type=product&search=reifen" \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5"
{
"items": [
{ "type": "product", "id": 942,
"title": "Winterreifen 205/55 R16",
"targetPath": "/winterreifen-205-55-r16",
"url": "https://woida.de/winterreifen-205-55-r16",
"imageUrl": "https://cdn.milofly.com/p/wr.jpg" }
],
"totalCount": 1
}
// SAYFALAMA YOK: totalCount DÖNEN öğe sayısıdır (toplam katalog değil); tip başına sınırlıdır,
// daraltmayı "search" yapar.
// targetPath DAİMA "/" ile başlar ve doğrudan POST /links'in targetUrl alanına yazılır.
// targetPath üretilemeyen satır HİÇ DÖNMEZ (atıf/yönlendirme kopmasın).
// 400 "Geçersiz type. İzinli: product | category | content | page." ← sessizce "hepsi"ne düşmez/api/apps/v1/shipping-profiles products.readShipping profiles
This is how you learn the value for the `shippingProfileId` field in the product write body — without this endpoint that field is a **trap**. Only active profiles are returned (default first, then by name). `profileType` is the **raw** value (volumetric/weight/flat, etc.) — it is not translated or interpreted. Profiles are store-level, not regional: no region pin is needed. ⚠️ If no profile is assigned to a product it falls back to the store's **default** profile — meaning a part that will ship with the supplier's own carrier gets the store's volumetric tariff; across a catalogue of thousands of items that is silently the wrong price. If `hint` **comes back populated, show it to the store owner** (it is populated when there is one profile or fewer).
# Profiller MAĞAZA düzeyindedir, bölgesel değil → bölge pini GEREKMEZ. curl https://api.milofly.com/api/apps/v1/shipping-profiles -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"totalCount": 2,
"items": [
{ "id": 1, "name": "Standart", "profileType": "desi", "isDefault": true },
{ "id": 4, "name": "Tedarikçi Kargosu", "profileType": "fixed", "isDefault": false }
],
"hint": null
}
// hint DOLU gelirse mağaza sahibine GÖSTERİLMELİDİR. Profil sayısı ≤1 iken şu metin döner:
// "Mağazada tedarikçi ürünleri için ayrı bir kargo profili yok. Ürünlerinizi varsayılan
// profile yazarsanız mağazanın KENDİ kargo tarifesiyle fiyatlanırlar. Mağaza sahibinden
// ayrı bir profil açmasını isteyin ve ürünleri o profile yazın."/api/apps/v1/tax-rules products.readTax rules
This is how you learn the value for the `taxRuleId` field in the product write body — without this endpoint that field is a **trap** (the sixth instance of the same gap: region · category · attributes · warehouse · shipping profile · tax rule). A tax rule is **regional** (`PRODUCT_REGIONS.tax_rule_id`), so the region pin is **required**: a German region's VAT rule is not valid in another region. Only the **active version** is returned — the core versions a rule whenever its rate changes (the old row is deactivated and stamped with `effective_until`), and writing an inactive id onto a product would mean the tax engine finds no rule at all, i.e. **the sale is calculated tax-free**. The field is **optional**: if you do not send it, the product falls back to the region's **default** rule, which in a single-rate store is already the right answer. ⚠️ If you do not send it on `PATCH`, the **existing value is preserved** — this protection was added on 2026-08-08; before that, every `PATCH` silently wiped the tax rule the store owner had chosen for the product. An invalid id, or one belonging to another region, returns `400`; it never silently falls back to the default.
# Vergi kuralı BÖLGESELDİR → bölge pini ZORUNLU. curl https://api.milofly.com/api/apps/v1/tax-rules -H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5"
{
"totalCount": 2,
"items": [
{ "id": 1, "name": "MwSt 19%", "rate": 19.00, "isDefault": true, "description": null },
{ "id": 3, "name": "MwSt 7%", "rate": 7.00, "isDefault": false, "description": null }
],
"hint": null
}
// Dönen id doğrudan ürün gövdesindeki "taxRuleId" alanına yazılır.
// Yalnız AKTİF sürüm döner: oran değiştiğinde çekirdek kuralı versiyonlar ve eski satır
// (status='inactive' / effective_until dolu) LİSTELENMEZ — ölü bir kimlik ürüne yazılsaydı
// vergi motoru onu "kural yok" sayar ve satış vergisiz hesaplanırdı.
//
// hint DOLU gelirse mağaza sahibine GÖSTERİLMELİDİR (bölgede hiç aktif kural yokken dolar)./api/apps/v1/shipping-packages products.readShipping packages (boxes)
This is how you learn the value for the `shippingPackageId` field in the product write body. It is **not the same thing as a shipping profile**: the profile is the price **tariff**, the package is the **box** the product goes into — the core packs the basket's items into boxes and derives the real volumetric weight, weight and box count from that. Packages are store-level, not regional: no region pin is needed. Dimensions come back **raw** (cm · grams · kg · volumetric); no unit conversion is applied, because a second conversion would mean the two sides seeing different numbers. ⚠️ If no package is assigned to a product, the core uses the `isDefault` box — usually the smallest one, which a large part does not fit into; the volumetric weight is then computed too low and shipping is undercharged. If `hint` **comes back populated, show it to the store owner** (it is populated when there are no packages, or only one).
# Paketler MAĞAZA düzeyindedir, bölgesel değil → bölge pini GEREKMEZ. curl https://api.milofly.com/api/apps/v1/shipping-packages -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"totalCount": 3,
"items": [
{ "id": 1, "name": "Küçük Kutu", "lengthCm": 25, "widthCm": 20, "heightCm": 15,
"emptyWeightG": null, "maxWeightKg": 5, "isDefault": true },
{ "id": 3, "name": "Büyük Kutu", "lengthCm": 60, "widthCm": 40, "heightCm": 40,
"emptyWeightG": null, "maxWeightKg": 30, "isDefault": false }
],
"hint": null
}
// Dönen id doğrudan ürün gövdesindeki "shippingPackageId" alanına yazılır.
// KARGO PROFİLİ İLE KARIŞTIRMAYIN: profil ücret TARİFESİ, paket ürünün girdiği KOLİDİR.
// Ölçüler HAM gelir (cm · g · kg · desi) — birim çevrimi yapılmaz./api/apps/v1/warehouses/{id}/claim inventory.writeClaim a warehouse
Binds one of the store's **empty** warehouses to this app. It is one of the two routes to take when `GET /warehouses` returns `myWarehouseCount: 0` (the other being your own virtual warehouse). It is idempotent: if it is already yours you get `200` with `alreadyMine: true`. Rejection reasons, all `409`: the warehouse is managed by **another** app (which one is **not disclosed**) · the warehouse holds the store's **own stock** (a non-empty warehouse cannot be claimed) · the warehouse is the store's **default**. Another region's warehouse gets a `404`.
curl -X POST https://api.milofly.com/api/apps/v1/warehouses/31/claim -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{ "success": true, "id": 31, "name": "Tedarikçi Deposu",
"message": "Depo bu uygulamaya bağlandı." }
// Zaten seninse: { "success": true, "id": 31, "alreadyMine": true, "message": "Bu depo zaten size bağlı." }
//
// 404 "Depo bulunamadı." ← başka bölgenin deposu da 404
// 409 "Bu depo BAŞKA bir uygulama tarafından yönetiliyor…" (HANGİSİ olduğu SÖYLENMEZ)
// 409 "Bu depoda mağazanın kendi stoğu var (N kalem). Dolu bir depo uygulamaya bağlanamaz…"
// 409 "Mağazanın varsayılan deposu bir uygulamaya bağlanamaz. Kendinize ayrı bir depo açın."/api/apps/v1/warehouses inventory.writeCreate my own virtual warehouse
The app creates its own virtual warehouse, and the warehouse is bound to this app **from the moment it is born** — a two-step flow would leave a window in which the store could put goods into it. `code` is **derived** from the app key and de-duplicated; you cannot supply a free-form code. `isDefault` is always `0`. The region pin is **required**.
namerequired | string | **Required** — the warehouse's display name, truncated to 100 characters. There is no address field: it is meaningless for a virtual warehouse. |
curl -X POST https://api.milofly.com/api/apps/v1/warehouses \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" -H "X-Region-Id: 5" \
-d '{"name":"Tedarikçi Sanal Depo"}'{ "success": true, "id": 31, "name": "Tedarikçi Sanal Depo",
"code": "APP-ACME-KARGO-5", "regionId": 5, "isMine": true,
"message": "Depo oluşturuldu ve bu uygulamaya bağlandı. Stoğunuzu buraya yazın." }
// code uygulamanın ANAHTARINDAN türetilir ve tekilleştirilir — serbest kod veremezsin.
// Depo DOĞDUĞU AN bu uygulamaya bağlıdır: iki adımlı akışta arada mağazanın mal girme
// penceresi kalırdı. is_default DAİMA 0./api/apps/v1/warehouses/{id}/claim inventory.writeRelease a warehouse
Removes the binding; stock management returns to the store. On a warehouse that is not bound to you: `404 "This warehouse is not bound to you."`
curl -X DELETE https://api.milofly.com/api/apps/v1/warehouses/31/claim -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{ "success": true, "id": 31,
"message": "Depo bağı kaldırıldı; stok yönetimi mağazaya döndü." }
// 404 "Bu depo size bağlı değil."Stock writing endpoints. The read side is the `stockQuantity` field on product endpoints; these endpoints **change** stock. Only the app's own products can be changed.
/api/apps/v1/inventory/adjust inventory.writeWrite stock (single row)
Changes the stock of the app's own product.
productIdrequired | long | Product id (must be the app's own product). |
moderequired | string | `add` · `subtract` · `set` (set to an absolute value). |
quantityrequired | int | Quantity. In `set` mode the new absolute stock, otherwise the delta. |
variantId | long | Variant id (which variant on a variant product). On a shared-stock product (`stockPool`), the quantity for a sales-quantity option is that option's package count; the item's shared stock changes by packages × content quantity. The response `quantity` and `stockQuantity` in `/products/identifiers` use the same package meaning. |
warehouseId | long | Warehouse id. Empty means the store's default warehouse. |
criticalLevel | int | Critical stock threshold — the store owner gets a low-stock notification below it. |
notes | string | Free-text note; shown on the stock movement record. |
# ⚠️ warehouseId olarak YALNIZ isMine=true olan depoyu kullanın (GET /warehouses).
# myWarehouseCount=0 ise henüz size depo atanmamıştır — stok YAZMAYIN, yoksa tedarikçi
# stoğu mağazanın kendi deposuna karışır ve ayrıştırmanın yolu kalmaz.
# Stok uçlarında bölge pini GEREKMEZ — stok depoya bağlıdır, bölgeye değil.
curl -X POST https://api.milofly.com/api/apps/v1/inventory/adjust \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" \
-d '{"productId":942,"mode":"set","quantity":40,"criticalLevel":5}'{
"success": true
}/api/apps/v1/inventory/bulk inventory.writeBulk stock write
Many rows in one request. This is the main path for supplier stock synchronisation; each row reports its own result.
curl -X POST https://api.milofly.com/api/apps/v1/inventory/bulk \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" \
-d '{"items":[
{"productId":942,"mode":"set","quantity":40},
{"productId":943,"mode":"subtract","quantity":4}
]}'{
"success": true,
"total": 2,
"failed": 0,
"results": [
{ "productId": 942, "success": true },
{ "productId": 943, "success": true }
]
}Orders whose items sit in **your** warehouse. Two paths are used together: the `fulfillment.requested` webhook **pushes** the work, these endpoints **pull** it. Pulling is not a fallback for pushing — it is part of the contract.
/api/apps/v1/fulfillments/pending orders.read + fulfillment.writeDiscover paid orders awaiting fulfilment
Returns the store's paid, fulfilment-pending orders with **all line items**. This endpoint does not route automatically and reading it assigns nothing. `assignmentState` is limited to `unassigned`, `assigned_to_this_app` or `assigned_elsewhere`; another app's identity is never exposed. Customer name/email additionally require `customers.read`, otherwise those fields are `null`. A claimable line carries a `claimEvidenceDigest` that is valid only for the current discovery snapshot.
page | int | Starts at 1; smaller values are raised to 1. |
pageSize | int | Paging is per order and is **clamped to 1–200.** |
curl "https://api.milofly.com/api/apps/v1/fulfillments/pending?page=1&pageSize=50" \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"totalCount": 1, "page": 1, "pageSize": 50,
"items": [
{ "orderId": 1187, "orderNumber": "SO-DE-2026-00001187",
"orderStatus": "processing", "paymentStatus": "paid", "currency": "EUR",
"regionId": 5, "grandTotal": 137.52,
"customer": { "name": null, "email": null },
"items": [
{ "orderItemId": 9001, "productId": 942, "variantId": null,
"productName": "Winterreifen 205/55 R16", "sku": "WR-205-55-16",
"quantity": 3, "shippedQuantity": 2, "remainingQuantity": 1,
"warehouseId": null, "assignmentState": "unassigned",
"claimable": true, "claimEvidenceDigest": "8C377E..." },
{ "orderItemId": 9002, "productId": 188, "variantId": null,
"productName": "Other app item", "sku": "OTHER-188",
"quantity": 1, "shippedQuantity": 0, "remainingQuantity": 1,
"warehouseId": 44, "assignmentState": "assigned_elsewhere",
"claimable": false, "claimEvidenceDigest": null }
] }
]
}
// This is discovery, not automatic routing: reading it assigns nothing.
// assignmentState deliberately masks the identity of another app.
// Customer fields require customers.read; without it name/email are null.
// Use only a current claimEvidenceDigest with POST /fulfillments/claim./api/apps/v1/fulfillments/claim orders.read + fulfillment.writeClaim selected fulfilment lines
Atomically and idempotently assigns up to 200 distinct, explicitly selected lines from one order to your app. Every line must carry its current `claimEvidenceDigest`. If the order, quantity, shipped quantity, warehouse or assignment changed, the API returns **HTTP 409** and claims nothing; refresh discovery first. A line assigned to another app cannot be claimed.
curl -X POST "https://api.milofly.com/api/apps/v1/fulfillments/claim" \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" \
-d '{"orderId":1187,"items":[{"orderItemId":9001,"evidenceDigest":"8C377E..."}]}'{ "success": true, "orderId": 1187, "claimed": 1 }
// At most 200 distinct items from one order. The operation is atomic and idempotent.
// If order, quantity, shipped amount, warehouse or assignment changed, the digest is stale:
// HTTP 409 is returned and NO item is claimed. Refresh GET /fulfillments/pending first./api/apps/v1/fulfillments/assigned orders.read + fulfillment.writeFulfilments assigned to me
Orders containing items from your warehouse. If a webhook was missed, the work shows up here — **this is not a fallback, it is part of the contract.** Requires two scopes: `orders.read` (to read the order) and `fulfillment.write` (to show you have business with it). 🔴 **The quantity to ship is `remainingQuantity`, not `quantity`**: the filter now works at line-item level and by quantity — a fully shipped item drops out of the queue, a partially shipped one stays with its remaining quantity. The statuses counted as “finished” are a **blocklist** (`shipped` · `delivered` · `cancelled` · `refunded` · `returned` · `partially_returned`); a new intermediate status that is not on that list counts as **pending** by default.
page | int | Starts at 1; smaller values are raised to 1. |
pageSize | int | Paging is per **order**, not per item. **Clamped to 1–200.** |
onlyPaid | bool | Defaults to `true` — paid orders only. You may turn it off, but the safe default is deliberate: shipping an unpaid order means giving the goods away. |
status | string | Narrows to a single order status. Left empty, all pending statuses. |
curl "https://api.milofly.com/api/apps/v1/fulfillments/assigned?page=1&pageSize=50" \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"totalCount": 3, "page": 1, "pageSize": 50,
"items": [
{ "orderId": 1187, "orderNumber": "SO-DE-2026-00001187",
"orderStatus": "processing", "paymentStatus": "paid",
"currency": "EUR", "regionId": 5, "createdAt": "2026-07-29T20:10:00Z",
"items": [
{ "orderItemId": 9001, "productId": 942, "variantId": null,
"productName": "Winterreifen 205/55 R16", "sku": "WR-205-55-16",
"quantity": 3, "warehouseId": 31,
"shippedQuantity": 2, "remainingQuantity": 1 }
] }
]
}
// 1) SAYFALAMA SİPARİŞ BAZINDA, kalem bazında DEĞİL — totalCount da sipariş sayısıdır.
// Kalem bazında olsaydı bir siparişin kalemleri iki sayfaya bölünür ve EKSİK BİR SİPARİŞİ
// sevk etmeye kalkardın. pageSize 1..200 arasına CLAMP'lenir, page en az 1'e çekilir.
//
// 2) VARSAYILAN YALNIZ ÖDENMİŞ siparişler (onlyPaid=true). Kapatılabilir; ama varsayılanın
// güvenli tarafta olması bilinçli: ödenmemiş siparişi sevk etmek malı bedava göndermektir.
//
// 3) 🔴 GÖNDERECEĞİN ADET "quantity" DEĞİL "remainingQuantity"DİR.
// Süzgeç KALEM DÜZEYİNE ve ADET BAZINA indi: tam sevk edilen kalem kuyruktan DÜŞER, kısmen
// sevk edilen KALAN adediyle kalır. shippedQuantity iptal/başarısız gönderileri saymaz.
// (Bu dokümanda bir dönem "kalem düzeyinde sevkiyat izi HENÜZ YOK, bilinen eksiklik" yazıyordu
// — DÜZELTİLDİ; o cümle geliştiriciyi gereksiz mükerrer-koruma yazmaya itiyordu.)
//
// 4) DÖNEN KALEMLER YALNIZ SANA ATANMIŞ olanlardır — siparişin toplam kalem sayısı daha fazla
// olabilir.
//
// 5) "Bitmiş" sayılan durumlar bir KARA LİSTEDİR: shipped · delivered · cancelled · refunded ·
// returned · partially_returned. Bu listede olmayan yeni bir ara durum (ör. "preparing")
// kendiliğinden BEKLİYOR sayılır.
//
// İTME/ÇEKME: bu uç, fulfillment.requested webhook'unun YEDEĞİ DEĞİL — sözleşmenin parçasıdır.
// Webhook kaçarsa iş burada görünür; yalnız webhook'a güvenen entegrasyon ilk kesintide
// sessizce sipariş kaçırır./api/apps/v1/orders/{id}/fulfillment fulfillment.writeWrite fulfilment status
Write the status back when you are done. **Fulfilment statuses only**: `processing` · `shipped` · `delivered`. Cancellation, refund and return are **not possible** with this scope — they move money and are the store's decision.
# NOT: bu uç PATCH ile çağrılır.
curl -X PATCH https://api.milofly.com/api/apps/v1/orders/1187/fulfillment \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" \
-d '{"status":"shipped","trackingNumber":"DHL-123456789","carrier":"DHL"}'{ "success": true }
// YALNIZ SEVKİYAT DURUMLARI: processing · shipped · delivered.
// cancelled / refunded / returned bu izinle YAPILAMAZ — onlar PARA hareketi doğurur, iade ve
// ödeme defterlerine dokunur ve mağazanın kendi kararıdır. Sevkiyat yazabilen bir eklentinin
// parayı da geri verebilmesi için hiçbir gerekçe yok.Customer data contains PII — ALL of these endpoints are gated by the customers.read scope (the strictest privacy barrier).
/api/apps/v1/customers/{id} customers.readCustomer detail
A customer of the region. Address/order history is out of scope for now (it will expand).
curl https://api.milofly.com/api/apps/v1/customers/88 \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"id": 88,
"email": "ayse@example.com",
"name": "Ayşe Yılmaz",
"status": "active",
"emailVerified": true,
"createdAt": "2026-03-12T00:00:00Z"
}The topics you subscribed to are POSTed to your webhook_url, signed with HMAC-SHA256. Verify the signature with your webhook_signing_secret. A failed delivery is retried with exponential backoff (5 attempts in total: 1 first + 4 retries → dead letter). See the Bus event catalogue for the topic catalogue.
<webhook_url>Incoming webhook (us → you)
When an event happens, Milofly POSTs to your URL. Verify the signature, dedupe by eventId and return 2xx (otherwise it is retried — 5 attempts in total).
POST <webhook_url>
X-Milofly-Hmac-Sha256: <imza — HEX>
X-Milofly-Topic: order.paid
X-Milofly-Store: <storeId — NUMERİK, store_code DEĞİL (imzaya girer)>
X-Milofly-Timestamp: <unix saniye>
{ "version": "2026-06", "topic": "order.paid", "storeId": 7,
"eventId": "order.paid:1042", // ← DEDUPE ANAHTARI (imzalı gövdede)
"occurredAt": "2026-06-26T09:14:00Z", "data": { "orderId": 1042 } }// Doğrulama (Node.js) — imza YALNIZ gövde DEĞİL, KANONİK dizgedir:
// topic + "\n" + storeId + "\n" + timestamp + "\n" + rawBody → HMAC-SHA256 → HEX
const canonical = topic + "\n" + storeId + "\n" + timestamp + "\n" + rawBody;
const expected = crypto.createHmac("sha256", SIGNING_SECRET)
.update(canonical, "utf8").digest("hex");
if (expected.length !== header.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header)))
return res.status(401).end();
// Replay: |now - timestamp| > 5dk ise REDDET.
// DEDUPE ZORUNLU: eventId'yi kalıcı sakla; gördüysen İŞLEME (yoksa komisyon çift yazılır).
if (await alreadyProcessed(body.eventId)) return res.sendStatus(200);
res.sendStatus(200); // 2xx dönmezsen üstel backoff ile yeniden denenir (toplam 5 deneme), sonra dead_letter<webhook_url> — topic: fulfillment.requested fulfillment.writefulfillment.requested — fulfilment assigned to you
Arrives when items from your warehouse are sold in an order. **The payload carries NO PII**; if you need customer details, fetch them separately with `customers.read`. **One event per app** (3 items = 1 webhook) and it goes **only to you** — you never see another supplier's items. **Push and pull are used together:** if a webhook is missed the work is discovered via `GET /fulfillments/assigned`; that is not a fallback, it is part of the contract.
X-Milofly-Topic: fulfillment.requested
{ "version": "2026-06", "topic": "fulfillment.requested", "storeId": 7,
"eventId": "fulfillment.requested:1042",
"occurredAt": "2026-07-29T21:00:00Z",
"data": {
"orderId": 1042,
"orderNumber": "SO-DE-2026-00001187",
"paymentStatus": "pending",
"paymentMethod": "cod_cash",
"warehouseId": 31,
"items": [
{ "orderItemId": 9001, "productId": 942, "variantId": null, "quantity": 2 },
{ "orderItemId": 9002, "productId": 943, "variantId": null, "quantity": 1 }
]
} }
// ⚠️ PII YOK: ad, adres, telefon, e-posta bu yükte GEÇMEZ. Gerekiyorsa customers.read ile
// ayrıca çekilir — sevkiyat yapan her uygulamanın müşteri verisine ihtiyacı yoktur ve
// olmayan ihtiyaç için veri göndermek, sızıntı yüzeyini bedavaya büyütmektir.// 1) UYGULAMA BAŞINA TEK OLAY — 3 kalem düştüyse 1 webhook gelir, 3 değil.
// Kalem başına olay gönderilseydi aynı siparişi 3 kez işler, 3 kez kargo çağırırdın.
//
// 2) FAN-OUT YOK — olay YALNIZ kalemleri kendisine atanmış uygulamaya gider.
// Başka bir tedarikçinin kalemleri senin yüküne girmez; onun siparişini görmezsin.
//
// 3) İTME ve ÇEKME BİRLİKTE KULLANILIR — bu bir yedek değil, SÖZLEŞMENİN PARÇASI:
// webhook kaçarsa (ağ, senin tarafın kapalı, dead_letter) iş kaybolmaz,
// GET /api/apps/v1/fulfillments/assigned ile öğrenilir. Yalnız webhook'a güvenen
// entegrasyon, ilk kesintide sessizce sipariş kaçırır.
//
// 4) İşi bitirince durumu geri yaz: PATCH /api/apps/v1/orders/{id}/fulfillment
// (fulfillment.write). Yalnız SEVKİYAT durumları yazılabilir — iptal/iade PARA hareketi
// doğurur ve bu izinle YAPILAMAZ.Tracking/attribution codes (influencer, campaign, affiliate). When a code is created the region is **not taken from the request**, it is written from the token's region — an app cannot open a code in another region. **Ownership rule:** you may only touch links whose `source_app` is your key; links created by the store by hand, or by another app, return `403`. The measurement endpoints (funnel, traffic, pages) are **aggregate**: no IP, no browser fingerprint, no session id is returned.
/api/apps/v1/links links.readList codes
The region pin is **optional**: with a pin you get that region's codes, without one **all regions**. ⚠️ `clickCount` is an **all-time** counter, whereas the funnel and traffic endpoints are **windowed** — do not mix the two.
status | string | Filter by status (`A` active · `D` passive). Left empty, all of them. |
# Bölge pini OPSİYONEL: pin varsa o bölge, pin yoksa TÜM bölgeler. curl "https://api.milofly.com/api/apps/v1/links?status=A" -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"items": [
{ "code": "AYSE20", "type": "influencer", "title": "Ayşe · Instagram",
"targetUrl": "/", "url": "https://magaza.com/go/AYSE20",
"commissionRate": 15.0,
"utmSource": "instagram", "utmMedium": "social", "utmCampaign": "yaz26",
"startsAt": "2026-06-01T00:00:00Z", "expiresAt": null,
"combinable": false, "priceRuleId": 42, "status": "A",
"clickCount": 1284, "createdAt": "2026-06-01T09:00:00Z" }
]
}
// ⚠️ clickCount ALL-TIME sayaçtır; huni ve trafik uçları PENCERELİDİR — karıştırma./api/apps/v1/links/{code} links.readCode detail
A single code, **unenveloped** (no array, no `items`). If not found, `404 "Code not found."` — a region-scoped token also sees another region's code as `404`: existence is not leaked.
curl https://api.milofly.com/api/apps/v1/links/AYSE20 -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
// ZARFSIZ — items yok, doğrudan nesne.
{ "code": "AYSE20", "type": "influencer", "title": "Ayşe · Instagram", "…": "…" }
// 404 "Kod bulunamadı." — bölge-kapsamlı token BAŞKA bölgenin kodunu da 404 görür
// (yetki hatası değil: varlık sızdırılmaz)./api/apps/v1/links/{code}/pages links.readPages the code brought traffic to
Which page types the clickers went to. Privacy: **aggregate only** — no IP, no browser fingerprint, no session id. If `label` cannot be resolved it comes back `null`; show the raw `page` value in that case.
days | int | Day window, **clamped to 1–365**, and the effective value is returned in the response. |
curl "https://api.milofly.com/api/apps/v1/links/AYSE20/pages?days=30" -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"code": "AYSE20", "days": 30,
"pages": [
{ "page": "product", "label": "Kablosuz Kulaklık", "views": 412 },
{ "page": "home", "label": null, "views": 98 }
]
}
// KVKK: yalnız AGREGAT — IP / tarayıcı imzası / oturum kimliği DÖNMEZ.
// label çözülemezse null; o zaman ham "page" değerini göster./api/apps/v1/links/{code}/funnel links.readThe code's conversion funnel
The step order is **fixed**: `clicks → sessions → productViews → addToCart → checkoutStart → orders`. `conversionFromPrevious` is `null` on the first step and whenever the previous step is `0` (no division by zero). ⚠️ The rate **can exceed 100%** — because of sessions that started at the window boundary and cross-device bridging; that is not a data error. `orders` is the **last-touch order count**, not split revenue. `consentLimited` is a **structural** warning computed from data: it is `true` when the window starts before the moment storefront tracking became independent of the cookie banner decision (the release of the 17.09.2026 decision) — in that part of the window a session was only opened for visitors who allowed analytics cookies, so `sessions` and the following steps may be lower. It is `false` once the window lies entirely after that moment. A gap of `clicks ≥ sessions` is normal in any case — clicks are counted on the server, a session starts when the page opens in the browser.
days | int | If omitted, **the attribution window of the link's region** is used (90 by default). If given, it is clamped to 1–365. The effective value is returned as `windowDays`. |
# days VERİLMEZSE linkin bölgesinin atıf penceresi kullanılır (varsayılan 90). curl https://api.milofly.com/api/apps/v1/links/AYSE20/funnel -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"code": "AYSE20", "windowDays": 90, "consentLimited": true,
"steps": [
{ "step": "clicks", "count": 1284, "conversionFromPrevious": null },
{ "step": "sessions", "count": 1102, "conversionFromPrevious": 85.8 },
{ "step": "productViews", "count": 740, "conversionFromPrevious": 67.2 },
{ "step": "addToCart", "count": 210, "conversionFromPrevious": 28.4 },
{ "step": "checkoutStart", "count": 96, "conversionFromPrevious": 45.7 },
{ "step": "orders", "count": 41, "conversionFromPrevious": 42.7 }
]
}
// Basamak sırası SABİTTİR. conversionFromPrevious ilk basamakta ve önceki basamak 0 iken null.
// ⚠️ Oran %100'ü AŞABİLİR — pencere sınırında başlamış oturumlar + cihazlar arası köprü;
// veri hatası DEĞİL.
// "orders" LAST-TOUCH sipariş SAYISIDIR, bölüşülmüş ciro değil.
// consentLimited YAPISALDIR ve veriden hesaplanır: pencere, vitrin izlemesinin çerez bandı kararından
// bağımsız hâle geldiği andan (17.09.2026 kararının yayını) önce başlıyorsa true — o kısımda oturum yalnız
// analitik çerezlere izin verende açılıyordu, sessions ve sonrası eksik olabilir. Pencere tamamen sonrasındaysa false.
// clicks ≥ sessions farkı her durumda normaldir (tıklama sunucuda, oturum tarayıcıda)./api/apps/v1/links/{code}/traffic links.readThe code's traffic (daily + sources)
`daily` contains **only the days that had clicks** — zero-filling is the consumer's job. `direct` = the referrer arrived empty, `other` = the domain could not be resolved; **the raw referrer URL is never returned under any circumstances**. The source list is capped at the 100 most-clicked rows.
days | int | Day window, clamped to 1–365. |
curl "https://api.milofly.com/api/apps/v1/links/AYSE20/traffic?days=30" -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"code": "AYSE20", "days": 30,
"daily": [ { "date": "2026-07-14T00:00:00Z", "clicks": 83 } ],
"referrers": [ { "source": "instagram.com", "clicks": 610 },
{ "source": "direct", "clicks": 204 },
{ "source": "other", "clicks": 12 } ]
}
// daily YALNIZ tıklaması olan günleri içerir — sıfır-doldurma tüketenin işidir.
// direct = referrer boş geldi · other = domain çözülemedi.
// HAM REFERRER URL'İ HİÇBİR KOŞULDA DÖNMEZ. Kaynak listesi en çok tıklanan 100 satırla sınırlı./api/apps/v1/links links.writeCreate a code
⚠️ This endpoint **requires a region pin** — unlike the other 7 in the group. A store-wide token **cannot create** a code without pinning a region (though it can list and update them); the reason is that a code's region is permanent and is not taken from the request, it is written from the token/pin region.
coderequired | string | **Required** — the code itself. If the same code is already active, `409`. |
targetUrlrequired | string | **Required** — the page the code redirects to. `GET /storefront-targets` gives you the value (`targetPath`). |
title | string | Human-readable label (e.g. person + channel). |
type | string | Code type label (e.g. `influencer`). |
commissionRate | decimal | 0–100. ⚠️ **Past attribution does not change**: the rate is snapshotted at order time; changing it affects only **subsequent** orders. |
utmSource / utmMedium / utmCampaign | string | `utmSource` · `utmMedium` · `utmCampaign`. ⚠️ Left empty, the redirect appends nothing to the target URL and the lead's source trail is **broken** — the store can never again answer “where did this customer come from”. |
priceRuleId | long | The id of the coupon to attach to the code (the `id` from the `POST /coupons` response). Only a rule of the coupon type is accepted. |
startsAt / expiresAt | ISO-8601 | `startsAt` · `expiresAt` — the validity range. |
combinable | bool | Whether this code is counted together with other codes in the attribution split. |
# ⚠️ BÖLGE PİNİ ZORUNLU — bu grupta yalnız BU uçta. Mağaza-geneli token, bölge
# pinlemeden kod AÇAMAZ (ama listeleyip güncelleyebilir).
curl -X POST https://api.milofly.com/api/apps/v1/links \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" -H "X-Region-Id: 5" \
-d '{
"code": "AYSE20",
"targetUrl": "/",
"title": "Ayşe · Instagram",
"commissionRate": 15,
"utmSource": "instagram", "utmMedium": "social",
"priceRuleId": 42
}'201 Created (Location: /api/apps/v1/links/AYSE20)
{ "code": "AYSE20", "…": "… AppLinkDto ile aynı şekil …" }
// region_id İSTEKTEN ALINMAZ, token/pin bölgesinden yazılır — başka bölgeye kod açamazsın.
//
// 400 "code zorunludur." · "targetUrl zorunludur (kodun yönlendireceği sayfa)."
// 400 "commissionRate 0-100 aralığında olmalıdır."
// 409 "'AYSE20' kodu zaten kullanımda."
// 400 (çekirdek) priceRuleId kupon tipinde bir kural değilse.
//
// ⚠️ UTM boş bırakılırsa yönlendirme hedef URL'e HİÇBİR ŞEY EKLEMEZ ve lead'in kaynak izi
// KOPAR — mağaza "bu müşteri nereden geldi" sorusunu bir daha cevaplayamaz./api/apps/v1/links/{code} links.writeUpdate a code (partial)
**Partial update:** `null` = **do not touch**. To clear a field, use the `clearCommissionRate` / `clearPriceRuleId` / `clearExpiresAt` flags — sending `null` does not clear it. `status` only accepts `"active"` | `"passive"`; the core's raw `A`/`D` codes are not leaked to this surface. You may only touch codes **you** created: another app's code gives `403`, a non-existent one `404`.
targetUrl | string | If sent, it **cannot be empty** (to leave it alone, do not send it at all). |
title | string | Sending an empty string **clears** the title (a deliberate exception on this field). |
startsAt | ISO-8601 | Can be changed, but **cannot be set back to `null`**. |
status | string | `active` | `passive`. When reactivating a passive code, if another active link carries the same code you get `409`. |
# KISMİ: null = DOKUNMA. Alanı SİLMEK için clear* bayrakları:
# clearCommissionRate · clearPriceRuleId · clearExpiresAt
curl -X PATCH https://api.milofly.com/api/apps/v1/links/AYSE20 \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" \
-d '{"commissionRate":18,"status":"active"}'200 { "code": "AYSE20", "commissionRate": 18.0, "status": "A", "…": "…" }
// status YALNIZ "active" | "passive" — çekirdeğin ham 'A'/'D' kodları bu yüzeye SIZDIRILMAZ.
//
// 400 "Gövde zorunlu." · "status yalnız 'active' veya 'passive' olabilir."
// 400 "targetUrl boş olamaz (kaldırmak için gönderme)." · "Güncellenecek alan yok."
// 403 "Bu kod bu uygulama tarafından oluşturulmadı; değiştirilemez."
// 404 "Kod bulunamadı."
// 409 pasif kodu aktife çekerken aynı kodu taşıyan başka aktif link varsa.
//
// ⚠️ GEÇMİŞ ATIF DEĞİŞMEZ: komisyon oranı sipariş anında snapshot'lanır. Oranı değiştirmek
// yalnız BUNDAN SONRAKİ siparişleri etkiler./api/apps/v1/links/{code} links.writeRemove a code (soft)
The row is **not deleted**, its status is set to passive (`status: "D"`) — so that past attribution and reports are not corrupted. It is **idempotent**: already passive still returns `200`. Another app's code gives `403`.
curl -X DELETE https://api.milofly.com/api/apps/v1/links/AYSE20 -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{ "success": true, "code": "AYSE20", "status": "D" }
// SOFT: satır SİLİNMEZ, pasife çekilir. İdempotent (zaten pasifse de 200).
// 403 başkasının kodu · 404 yok.Coupons live in the core's price rule engine (`rule_type='coupon'`); an app **never applies the discount itself**, it only wires up “which code → which coupon”. **The region pin is required**: price and currency depend on the region. The chain: you write the `id` of the coupon you create here into the `priceRuleId` field of the `POST/PATCH /links` body — that is the only way an influencer code carries a real discount.
/api/apps/v1/coupons coupons.readList coupons
The region's **active** coupons; there is no paging, all of them are returned. Names resolve in the region's language (they are not overridden by the admin's language). `discountType` / `discountValue` / `minOrderAmount` / `maxDiscountAmount` are **resolved out of the internal rule JSON into flat fields** — you do not parse raw JSON; if the rule is malformed these fields come back `null` but the coupon is still listed. ⚠️ The `combinable` field was **removed on 2026-08-19**: it was documented as campaign stacking, but no query on the coupon path ever read it — it did nothing. A coupon **always** stacks on top of active campaigns. (The `combinable` in a link's attribution split is a separate field and remains.)
curl https://api.milofly.com/api/apps/v1/coupons -H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5"
{
"items": [
{ "id": 42, "couponCode": "AYSE20", "name": "Ayşe %20",
"discountType": "percentage", "discountValue": 20, "currency": "EUR",
"minOrderAmount": 50, "maxDiscountAmount": null,
"startsAt": "2026-06-01T00:00:00Z", "endsAt": null,
"maxUsage": null, "currentUsage": 38, "status": "A" }
],
"totalCount": 1
}
// discountType/discountValue/minOrderAmount/maxDiscountAmount iç rule_config JSON'undan
// ÇÖZÜLÜP düz alanlara açılır — ham JSON parse etmezsin. rule_config bozuksa bu alanlar null
// gelir ama kupon YİNE listelenir.
// ⚠️ "combinable" 2026-08-19'da KALDIRILDI: kupon istiflemesi diye belgelenmişti ama kupon
// yolundaki hiçbir sorgu is_combinable'ı okumuyordu — alan hiçbir şey yapmıyordu.
// Kupon DAİMA aktif kampanyaların üstüne biner. (Linkin atıf bölüşümündeki "combinable"
// AYRI bir alandır, o çalışıyor ve duruyor.)/api/apps/v1/coupons coupons.writeCreate a coupon
The coupon created is **always cart-level** (product/category narrowing does not exist on this thin surface), and the region is **not taken from the request**, it is born in the pinned region. The returned `id` goes into the `priceRuleId` field of the `POST/PATCH /links` body.
couponCoderequired | string | **Required**, at most 60 characters. If it collides in the core, `400`. |
namerequired | string | **Required** — the coupon's display name. |
discountTyperequired | string | **Required**: `percentage` | `fixed`. ⚠️ This two-value allowlist exists for a fail-loud reason: the cart engine applies only these two, and any other value would produce “the coupon looks valid but no money comes off”. |
discountValuerequired | decimal | **Required**, greater than zero; for `percentage` it cannot exceed 100. |
minOrderAmount / maxDiscountAmount / maxUsage / perCustomer | decimal|int | `minOrderAmount` · `maxDiscountAmount` (cannot be negative) · `maxUsage` · `perCustomer`. |
startsAt / endsAt | ISO-8601 | `startsAt` · `endsAt` — the validity range. |
curl -X POST https://api.milofly.com/api/apps/v1/coupons \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" -H "X-Region-Id: 5" \
-d '{
"couponCode": "AYSE20",
"name": "Ayşe %20",
"discountType": "percentage",
"discountValue": 20,
"minOrderAmount": 50
}'201 Created
{ "id": 42, "couponCode": "AYSE20", "…": "… AppCouponDto ile aynı şekil …" }
// SABİT: yaratılan kupon DAİMA sepet bazlıdır (application='cart_total', scope='all');
// ürün/kategori daraltması bu dar yüzeyde YOKTUR. region_id İSTEKTEN ALINMAZ.
//
// ZİNCİR: dönen id → POST/PATCH /links gövdesindeki priceRuleId. İnfluencer kodu ancak
// böyle GERÇEK indirim taşır.
//
// 400 "couponCode zorunlu." · "couponCode en fazla 60 karakter." · "name zorunlu (kuponun görünen adı)."
// 400 "discountType 'percentage' veya 'fixed' olmalı." · "discountValue sıfırdan büyük olmalı."
// 400 "Yüzde indirim 100'den büyük olamaz." · "minOrderAmount negatif olamaz."How revenue is split when an order touched more than one tracking code. The setting lives **in the core** (the store's plugin settings); the app does not copy it to its own side — it reads and writes it through this endpoint. It is region-scoped, and the region pin is **required**. 🔴 Money-critical: a click outside the `windowDays` window produces no commission; `model` answers “who shares it”, `windowDays` answers “for how long” — they are separate axes.
/api/apps/v1/attribution-settings attribution.manageRead the attribution setting
⚠️ **The asymmetry is deliberate:** `model` / `maxLinks` / `weights` come back `null` if they were never saved — no fake value is invented, and in that case the core default applies (an **equal** split among combinable links). `windowDays`, on the other hand, is **always the effective** value (`90` — what the engine will use — if nothing was saved). The reason: the split is optional, the window is not — the window is always in force, so you can show it directly on your screen. `isConfigured` tells you whether the setting was ever saved at all.
curl https://api.milofly.com/api/apps/v1/attribution-settings \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "X-Region-Id: 5"
{
"isConfigured": true,
"settings": { "model": "weighted", "maxLinks": 3, "weights": [0.6, 0.3, 0.1], "windowDays": 90 }
}
// Hiç kaydedilmemişse:
{ "isConfigured": false,
"settings": { "model": null, "maxLinks": null, "weights": null, "windowDays": 90 } }
// ⚠️ ASİMETRİ BİLİNÇLİ: model/maxLinks/weights kaydedilmemişse null döner (sahte değer
// uydurulmaz — çekirdek varsayılanı "combinable linkler arasında EŞİT" uygulanır),
// AMA windowDays DAİMA EFEKTİF değerdir (kaydedilmemişse motorun kullanacağı 90).
// Sebebi: bölüşüm opsiyoneldir, PENCERE DEĞİLDİR — pencere her zaman yürürlüktedir.
// Bu yüzden windowDays'i ekranında doğrudan gösterebilirsin./api/apps/v1/attribution-settings attribution.manageWrite the attribution setting
After the write, **the effective setting the engine will see is read back and returned** — the body you sent is not echoed verbatim (write ↔ read symmetry). On invalid values the core fails loud with `400`.
model | string | `last_touch` | `equal` | `weighted`. |
maxLinks | int | Greater than zero. `null` = unlimited. |
weights | decimal[] | Positional weights in `weighted` mode, **newest to oldest**. At least one must be greater than zero; negative values count as `0`. |
windowDays | int | 1–3650. If `null` is sent, `90`. 🔴 Money-critical: a click outside this window produces no commission. |
curl -X PUT https://api.milofly.com/api/apps/v1/attribution-settings \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" -H "X-Region-Id: 5" \
-d '{"model":"weighted","maxLinks":3,"weights":[0.6,0.3,0.1],"windowDays":60}'{ "success": true,
"settings": { "model": "weighted", "maxLinks": 3, "weights": [0.6,0.3,0.1], "windowDays": 60 } }
// Yazımdan SONRA motorun göreceği EFEKTİF ayar geri OKUNUP döner (yazılan ↔ okunan simetrisi)
// — gönderilen gövde aynen yansıtılmaz.
//
// 🔴 PARA-KRİTİK: windowDays penceresini AŞAN tıklama komisyon ÜRETMEZ. model ve windowDays
// AYRI EKSENLERDİR: model "kimler paylaşır", window "ne kadar süre".
//
// 400 "Gövde zorunlu." · geçersiz model · maxLinks ≤ 0 · weighted ağırlık toplamı 0 ·
// windowDays aralık dışı (1-3650).Counterparty (accounting) records plus purchase receipts with costs. A single permission: `purchases.write` — a counterparty record is meaningless on its own, it is only the other end of a purchase receipt. Why this exists: increasing stock does not say **at what price** it was bought; if the cost is not recorded, the average-cost ledger never learns it, cost is unknown at the point of sale, and the store's profit report comes out **wrong**. Isolation: you see only the counterparties and receipts you created; the store's own records do not even leak their **existence** (`404`, not `403`).
/api/apps/v1/suppliers purchases.writeList counterparty records
Only the records **you created**. Here “supplier” means a **counterparty** (an accounting counterparty), not a supplier *connector* — the connector is the calling app itself. A counterparty record has **no region column**: counterparties are store-wide (you can bring goods from the same German supplier into both the DE and AT regions), so no region pin is needed.
page / pageSize | int | `page` · `pageSize`. |
search / status / countryCode | string | `search` · `status` (`A`/`P`) · `countryCode`. |
# Bölge pini GEREKMEZ: CARILER tablosunda bölge kolonu YOKTUR, cari mağaza genelindedir. curl "https://api.milofly.com/api/apps/v1/suppliers?page=1&pageSize=50" -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"success": true, "total": 3, "page": 1, "pageSize": 50,
"items": [
{ "id": 11, "code": "RSU", "name": "RSU GmbH",
"countryCode": "DE", "currencyCode": "EUR",
"taxData": { "ust_idnr": "DE123456789" },
"defaultTaxTreatment": "reverse_charge", "incotermDefault": "DAP",
"leadTimeDays": 3, "paymentTermDays": 30,
"contactName": null, "contactEmail": null, "contactPhone": null,
"addressData": null, "notes": null,
"status": "A", "sourceApp": "app.acme.kargo", "externalRef": "RSU/2026/00412",
"createdAt": "…", "updatedAt": "…", "entryCount": 7, "lastEntryDate": "…" }
]
}
// İZOLASYON: yalnız SENİN açtığın kartlar. Mağazanın elle girdiği (source_app NULL) ve başka
// eklentinin kartları bu uçtan GÖRÜNMEZ BİLE./api/apps/v1/suppliers/{id} purchases.writeCounterparty record detail
If it is not found, or belongs to another source: `404 "Counterparty not found, or it does not belong to this app."` — another source's record does not even leak its **existence**.
curl https://api.milofly.com/api/apps/v1/suppliers/11 -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{ "success": true, "item": { "id": 11, "code": "RSU", "…": "… yukarıdaki kart …" } }
// 404 "Cari bulunamadı ya da bu uygulamaya ait değil." ← varlık sızdırılmaz/api/apps/v1/suppliers/tax-fields purchases.writeA country's tax fields (discovery endpoint)
🔴 **Read before you write.** The tax fields of a counterparty record are **not hard-coded**, they come from the country's region definition: Finanzamt + USt-IdNr in Germany, tax number + tax office in Turkey, EIN in the US. If `regionDefined: false`, there is **no** definition for that country: no field set is invented and `taxData` **cannot be sent** (if you send it you get a coded error) — the record can still be created. `label: null` = no translation was entered on the platform for that language; nothing is invented.
countryCode | string | ISO-2 country code (e.g. `DE`). |
lang | string | The label language. If omitted, the core default `tr` is used — the plugin surface has **no** session language, so it is asked for explicitly. |
curl "https://api.milofly.com/api/apps/v1/suppliers/tax-fields?countryCode=DE&lang=de" \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{
"success": true, "countryCode": "DE", "regionDefined": true,
"fields": [
{ "id": 3, "fieldKey": "ust_idnr", "fieldType": "text", "isRequired": true,
"validationPattern": "^DE[0-9]{9}$", "mask": null, "maxLength": 20,
"displayOrder": 1, "label": "USt-IdNr.", "placeholder": null }
]
}
// Alan seti KODDA SABİT DEĞİLDİR: ülkenin bölge tanımından gelir. Almanya'da USt-IdNr,
// Türkiye'de vergi no + vergi dairesi, ABD'de EIN.
// regionDefined:false ⇒ o ülke için tanım YOK: alan seti uydurulmaz ve taxData GÖNDERİLEMEZ
// (gönderilirse CARI_TAX_REGION_NOT_FOUND). Kart yine de açılabilir.
// label:null = Platform'da o dil için çeviri girilmemiş (uydurulmaz)./api/apps/v1/suppliers purchases.writeWrite a counterparty record (upsert)
🔴 **Careful: this endpoint is `PUT`, NOT `POST`.** An app that sends `POST /api/apps/v1/suppliers` gets `405 Method Not Allowed` and leaves the feature dead. There is no `DELETE` either (deliberately — a counterparty is the other end of a receipt); an unused record is deactivated with `status: "P"`. 🔴 **The body is complete, not partial:** a field you do not send is not carried over from the existing record, it is **cleared**. If you want a partial update, `GET` it first and write over it. There is **no silent truncation** on column-width overflow — a truncated tax number means believing wrong data is right.
externalRefrequired | string | **Required**, at most 200 characters; only letters, digits and `. _ : - /`. Without this field, a resend of the same record cannot be told apart and you get **duplicates**. If free text were accepted, whitespace/trimming differences would make two requests look “different” and silently disable the protection. |
code / name / countryCode / currencyCode | string | `code` (≤50, unique **store-wide**, and it may collide with the store's own codes — there is no silent renaming) · `name` (≤300) · `countryCode` (ISO-2) · `currencyCode`. |
taxData | object | Its keys are determined by `GET /suppliers/tax-fields`. An unknown key or a missing required field is rejected with a coded error. |
defaultTaxTreatment / incotermDefault / leadTimeDays / paymentTermDays | mixed | `defaultTaxTreatment` (`standard` | `reverse_charge` | `exempt` | `import` | `not_applicable`) · `incotermDefault` · `leadTimeDays` · `paymentTermDays`. |
contactName / contactEmail / contactPhone / notes | string | `contactName` (≤200) · `contactEmail` (≤200) · `contactPhone` (≤30) · `notes` (≤1000). |
status | string | `A` (active) | `P` (passive). Defaults to `A`. |
# 🔴 FİİL PUT'TUR. POST gönderen uygulama 405 Method Not Allowed alır. DELETE de YOKTUR
# (bilinçli): kullanılmayan kart status:"P" ile pasife alınır.
curl -X PUT "https://api.milofly.com/api/apps/v1/suppliers?lang=de" \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" \
-d '{
"externalRef": "RSU/2026/00412",
"code": "RSU", "name": "RSU GmbH",
"countryCode": "DE", "currencyCode": "EUR",
"taxData": { "ust_idnr": "DE123456789" },
"defaultTaxTreatment": "reverse_charge"
}'// İLK gönderim:
{ "success": true, "created": true, "id": 11, "externalRef": "RSU/2026/00412" }
// TEKRAR (aynı externalRef):
{ "success": true, "created": false, "id": 11, "externalRef": "RSU/2026/00412" }
// 🔴 GÖVDE TAMDIR, KISMİ DEĞİL: gönderilmeyen alan MEVCUTTAN TAŞINMAZ, BOŞALIR.
// PATCH semantiği istiyorsan önce GET ile oku, üstüne yaz.
//
// 400 "externalRef zorunlu — bu alan olmadan aynı kaydın TEKRAR gönderimi ayırt edilemez ve
// çift kayıt oluşur."
// 400 "externalRef yalnız harf, rakam ve . _ : - / içerebilir."
// 400 "code en fazla 50 karakter olabilir (gönderilen: 60)." ← SESSİZ KIRPMA YOK
// 400 "status yalnız 'A' (aktif) ya da 'P' (pasif) olabilir."
// 409 "Cari kartı yazılamadı (eşzamanlı çakışma). Tekrar deneyin."
//
// KODLU HATALAR (400, { success, code, params, message, hint } — message = KODUN KENDİSİ):
// CARI_CODE_EXISTS · CARI_TAX_FIELD_REQUIRED · CARI_TAX_FIELD_UNKNOWN · CARI_TAX_REGION_NOT_FOUND/api/apps/v1/purchases purchases.writeWrite a purchase receipt (draft + approval in one call)
🔴 **Why the region pin is required:** when a warehouse sees its **first** receipt, the ledger currency is set permanently, and its source is the currency of the region active at that moment. The cost key contains **no** region — a ledger currency set wrongly **cannot be undone**. There are three gates: the **warehouse** (only a warehouse bound to this app; if there is no bound warehouse, or more than one, it fails loud with `409`), the **product** (line items may only be products you wrote; a foreign product gives `404` — a purchase receipt permanently changes a product's average cost, and an app must not touch the cost of the store's own product) and the **counterparty** (only counterparties you created). The document type is **fixed**: purchases only; scrap and stock counts are the store's own decision.
externalRefrequired | string | **Required**, ≤200 characters, only letters/digits and `. _ : - /`. **Idempotency:** if you resend with the same reference, an approved receipt is **not written a second time** (`created:false` plus an explanatory note); a receipt left as a draft **is approved from where it stopped** (the body is ignored); a **cancelled** receipt gives `409` — it cannot be revived, send a new reference. |
cariExternalRef | cariIdrequired | string|long | `cariExternalRef` **or** `cariId` — one is required. If there is no record, `400`, telling you to call `PUT /suppliers` first. |
warehouseId | long | Left empty, the **single** warehouse bound to this app is used. If there is no bound warehouse, or more than one, the request is rejected fail-loud — which one to write to is **never guessed**. |
currencyCode / fxRate / taxTreatmentrequired | string|decimal | `currencyCode` is **required**. If the invoice currency differs from the warehouse's **ledger** currency, `fxRate` is required too: **there is no FX service**, and no rate is invented. |
entryDate | ISO-8601 | UTC. Backdating is allowed, **future dates are not**. Empty means now. |
items[]required | array | 1–500 line items. Each item takes: `productId` · `quantity` (positive, ≤2 decimals) · `unitPrice` (**required**; `0` is valid = a free sample, but it cannot be left empty) · `variantId` · `discountRate` / `discountAmount` (cannot exceed the gross amount) · `taxRate` / `taxAmount` · `taxDeductible` (**required** if there is tax) · `notes`. |
docNo / docDate | string | The supplier's document number (≤100) and `docDate`. |
# 🔴 BÖLGE PİNİ ZORUNLU: bir depo İLK fişini gördüğünde DEFTER PARA BİRİMİ kalıcı olarak
# kurulur ve kaynağı o an aktif olan bölgenin para birimidir. GERİ ALINAMAZ.
curl -X POST https://api.milofly.com/api/apps/v1/purchases \
-H "Authorization: Bearer mfapp_xxxxxxxxxxxx" -H "Content-Type: application/json" -H "X-Region-Id: 5" \
-d '{
"externalRef": "RSU/2026/00412",
"cariExternalRef": "RSU/2026/00412",
"warehouseId": 31,
"currencyCode": "EUR",
"taxTreatment": "reverse_charge",
"docNo": "RE-88213",
"items": [ { "productId": 942, "quantity": 4, "unitPrice": 84.50,
"taxRate": 0, "taxDeductible": true } ]
}'{
"success": true, "created": true,
"entryId": 501, "entryNo": "GRS-2026-000501", "status": "posted",
"warehouseId": 31, "cariId": 11,
"currency": "EUR", "bookCurrency": "EUR", "fxRate": 1,
"grandTotalDoc": 338.00, "totalCostBook": 338.00,
"entryDate": "2026-08-06T10:00:00Z",
"items": [
{ "productId": 942, "variantId": null, "quantity": 4,
"unitPriceDoc": 84.50, "netAmountDoc": 338.00, "taxAmountDoc": 0,
"taxDeductible": true,
"unitCostBook": 84.50, "totalCostBook": 338.00, "avgCostBookAfter": 84.50 }
],
"note": null
}
// 🔴 YANIT CANLI OKUMADIR, gönderilenin AYNASI DEĞİL: birim maliyet kur + iskonto +
// İNDİRİLEMEYEN vergiden HESAPLANIR. avgCostBookAfter = yazımdan SONRAKİ hareketli ortalama.
//
// 🔴 İKİ PARA BİRİMİ AYRI DÖNER: currency (fatura) ve bookCurrency (deponun defteri).
// Farklıysa fxRate ZORUNLUDUR — KUR SERVİSİ YOKTUR, uydurma kur yazılmaz. Defter para
// birimini öğrenmenin yolu: fişi bir kez dene, STOCK_ENTRY_FX_RATE_REQUIRED kodunun
// params'ında doc ve book gelir.
//
// İDEMPOTENCY (aynı externalRef ile tekrar):
// · fiş ONAYLI → 200, created:false, note:"…ZATEN onaylanmış — stok ve maliyet ikinci kez
// yazılmadı."
// · fiş TASLAK → kaldığı yerden ONAYLANIR (gövde YOK SAYILIR)
// · fiş İPTAL → 409 "İptal edilmiş bir belge yeniden canlandırılamaz… YENİ bir externalRef
// gönderin."
// · eşzamanlı yarış → kazanan fişin sonucu döner, ÇİFT FİŞ YOK.
//
// ÜÇ KAPI: DEPO (yalnız bu uygulamaya bağlı depo; 409 + ne yapılacağı) · ÜRÜN (yalnız senin
// yazdığın ürünler; 404 + productIds[]) · CARİ (yalnız senin açtığın cari; 400 + "Önce PUT
// /api/apps/v1/suppliers ile kartı yazın.").
//
// BELGE TİPİ SABİT: yalnız alış. doc_type DIŞARIDAN ALINMAZ./api/apps/v1/purchases/{id} purchases.writePurchase receipt detail
If it is not found, or belongs to another app: `404 "Receipt not found, or it does not belong to this app."`
curl https://api.milofly.com/api/apps/v1/purchases/501 -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{ "success": true, "item": { "entryId": 501, "…": "… fiş …" } }
// 404 "Fiş bulunamadı ya da bu uygulamaya ait değil."/api/apps/v1/purchases/by-ref/{externalRef} purchases.writeLook up a receipt by my own reference
Answers the question **“did it get written?”** after a connection drop **without attempting another write**. Without this endpoint the only route would be to resend the receipt; even with idempotency protecting you, asking for state with a write request is a bad habit. If there is no record, `404 "No receipt was written with this externalRef."`
curl https://api.milofly.com/api/apps/v1/purchases/by-ref/RSU%2F2026%2F00412 \ -H "Authorization: Bearer mfapp_xxxxxxxxxxxx"
{ "success": true, "entryId": 501, "entryNo": "GRS-2026-000501",
"status": "posted", "warehouseId": 31,
"totalCostBook": 338.00, "bookCurrency": "EUR" }
// Kesinti sonrası "yazıldı mı?" sorusunu YENİ BİR YAZMA DENEMESİ YAPMADAN cevaplar.
// 404 "Bu externalRef ile yazılmış bir fiş yok."Ask only for the scopes you need when creating an app. A scope expansion requires fresh approval (no silent expansion).
orders.readRead orders (list + detail) Liveorders.writeImport orders (marketplace import) Liveproducts.readRead products Liveproducts.writeUpdate products Livecustomers.readRead customer/address data (PII) — the strictest privacy barrier Livewebhooks.manageManage webhook subscriptionsComing soonlinks.readRead referral/tracking codes (affiliate/influencer) Livelinks.writeCreate referral/tracking codes (affiliate/influencer) Liveorders.costs.writeWrite cost/commission lines on an order (profit & loss) Livetracking.readSubscribe to central tracking events (via webhook) Livetracking.settings.readRead the store settings of your own tracking provider (measurement_id, api_secret) Livetracking.tag.writeWrite the browser tag template of your own tracking provider (injects a script into the storefront) Livecoupons.readList the store's active coupons Livecoupons.writeCreate coupons on behalf of the store (influencer discount) Liveattribution.manageRead/write the multi-link revenue attribution model Liveinventory.writeWrites stock levels (add/subtract/set; single row or bulk). Livefulfillment.writeWrites 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. Livepurchases.writeWrites its own supplier accounts and purchase receipts (its own records only). Liveinvoices.readReads 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. Liveinvoices.writeWrites 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. Livelistings.writeWrites 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. Liveshipping.providerBecomes 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. LiveYou can price your plugin as free, one-time or a subscription. You manage the plans in the portal (app detail → Pricing). Milofly collects the payment (we are the merchant of record); a commission is deducted and the rest is paid out to you.
💳 The plan is chosen at install time. Without a plan your plugin installs for free. With a paid plan the store owner picks and pays for it during installation.
🌍 A separate entitlement per region. The same app is installed separately per region and the subscription runs per region; the amount is in that region's currency.
⛔ 402 Payment Required. Until payment completes, a paid installation returns 402 to API calls. Once paid, access and webhooks open automatically. If your subscription goes past_due, you get 402 again.
🔁 Renewal. Subscriptions renew automatically at the end of a period; a failed charge stops access.