# DYLI Commerce API reference

Version: `v1.32`\
Production base URL: `https://www.dyli.io/api/commerce/v1`  
OpenAPI 3.1: `https://www.dyli.io/docs/openapi/commerce-v1.openapi.json`  
Purpose: collectibles apps with boxes, vaulting, sell-backs, shipping, secondary marketplace and P2P trading. Box checkout uses card or USDC; custom box checkouts can accept other crypto and settle in USDC. Peer marketplace payments and trade cash use the customer's Abstract USDC wallet.

## Available in v1.32

Customers can open 1–10 of the same box, choose Vault or Sell, ship eligible items, list and buy collectibles, make and accept offers, and trade items with optional USDC. Check capabilities before showing actions. The example includes these live flows. Card checkout applies to boxes; peer marketplace and trade cash settle from the customer's Abstract USDC wallet. Shipping has its own USDC checkout. Conversion from other crypto is not built into the example.

Use /community endpoints for marketplace and P2P, not legacy catalog quote types. Check app configuration and returned capabilities before using a documented action.

Start at https://www.dyli.io/docs/api/commerce for the overview or https://www.dyli.io/docs/api/commerce-partner-setup to run the storefront. This reference contains the full endpoint details for custom integrations and coding tools.

The starter defaults to the production Commerce and Read URLs: no slug or URL override is needed. For your own Privy app, set DYLI_API_KEY, NEXT_PUBLIC_PRIVY_APP_ID, PRIVY_SECRET_KEY and STOREFRONT_ORIGIN. NEXT_PUBLIC_DEMO_MODE=false uses real money and inventory, even locally; true simulates purchases without payments. A separate lab needs an assigned URL and key. The doctor command checks the connection and configuration; test login, checkout and fulfillment before launch.

## Marketplace and trading

Let customers list collectibles, buy from each other, make offers and trade. These flows use the same marketplace and P2P contracts as DYLI. No separate inventory or contracts to deploy.

The [example app](https://github.com/doyoulikeit/dyli-commerce-example) includes Shop, listing and offer forms, vault actions, a trade composer, and incoming and sent trades.

## Choose your community

Save the settings on your app with `PATCH /config`:

```json
{
  "community_settings": {
    "marketplace_enabled": true,
    "trading_enabled": true,
    "include_dyli_marketplace": false,
    "include_dyli_collectors": false
  }
}
```

| Setting | Off | On |
| --- | --- | --- |
| `include_dyli_marketplace` | Orders created through your Commerce app | Listings and offers across DYLI |
| `include_dyli_collectors` | Customers synchronized to your app | Collectors across DYLI |

Both inclusion settings default off and can be changed independently. They control discovery and which outside orders or collectors your app can act on. They do not make activity private. Listings and offers created in your app **always appear on DYLI**, regardless of these settings. Trades use DYLI's normal inbox and activity, including trades with DYLI collectors. Contract activity is public.

In the example, set `INCLUDE_DYLI_MARKETPLACE=true` and/or `INCLUDE_DYLI_COLLECTORS=true` in your server environment. `MARKETPLACE_ENABLED` and `TRADING_ENABLED` control each feature. Restart locally or redeploy; the running server saves explicit values to your app automatically. Unspecified settings stay unchanged. Use the same settings in deployments that share a Commerce key. Builds do not change app configuration. `npm run community:configure` remains available to preview settings, with `-- --apply` to save manually.

Keep configuration on your backend. Never let a customer change these settings through a public proxy.

## Check availability

`GET /` returns `capabilities.community.marketplace` and `capabilities.community.trading`. Each contains `ready` and `include_dyli`. Bootstrap also exposes them under `storefront.community`. `GET /community/settings` reads the current capability state directly.

If `ready` is false after enabling the feature, [request activation from DYLI](commerce-support.md#operations). Include your app name and the feature you want. Your key cannot enable a platform capability that hasn't been activated. Turning a feature off blocks new preparations and discovery, but existing action receipts and trade history remain available for recovery.

## Discover listings, offers and collectors

All paths below are relative to `https://www.dyli.io/api/commerce/v1`. Every request uses your server-side Commerce key.

| Request | Returns |
| --- | --- |
| `GET /community/market?kind=listing` | Active listings with current on-chain price and quantity |
| `GET /community/market?kind=offer&token_id=123` | Offers for a collectible |
| `GET /community/market?kind=listing&external_customer_id=customer-123` | That customer's active listings within the app's scope |
| `GET /community/collectors?q=collector` | Up to 20 matching usernames; type at least two characters |
| `GET /community/collectors/{username}/holdings?page=1` | Live, visible collectibles in that collector's wallet |
| `GET /community/trades?external_customer_id=customer-123` | Incoming and outgoing trade history for that customer |

Market results accept `q`, `token_id`, `offset`, `brand`, `category`, `subcategory`, `seller` (username), `min_price`, `max_price` and `sort=newest|price_low|price_high`. Price filters are decimal USDC amounts from 0 to 1,000,000. Filters and sorting apply before pagination. The response includes `facets.brands`, `facets.categories` and `facets.subcategories` from orders visible to your app, respecting the search and seller scope.

Trade history accepts `offset` and `view=all|incoming|outgoing|history`. Both feeds return up to 25 rows and `pagination.next_offset` (`null` at the end). Holdings use `pagination.has_more`. Market rows include `order_id`, `token_id`, `kind`, `unit_amount`, `price`, `quantity`, `expiration`, `maker`, `product` and `collector`. `unit_amount` is an integer string in USDC base units (six decimals); `price` is a decimal amount for display. Feed prices are indicative until preparation and confirmation.

Use the Collection token ID, not the product ID. Collector responses contain public profile fields, never their login subject or email. A collector outside your allowed community returns not found.

With DYLI collectors off, the inbox shows trades with your app's customers and trades created through your app. It does not pull in the customer's unrelated DYLI trades. Trades already created through your app stay in its history if you later turn broader discovery off.

## Prepare, sign, confirm

The same flow handles every marketplace or on-chain trade action:

1. Authenticate the customer on your backend. Supply their synchronized `external_customer_id` yourself.
2. Call `POST /community/actions` with a saved `Idempotency-Key` and the action terms below.
3. Show the returned `action.input` and preview. If `approvals` is nonempty, send those instructions in order and wait for success. They grant item-transfer or USDC permissions, not a purchase by themselves.
4. Have the customer sign `action.transaction` exactly as returned. Persist the action ID and transaction hash immediately.
5. Call `POST /community/actions/{id}/confirm` with `{ "tx_hash": "0x..." }`. Show success when `action.status` is `completed`.

The API checks the customer wallet, ownership, app settings, live contract order, price, quantity and normal DYLI sale restrictions. Confirmation verifies the exact sender, contract, calldata and receipt before recording native DYLI activity.

Preparation lasts 10 minutes. It does not reserve stock or lock funds. A public listing can sell or an offer can expire before your customer confirms. Never replace the wallet, contract, data, value or network in a returned instruction. Use the existing gas-sponsorship configuration when supported by the wallet.

### Marketplace action terms

Every body includes `external_customer_id` and `kind`.

| `kind` | Other fields |
| --- | --- |
| `list` | `token_id`, `quantity`, `unit_amount`, `expiration` |
| `offer` | `token_id`, `quantity: 1`, `unit_amount`, `expiration` |
| `buy` | `token_id`, `order_id`, `quantity`, `expected_amount` |
| `accept_offer` | `token_id`, `order_id`, `quantity: 1`, `expected_amount` |
| `cancel_listing` / `cancel_offer` | `token_id`, `order_id` |

`unit_amount` is the per-item price in integer USDC base units. `expected_amount` is the selected order's unit amount multiplied by quantity. Use strings for both. Listings allow 1–20 copies; offers and offer acceptance cover one copy. Expiration is a Unix timestamp between five minutes and 30 days in the future. A maker can cancel their own order, not someone else's.

```json
{
  "external_customer_id": "customer-123",
  "kind": "list",
  "token_id": "456",
  "quantity": 2,
  "unit_amount": "25000000",
  "expiration": 1900000000
}
```

This lists two copies at $25 each. Replace the sample expiration with a timestamp in the allowed window.

### Trade action terms

Send `kind: "trade"`, the other collector's `collector` username, `give` and `receive` item arrays, and `give_usdc` / `receive_usdc` in base-unit strings. Each item is `{ "token_id": "456", "quantity": 1 }`. Each side must contribute an item or cash. Only one side can include cash; use the difference. There can be up to 20 different items per side and 20 copies of each item.

```json
{
  "external_customer_id": "customer-123",
  "kind": "trade",
  "collector": "collector1234",
  "give": [{ "token_id": "456", "quantity": 1 }],
  "receive": [{ "token_id": "789", "quantity": 1 }],
  "give_usdc": "5000000",
  "receive_usdc": "0"
}
```

The customer offers one item plus $5 for the other item. Nothing exchanges at creation. Keep the offered assets in the wallet until the other collector accepts or the sender cancels.

To accept or cancel, prepare `kind: "accept_trade"` or `kind: "cancel_trade"` with `trade_id` from trade history, then follow the same sign-and-confirm flow. Only the receiver can accept; only the sender can cancel. Trade IDs are not marketplace order IDs. Current trades include a verified creation receipt so the items shown can be checked against the on-chain terms. Older trades without a receipt must be recreated before accepting through Commerce.

The receiver can decline with `POST /community/trades/{id}/decline` and their `external_customer_id`. Declining updates the native inbox without transferring assets or charging a fee. It does not cancel the sender's on-chain order. Diamonds remain available on DYLI, not in Commerce trade preparation.

## Fees and payments

Marketplace and trade cash settle in Abstract USDC from the customer wallet. These actions do not use Stripe Checkout or a partner treasury payer. Customers can fund the wallet through their own supported transfer or conversion flow first.

DYLI's normal marketplace fees apply. The contract determines the seller's net proceeds. The box profit share and optional box fees do not apply to these peer transactions. There is no payout-recipient override.

## Recovery

Use `GET /community/actions/{id}` or `GET /community/actions?external_customer_id=customer-123` (the latest 50 actions). Prepared actions return instructions until they expire. Completed actions return their result and transaction reference, not another transaction to send.

Always reuse the saved idempotency key and body when retrying preparation. Reusing a key with different terms returns `idempotency_conflict`. If a transaction was sent, confirm that hash even after the preparation timer expires. Do not prepare and pay again.

`requires_action` or `community_reconciliation_required` means the chain transaction succeeded but recording needs a retry. Keep the same hash and call confirmation again. `transaction_not_confirmed` means wait and retry; it is not permission to send another transaction. An uncertain wallet submission without a hash needs wallet-history recovery before another send. Poll action and trade history for updates; community-specific webhooks are not emitted in this release.

## Development before live checkout

Do not block UI development on Commerce activation. A custom UI can use local mock data without credentials, or the Public Read API with a free Read key. All Commerce endpoints (including catalog GETs and bootstrap) still require a Commerce key. The current full example loads its live catalog through Commerce even with NEXT_PUBLIC_DEMO_MODE=true, so a free Read key or missing key will not populate that catalog. Demo mode skips customer login and simulates purchases; it does not relax API authorization or create a lab. Localhost with demo disabled uses real production funds and inventory.

A temporary HTTPS deployment you control, such as a Vercel project URL, is fine at registration. The finished storefront need not exist yet. The saved origin is used to allow checkout returns, not just for records. Ask DYLI before changing it. Apex and www are separate origins; redirects do not approve domains.

## Requests that need DYLI

Request guide: https://www.dyli.io/docs/api/commerce-support. Its buttons open the signed-in form at https://www.dyli.io/requestapi?inquiry=managed_login#commerce-support (topics: managed_login, domain_login, payments, testing, other). Guests must sign in to submit. The server emails support@dyli.io and CCs the developer account email. History shows queued/sent status and timestamps after refresh. Duplicate clicks do not send duplicate emails. Limits: one request per app/topic per 24 hours, three per account per 24 hours, and 10 minutes between new requests. Reply to the email to follow up. Include the requested change, storefront URL and any error code/request_id, never API keys, login tokens, wallet keys or provider secrets.

- Managed login: /commerce-support#managed-login. DYLI configures shared DYLI identity through a domain-restricted Privy client, or an isolated provider app, and approves exact origins. Creation of a key is not activation. Wait for bootstrap auth.available:true with the correct public app_id and origins, then test login/wallet creation.
- Domain or login-mode changes: /commerce-support#domains-and-login. Provide current/desired settings and whether customers already exist. Key rotation doesn't change auth mode. Update your own provider's domain settings separately.
- Payment setup, DYLI Stripe promotion codes, or unavailable checkout/gas capabilities: /commerce-support#payments. Provide payment approach and capability/error fields; keep unavailable actions disabled.
- Lab availability or a production launch test: /commerce-support#testing. Wait for an agreed plan and, for a lab, its assigned URL and key. No automatic sandbox.
- Higher limits or unresolved payments/orders/redemptions: /commerce-support#operations. Include expected traffic/date or order/redemption/transaction IDs as relevant. Honor Retry-After; don't pay again if a payment may already have moved funds.

The fragments above are under https://www.dyli.io/docs/api. Support requests are not approval and do not enable a capability by themselves.

## Artwork without stretching or cropping

Guide: https://www.dyli.io/docs/api/commerce-artwork. For a pulled item, use rewards[i].product and the first non-empty image_url, image, or images[0] URL. Keep the full API asset inside a consistent aspect-ratio frame; use object-fit:contain, object-position:center and modest padding. Do not use cover or stretch width/height independently. Don't apply a blanket zoom to all product shapes. Use a neutral placeholder if artwork is missing. With Next Image fill, use a positioned parent and responsive sizes. Animations must not change the server's pull result.

## Integration boundary

An integrator provides:

- a backend that can keep a DYLI API key secret and make HTTPS requests;
- a stable external customer ID and an Abstract EVM wallet for each customer receiving an asset;
- durable storage for its own customers, carts, idempotency keys, quote/order references, and processed event IDs;
- either a normal DYLI card checkout UI, a customer USDC wallet, or a partner treasury wallet.

An integrator does **not** provision Supabase, access the DYLI database, run a DYLI fulfillment worker, hold DYLI Stripe credentials, or index payment chains. DYLI owns catalog normalization, pricing, inventory claims, card payment, onchain payment verification, replay protection, order state, and fulfillment.

## Authentication and required headers

Every request uses only its app-bound Commerce API key:

```http
x-api-key: dyli_live_...
```

Bearer authentication is also accepted:

```http
Authorization: Bearer dyli_live_...
```

- Keep the key in server-side code.
- Credentials are accepted only in headers, never in query parameters or request bodies.
- Every Commerce request, including GET, requires a dedicated key_type=commerce key bound to a registered application, and an active DYLI Pro membership for its owner.
- Read/legacy keys cannot access Commerce even if they contain commerce/write scope strings. Rotation preserves key type and application.
- DYLI resolves the application from the key. GET /bootstrap returns partner.slug and configuration; no slug environment variable or preliminary lookup is needed for other endpoints. Older clients may send x-partner-slug as an optional assertion; mismatches return commerce_app_mismatch. A key cannot select another app, including one under the same owner. Missing application bindings never create a default account or fall back to a key label.

### Self-serve application onboarding

1. Sign in at https://www.dyli.io/requestapi with an email on your DYLI account.
2. Choose free Read API or Pro-only Commerce API. Pro is required for the developer, not end customers.
3. Register a globally unique company name and app slug, HTTPS checkout-return domain, use case, auth/wallet/payment choices, and app type. Names are case/Unicode/whitespace-normalized; registration is not legal name verification.
4. Receive the secret once and download the tailored backend starter. The slug is discovered from the key. Support is notified through a durable email queue. Select an existing app when creating another key for it.
5. Keep only DYLI_API_KEY on your server for the DYLI connection. The starter archive does not include the raw key. Your own authentication still needs its provider configuration.

Existing authentication is the default choice. Verify customer sessions on YOUR server using your provider, map them to stable customer IDs and trusted wallets, then call POST /customers. Partners with existing auth do not need Privy or a separate managed-login setup. Wallet signing and payment verification are still required.

If you use your own Privy app, keep its credentials on your server. For managed login, request activation through https://www.dyli.io/docs/api/commerce-support#managed-login. DYLI configures shared DYLI identity through a Privy client or an isolated provider app. Neither option creates a test environment or funds a wallet.

Three active keys per type and 25 registered apps per owner. Default limits: Read 30 requests/minute; Commerce separate 300 reads / 120 writes per minute. Operator overrides may differ. Pro expiration stops Commerce requests; the same enabled key can still access public Read endpoints at free limits. No automatic legacy key upgrade.

## Integration rules

- Checkout contains one product: gacha Boxes permit quantity `1–10`; other quote types require quantity `1`.
- A catalog row's `purchase.quote_item` is the purchase reference. Preserve its identifiers/type; Box quantity can be selected up to the advertised maximum of 10.
- The API, not the client, prices the order and supplies current payment recipients/token addresses.
- Quotes normally expire after 10 minutes. A Stripe session may extend the quote to the Checkout expiry.
- A quote does not reserve inventory. Availability is checked again at payment finalization.
- Purchased assets go first to the customer's DYLI Collection on Abstract.
- Physical delivery is a later redemption, not part of purchase checkout.
- The delivery wallet and USDC payer may be different wallets.
- Never reuse an onchain payment transaction for two orders.
- Treat fulfillment as asynchronous; payment success is not fulfillment completion.

## Runtime readiness

Call the root before enabling checkout:

```bash
curl "https://www.dyli.io/api/commerce/v1" \
  -H "x-api-key: $DYLI_API_KEY"
```

The response contains partner configuration, catalog capability, write state, payment methods, and fulfillment readiness. Require:

```json
{
  "capabilities": {
    "writes": { "enabled": true, "status": "available" },
    "payments": ["usdc", "stripe_card"],
    "purchasing_ready": true,
    "fulfillment": { "ready": true, "live_ready": true, "mode": "executor" }
  },
  "payment": {
    "stripe_card": {
      "available": true,
      "hosted_checkout": true,
      "embedded_checkout": true,
      "webhook_configured": true
    }
  }
}
```

Only present methods in `capabilities.payments`. Only submit a purchase when `capabilities.purchasing_ready` is `true`.

## Box checkout and delivery

1. `GET /catalog/boxes` and choose a box where `purchase.supported` is `true`.
2. `PUT /customers/{externalCustomerId}` with the customer's Abstract wallet.
3. `POST /quotes` with that row's exact `purchase.quote_item`.
4. `POST /quotes/{quoteId}/stripe-checkout` with `ui_mode: "embedded"` and an approved `return_url`.
5. Render Stripe.js with the returned publishable key and client secret.
6. Confirm the checkout on your server and read the paid order.
7. Create the box play, send the returned buy transaction, read results, submit Vault/Sell decisions, then send and confirm finalization. See the Boxes section for the exact requests.
8. Read the completed order and updated holdings. Only confirmed settlement should update the customer's vault or sell-back balance.
9. Create a redemption when the customer wants to ship eligible items from their vault.

The starter uses embedded card checkout. Hosted Stripe Checkout and USDC payment are also supported. Webhooks are optional; you can poll order status instead.

## Complete endpoint map

| Method and path | Purpose |
| --- | --- |
| `GET /` | Partner configuration and live capabilities/readiness. |
| `GET /config` | Read partner display, branding, catalog, fee, and webhook configuration. |
| `PATCH /config` | Update partner configuration or rotate the partner webhook secret. |
| `GET /catalog/{surface}` | Read one partner-filtered catalog surface. |
| `GET /catalog/{surface}/{identifier}` | Read supported item detail or a collection by slug. |
| `GET /customers` | List synchronized partner customers. |
| `PUT /customers/{externalCustomerId}` | Create or update a customer idempotently. |
| `GET /customers/{externalCustomerId}` | Read one synchronized customer. |
| `POST /quotes` | Resolve current inventory and create a server-priced quote. |
| `GET /quotes/{quoteId}` | Read a quote. |
| `POST /quotes/{quoteId}/payment-authorization` | Build the exact USDC payer message to sign. |
| `POST /quotes/{quoteId}/stripe-checkout` | Create hosted or embedded Stripe Checkout. |
| `GET /stripe-checkouts/{paymentSessionId}` | Read checkout/payment-session state. |
| `GET /stripe-checkouts/resolve?stripe_session_id={CHECKOUT_SESSION_ID}` | Recover the Commerce payment-session ID from Stripe's hosted return parameter. |
| `POST /stripe-checkouts/{paymentSessionId}/confirm` | Retrieve and verify Stripe payment, then converge on one order. |
| `POST /stripe-checkouts/{paymentSessionId}/cancel` | Expire an unpaid Stripe checkout before clearing local recovery; not a refund. |
| `POST /orders` | Verify USDC transaction and payer authorization; create the paid order. |
| `GET /orders` | List orders, optionally by external customer. |
| `GET /orders/{orderId}` | Read one order and fulfillment state. |
| `POST /box-plays` | Start the customer-wallet Box play for a paid Box order. |
| `GET /box-plays` | List a customer's Box plays for recovery. |
| `GET /box-plays/{boxPlayId}` | Read Box play state. |
| `POST /box-plays/{boxPlayId}/results` | Verify the gacha buy receipt and reveal the batch. Poll readiness before deciding. |
| `POST /box-plays/{boxPlayId}/commit` | Deprecated alias of results; no automatic claim. |
| `POST /box-plays/{boxPlayId}/decision` | Choose `claim` or `sell_back` and prepare the matching finalization transaction. |
| `POST /box-plays/{boxPlayId}/finalize` | Verify finalization and complete the Box claim or sell back. |
| `POST /redemptions/address-validation` | Normalize and validate a physical destination. |
| `POST /redemptions` | Verify Collection ownership and quote shipping. |
| `GET /redemptions` | List redemptions with `external_customer_id`, `limit`, and `offset` filters. |
| `GET /redemptions/{redemptionId}` | Read redemption state. |
| `POST /redemptions/{redemptionId}/prepare` | Reprice, select rates, and return a customer-wallet transaction. |
| `POST /redemptions/{redemptionId}/confirm` | Verify the customer transaction and create fulfillment records. |
| `GET /events` | Reconcile order events and webhook delivery state. |

## Partner configuration

`GET /config` and `PATCH /config` operate on:

```json
{
  "display_name": "Example Store",
  "branding": {
    "name": "Example Store",
    "logo_url": "https://store.example/logo.png",
    "support_url": "https://store.example/support",
    "primary_color": "#101010",
    "accent_color": "#ffffff"
  },
  "catalog_rules": {},
  "fee_rules": {
    "enabled": true,
    "type": "percent",
    "percent": 5,
    "fixed_amount": 0,
    "minimum": null,
    "maximum": null,
    "label": "Service fee"
  },
  "webhook_url": "https://store.example/webhooks/dyli",
  "rotate_webhook_secret": false
}
```

- Fee type is `percent`, `fixed`, or `fixed_plus_percent`.
- Zero partner fees: `PATCH /config` with `{"fee_rules":{"enabled":false}}`. An enabled fixed fee of 0 also works with a null/zero minimum. Fixed fees are per order unless `per_unit:true`; then fixed_amount multiplies box quantity. Percentages use subtotal. Render the quote's `partner_fee_cents`; never impose a hardcoded frontend minimum. Box price, processing, gas and shipping remain separate.
- A trusted partner backend can pass `fee_rules` to `POST /quotes` to override that quote only. Rules are snapshotted and bound to idempotency. Never accept them from a shopper. `fee_quantity` describes multiplied fixed fees; Stripe and USDC collect the whole total.
- Starter server env: `INCLUDED_IDS=12997,12998` limits displayed/quotable boxes; omit for all permitted. Choose `FLAT_FEE=2` ($2 per box) OR `PERCENT_FEE=5` (5% of subtotal). One set to 0 explicitly disables added fees; neither set inherits account rules. Restart/redeploy after changes. These are server-only variables.
- Commerce key owners receive 10% of net settled box profit: base sales minus cash buybacks, kept-item costs, and refunds; losses offset gains before the share is floored at zero. Kept-item costs use positive saved dabble_cost, otherwise product price, rounded to cents. Unresolved kept-item costs keep earnings pending. Collected added fees belong to the owner separately as Your fee. Pending/unsettled, refunded/cancelled/failed, test and complimentary purchases earn nothing; settlement refunds hold added fees for reconciliation.
- `GET /analytics?offset=0&limit=25` is a private app-scoped report: summary, boxes, orders, applications, pagination, share_percent (10), as_of. Summary monetary fields: sales, buybacks, item_costs, refunds, profit, profit_share, your_fee, earnings; count fields: orders, completed_orders, boxes_opened, buyback_count. Totals are unpaginated. Do not expose it to shoppers. Profile → Storefront → Commerce aggregates the owner's apps and retained key history. Earnings enter the existing verified Claim flow and withdrawal accounting; the customer gets the product, the key owner gets Commerce earnings. Per-app/box share is not a separate claimable balance.
- Setting a webhook for the first time or sending `rotate_webhook_secret: true` returns a `webhook_secret` once. Store it immediately.
- Webhook URLs must be public HTTPS endpoints; local/private/reserved targets and credential-bearing URLs are rejected.

## Catalog

Use `GET /catalog/boxes` for the current Commerce experience. The wider catalog includes these read surfaces for configured apps:

`explore`, `listings`, `secondary-listings`, `boxes`, `redemptions`, `ebay`, `collections`, `fair-drops`, `digital-packs`, `search`, and `options`.

`redemptions` requires `product_id` and returns public redemption activity for that product.

Purchasable rows include:

```json
{
  "purchase": {
    "supported": true,
    "action": "buy",
    "reason": null,
    "quote_item": {
      "type": "box",
      "box_id": 12997,
      "quantity": 1
    }
  }
}
```

The box quote item is:

```json
{ "type": "box", "box_id": 12997, "quantity": 1 }
```

The schema also documents the following shapes for other integrations. For peer
marketplace purchases use /community/actions; eBay checkout is not included:

```json
{ "type": "listing", "listing_id": "primary:12345", "quantity": 1 }
```

```json
{
  "type": "secondary-listing",
  "listing_id": "...",
  "product_id": 12345,
  "token_id": "67890",
  "quantity": 1
}
```

```json
{ "type": "ebay", "item_id": "v1|123456789|0", "quantity": 1 }
```

Discovery-only rows may have `purchase.supported: false` with an explanatory `reason`. Never construct a listing ID or infer purchasability from price alone.

## Customer identity and delivery wallet

Your application owns authentication and `external_customer_id`. DYLI uses the customer's Abstract wallet as the asset destination.

DYLI links that wallet to an existing profile or creates one with a unique
generated username, such as `collector_4821`. Orders and pulls remain attached to
that profile. Existing usernames and privacy settings are preserved. Contact
details are not used to generate public usernames. This does not create a DYLI
login: matching emails or partner Privy IDs do not authorize access to a DYLI
account or wallet.
If a short handle is taken, DYLI can add a word, such as `mint_ripper_4821`,
while keeping a four-digit suffix.

```bash
curl -X PUT \
  "https://www.dyli.io/api/commerce/v1/customers/customer_123" \
  -H "x-api-key: $DYLI_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "wallet_address": "0xCUSTOMER_ABSTRACT_WALLET",
    "wallet_chain": "abstract",
    "email": "buyer@example.com",
    "name": "Example Buyer",
    "partner_auth": {
      "provider": "your-auth-system",
      "subject": "auth-user-123"
    }
  }'
```

- Treat `external_customer_id` as permanent.
- Wallet addresses must be Abstract-compatible EVM addresses.
- Within one partner namespace, a wallet or `(partner_auth.provider, subject)` cannot belong to two external customers.
- `customer.wallet_address` is the asset destination.
- `payment.payer` is the USDC funding/authorizing wallet and may be different.

## Quotes and idempotency

Every create mutation needs a stable `Idempotency-Key`. Repeating the same key and same body returns the original result. Reusing the key with different input returns `409 idempotency_conflict`.

```bash
curl -X POST "https://www.dyli.io/api/commerce/v1/quotes" \
  -H "x-api-key: $DYLI_API_KEY" \
  -H "Idempotency-Key: quote_customer_123_cart_456" \
  -H "content-type: application/json" \
  -d '{
    "external_customer_id": "customer_123",
    "items": [
      { "type": "box", "box_id": 12997, "quantity": 1 }
    ]
  }'
```

The response contains:

- `quote.id`, customer ID, currency, resolved item, status, and expiry;
- `price_breakdown.subtotal_cents`, partner fee, total cents, decimal values, currency, and fee label;
- `payment.amount`, `currency: "USDC"`, `supported_chains`, `recipients`, and `tokens`.

Use cents for exact arithmetic. Do not calculate a payable total from catalog data.

## Storefront request protection

POST /storefront/limits uses the server Commerce key and body {budget, subject_hash}.
subject_hash is a 64-character lowercase HMAC-SHA-256 hex digest computed on the
partner server with its key; no raw IPs/customer IDs. Supported budgets per 60s:
client-read 90, client-write 40, customer 90, upstream-read 220, upstream-write 80.
The server forces a single subject for upstream budgets. Protection checks use a
separate lane capped at 1,800/min, not the regular Commerce read/write quota.
200 returns allowed:true; 429 returns allowed:false and retry_after_seconds plus
Retry-After. 503 means unavailable; fail closed. DYLI owns the shared counters;
integrators do not receive Supabase credentials. Use authenticated customer IDs,
trusted proxy IPs, and count actual upstream fan-out calls. WAF and monitoring at
the storefront host are still needed. This endpoint never creates payments/orders.

## Payment choice 1: hosted or embedded Stripe

DYLI owns the Stripe account, Session/PaymentIntent, webhook, verification, and order finalization. The partner does not need Stripe credentials or a Stripe webhook.

Create hosted Checkout:

```bash
curl -X POST \
  "https://www.dyli.io/api/commerce/v1/quotes/$QUOTE_ID/stripe-checkout" \
  -H "x-api-key: $DYLI_API_KEY" \
  -H "Idempotency-Key: stripe_customer_123_checkout_456" \
  -H "content-type: application/json" \
  -d '{
    "external_customer_id": "customer_123",
    "external_order_id": "your-order-456",
    "customer": {
      "external_customer_id": "customer_123",
      "wallet_address": "0xCUSTOMER_ABSTRACT_WALLET",
      "email": "buyer@example.com"
    },
    "fulfillment": { "mode": "vault" },
    "ui_mode": "hosted",
    "success_url": "https://store.example/checkout/success?session_id={CHECKOUT_SESSION_ID}",
    "cancel_url": "https://store.example/checkout/cancelled"
  }'
```

Redirect to `checkout.checkout_url`.

Return URLs may use registered HTTPS domains or exact loopback hosts (localhost, 127.0.0.1, [::1], including development ports). Use the customer's current page, validated server-side; never arbitrary Host headers. Loopback navigation does not enable test payments. Register local origins with the login provider separately.

For embedded Checkout, send `ui_mode: "embedded"` plus an HTTPS `return_url`. Render Stripe.js with the returned `checkout.publishable_key` and `checkout.client_secret`. These are checkout-scoped client values, not DYLI server credentials.

DYLI adds a card-processing fee. Use the exact quote, card-fee, and total cents returned by the API rather than hardcoding a rate.

Hosted and embedded Checkout include promotion-code entry. Codes are configured in DYLI's Stripe account and verified by Stripe, not supplied as client-side discounts. Confirm checkout normally, including 100%-off orders without a PaymentIntent. On a discounted order, price_breakdown.total_cents is actual cash paid; stripe_discount_cents records the discount, split into stripe_item_discount_cents, stripe_partner_fee_discount_cents and stripe_card_fee_discount_cents. Original item/fee prices remain in the breakdown. Earnings use sales and fees after discounts; USDC still requires the exact quote amount.

Stripe normally finalizes through DYLI's webhook. On the return page, your server may also call:

```text
POST /stripe-checkouts/{paymentSessionId}/confirm
POST /stripe-checkouts/{paymentSessionId}/cancel
GET  /stripe-checkouts/{paymentSessionId}
GET  /stripe-checkouts/resolve?stripe_session_id={CHECKOUT_SESSION_ID}
```

Use `resolve` when browser storage did not retain the Commerce payment-session ID. Confirmation paths retrieve Stripe state and verify session metadata, PaymentIntent, live/test provenance, currency, and exact amount. A browser redirect is never payment evidence. Webhook and explicit confirmation converge idempotently on one order.

To abandon an unpaid checkout, verify customer ownership and call cancel. Clear local recovery only after cancelled:true. DYLI verifies Stripe is unpaid and expired, refusing completed/processing payments (409 checkout_not_cancellable). Network uncertainty must leave recovery saved. Cancellation is repeatable and never refunds or discards a paid order.

## Payment choice 2: customer or partner-treasury USDC

Supported chains are currently Abstract, Avalanche, Ethereum, Base, Arbitrum, Optimism, Polygon, HyperEVM, Monad, and Solana. Always use the quote response's current chain, recipient, and token values.

The funding wallet must:

1. send the **exact** quote total in native USDC to the returned recipient;
2. wait for the required confirmation/finality;
3. request the authorization message for that exact payment and customer intent;
4. sign the message;
5. create the order with the same quote, customer, fulfillment, chain, transaction, and payer.

Authorization request:

```bash
curl -X POST \
  "https://www.dyli.io/api/commerce/v1/quotes/$QUOTE_ID/payment-authorization" \
  -H "x-api-key: $DYLI_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "external_customer_id": "customer_123",
    "customer": {
      "external_customer_id": "customer_123",
      "wallet_address": "0xCUSTOMER_ABSTRACT_WALLET"
    },
    "fulfillment": { "mode": "vault" },
    "payment": {
      "chain": "base",
      "tx_hash": "0xPAYMENT_TRANSACTION_HASH",
      "payer": "0xPAYER_WALLET"
    }
  }'
```

Sign `authorization.message` exactly:

- EVM externally owned account: EIP-191 `personal_sign`.
- EVM smart wallet: EIP-1271 contract signature.
- Solana: Ed25519 `signMessage`, encoded as base58 or base64.

Create the order:

```bash
curl -X POST "https://www.dyli.io/api/commerce/v1/orders" \
  -H "x-api-key: $DYLI_API_KEY" \
  -H "Idempotency-Key: order_customer_123_checkout_456" \
  -H "content-type: application/json" \
  -d '{
    "quote_id": "4b975557-7d53-4d52-b240-40ceac59eead",
    "external_order_id": "your-order-456",
    "external_customer_id": "customer_123",
    "customer": {
      "external_customer_id": "customer_123",
      "wallet_address": "0xCUSTOMER_ABSTRACT_WALLET",
      "email": "buyer@example.com"
    },
    "fulfillment": { "mode": "vault" },
    "payment": {
      "chain": "base",
      "tx_hash": "0xPAYMENT_TRANSACTION_HASH",
      "payer": "0xPAYER_WALLET",
      "payer_signature": "0xSIGNED_AUTHORIZATION_MESSAGE"
    }
  }'
```

DYLI verifies successful exact-amount USDC transfer evidence, sender, recipient, token, confirmations, quote binding, intent signature, transaction reuse, and inventory availability.

### Accept other crypto

The customer's payment currency and DYLI's settlement currency can differ. A custom checkout can use a swap or bridge provider such as Relay (https://docs.relay.link/use-cases/cross-chain-swaps) for supported assets and routes. This conversion is an integrator-built feature, not included in the starter.

For Abstract settlement, first deliver the converted USDC to the customer's Abstract wallet or a partner-controlled treasury wallet. Wait for funding, then send the exact total of an open DYLI quote from that wallet to `payment.recipients.abstract`, using `payment.tokens.abstract`. Request and sign the DYLI payment authorization with that same payer wallet. Submit the final Abstract USDC transfer hash, payer and signature, not the source-chain transaction or swap receipt.

Allow for conversion fees/slippage. If the quote expires during conversion, obtain a fresh quote and confirm price changes before paying DYLI. Persist conversion/payment references and resume interrupted work without duplicate charges. After sending DYLI payment, recover the existing payment/order instead of sending again.

The signed payer must match the USDC transfer sender. A bridge payout directly to DYLI may name a relayer/contract as sender, which the customer cannot sign for. Fund a wallet you can sign with first; don't treat a swap success callback as settlement evidence.

### Partner treasury or pooled-wallet model

A partner may charge or debit customers in its own system and settle every DYLI quote from a partner-controlled treasury wallet:

- the customer pays the partner through the partner's chosen method;
- the treasury pays DYLI's exact per-quote amount and signs the DYLI authorization;
- DYLI verifies the submitted payment transaction and authorization;
- DYLI delivers the asset directly to `customer.wallet_address`, not the treasury.

This is not a prefunded balance custodied by DYLI. The partner owns its customer billing, internal balance/ledger, refunds, treasury funding, signing, and reconciliation. Use one treasury transaction per DYLI order.

## Orders and fulfillment

```text
GET /orders/{orderId}
GET /orders?external_customer_id=customer_123&limit=25&offset=0
```

Important order fields:

- `id`, `quote_id`, `external_order_id`, `external_customer_id`.
- `customer` and `delivery.wallet_address`.
- `price_breakdown`, `currency`, and verified `payment` context.
- `status`: normally `paid`, `processing`, `completed`, or `failed`.
- `fulfillment_status`: queue/executor state.
- `dyli_order_ids`: downstream DYLI fulfillment records.
- `error`: structured action/retry information.
- created, updated, and completed timestamps.

Wait for `fulfillment_status: "completed"` or `order.completed`; never mark fulfillment complete merely because payment succeeded.

### DYLI order, Collection, activity, and customer identity

The Commerce order and the normal DYLI order are separate records:

1. Payment creates the partner-scoped Commerce order.
2. Fulfillment creates one or more normal DYLI `orders` rows.
3. DYLI validates that every returned `dyli_order_id` exists, is successful and activity-ready, and belongs to the linked customer before marking the Commerce order completed.

The normal orders feed the customer's Collection and DYLI sales/activity. `dyli_order_ids` is the link from the Commerce order to those records.

Customer identity is wallet-based:

- An Abstract wallet already used by a DYLI customer retains its existing asset ownership.
- A new wallet gets a DYLI profile with a unique generated username that can own orders and Collection assets. This does not create a DYLI login session or link the customer's other logins.
- Different partners using the same wallet resolve to the same DYLI user while their external customer IDs and Commerce histories remain partner-scoped.

## Box orders

One paid Box order opens **1–10 units of the same live gacha Box**. Other checkout types still accept quantity 1. Read `capabilities.box_play.ready`, `max_quantity`, `chain_id`, and the catalog row's `purchase.supported` before enabling payment. Retired flip/dabble contracts and restocking Boxes are not purchasable through this flow.

The partner's payment is verified once. The customer's two gacha transactions have zero native value and **do not debit the Box price again**. Gas still needs sponsorship or native funds. DYLI's canonical gacha engine draws the rewards, validates receipts and settles real holdings; partners must not generate outcomes or infer ownership from an animation.

| Step | Request / action | Result |
| --- | --- | --- |
| Purchase | Quote `{ "type": "box", "box_id": 123, "quantity": 3 }`, then complete Stripe or USDC payment | One paid Commerce order |
| Prepare | `POST /box-plays` with `{ "order_id": "…" }` | `box_play.id`, `quantity`, `opening_reference`, and `transaction` (`phase: "buy"`) |
| Buy | Customer wallet sends the returned transaction, on its `chain_id` | Persist the buy transaction hash immediately |
| Draw | `POST /box-plays/{id}/results` with `{ "tx_hash": "0x…" }` | Poll when `ready: false`, respecting `retry_after_ms`; when ready, show ordered `box_play.rewards[]` |
| Choose | `POST /box-plays/{id}/decision` with `{ "decisions": ["claim", "sell_back", "claim"] }` | Exactly one choice per reward; returns finalize transaction when `ready: true` |
| Settle | Send that transaction, then `POST /box-plays/{id}/finalize` with `{ "tx_hash": "0x…" }` | Receipt-verified `completed`, real `dyli_order_ids`, and updated holdings / buyback balance |

Each reward includes `index`, `product_id`, `token_id`, `product`, `rarity`, and `buyback_amount` in USD. Offer sell-back only for a positive buyback. `claim` is the API value for “Vault.” Card-funded buybacks retain DYLI's card-risk withdrawal holds. A partner-treasury-funded purchase is USDC funded at DYLI, even if the partner independently accepted a card.

Claims and buyback USDC settle to the registered customer wallet. They do not automatically credit a partner treasury or a separate app ledger. Reconcile confirmed settlement before updating your own balance; a reveal or a selected Sell button is not payment evidence.

Commerce `sell_back` applies during opening settlement. A finalized `claim` cannot be changed into a sellback; use the separate post-vault DYLI offer acceptance flow below. The public Read API's `/offers` stays read-only and does not generate customer-specific buybacks. Eligible paid gacha claims retain their original opening buyback until 48 hours after the claim is recorded, while the customer owns the item. Loading offers does not reset that deadline. Standing DYLI offers use separate pricing/eligibility and expirations (commonly 90 days), not guaranteed automatic renewal.

`POST /box-plays` is naturally idempotent by paid `order_id`: one order authorizes one batch. Never create another paid order just because an opening timed out. `409 box_play_busy` means another request holds the order lock; retry with backoff. Both `results` and `decision` can return HTTP 200 with `ready: false`, `restart: true`, and `recovery.next_action` when the block window expires. Follow that action: call create again with the **same paid order**. Do not keep polling the old decision. A restart returns a new `opening_reference`; clear old buy/finalize hashes and choices when it changes, submit the returned prepaid buy transaction, then request results again. This restarts the opening, not the payment (`recovery.requires_new_payment: false`). GET by play ID also exposes recovery instructions. If `recovery.code` is `results_required`, confirm the existing buy using the supplied results action instead of restarting it.

Persist the order/play IDs, opening reference, decisions, and both transaction hashes before subsequent network requests. Use `GET /box-plays?external_customer_id=...` and `GET /orders?external_customer_id=...&external_order_id=...` for recovery on another device. Repeating `POST /box-plays` resumes the existing purchase. If finalize was already broadcast, submit its hash to `/finalize`; do not send a second transaction. `/decision` can also accept `tx_hash` to recover a known finalize receipt.

The old `/commit` path is an alias of `/results` only. There is **no automatic claim**, no `defer_decision` behavior, and no compatibility with retired contract calldata. Existing legacy plays need operator reconciliation, not replay through gacha.

Never rebuild calldata, replace the `from` wallet, or change target/value. Validate network and wallet, then treat each transaction as an opaque instruction. `GET` reads do not prepare signatures or send transactions.

### Post-vault DYLI offer acceptance

Check `capabilities.post_vault_offers.ready`. Keep the Commerce key server-side and authenticate your customer. Only DYLI buyback offers are supported, not peer offers. Paths below use `/api/commerce/v1`.

1. `POST /offers/query` with `{ "external_customer_id": "customer-123", "token_id": "456" }`. This verifies ownership in the synchronized wallet and invokes customer-specific DYLI discovery. Discovery may create a bid, so it requires write scope and uses the write rate limit. It does not sell the item. Upstream failures are errors, not empty results.
2. Choose from `offers[]`: `id`, `token_id`, `price` (USDC units), `amount` (integer string in six-decimal USDC base units), `currency`, `maker_wallet`, `expiration` (Unix seconds), `expires_at` (ISO), `quantity: 1`, `type` (`claim_buyback` or `standing_buyback`), `eligibility: requires_preparation`.
3. `POST /offer-acceptances` with `Idempotency-Key` and `{ "external_customer_id": "customer-123", "token_id": "456", "offer_id": "789", "expected_amount": "75000000" }`. Copy the offer's `id` and `amount`; preparation revalidates price, ownership, on-chain bid, deadline and native DYLI restrictions. Returns `acceptance` with `eligibility: eligible` inside its `offer`.
4. If `acceptance.approval_transaction` is non-null, the customer signs it and waits for success. This grants ERC-1155 operator approval to the DYLI marketplace; disclose that permission. Then sign `acceptance.transaction` exactly as returned (`acceptBid` on Abstract). Persist ID/hash immediately. The customer needs gas or wallet sponsorship. Preparation does not extend expiration or reserve a public bid; the sale must be mined before `expires_at`.
5. `POST /offer-acceptances/{id}/confirm` with `{ "tx_hash": "0x..." }`. Receipt validation checks seller, buyer, bid, token, quantity, amount, target, calldata and block time, then calls the same DYLI settlement as the native app. Eligible box restocking, owner-box reservations/holds, applicable adjustment accounting and withdrawal holds retain their normal rules. Proceeds go through the marketplace to the customer; partner fee rules and payout recipient overrides are not supported and are rejected.

Recover with `GET /offer-acceptances/{id}` or `GET /offer-acceptances?external_customer_id=customer-123&limit=25&offset=0`. States: `prepared`, `expired` (unsubmitted deadline passed), `processing`, `requires_action`, `completed`. Completed results include `result.dyli_order_id` and `tx_hash`; private DYLI user IDs and API key IDs are not exposed. No offer-specific webhook is emitted.

Retry create with the same idempotency key/input. Changed input returns `409 idempotency_conflict`; duplicate offer creation returns `409 acceptance_exists` with the existing `acceptance_id`. `409 offer_busy` is retryable. Confirm an already-broadcast hash even after the offer deadline or loss of ownership: the mined block determines eligibility. `503 acceptance_reconciliation_required` means the sale succeeded on chain but recording needs retry; call confirm again with the same hash, never submit a second sale. Completed confirmations are idempotent and reject different hashes. Expired prepared records omit transaction instructions but still accept timely mined receipts.

### Your own domain or mobile checkout

Privy is a starter choice, not a Commerce requirement. Your backend authenticates your user and binds their stable external ID to their Abstract wallet. It keeps the DYLI key; the browser or mobile app never receives it. A native app can use its own wallet/signing SDK and render only its partner-filtered Boxes.

Use DYLI-hosted Stripe for the smallest payment integration (Apple Pay availability is controlled by Stripe/device eligibility). To use your own Apple Pay, card processor, or app balance, verify/debit it on **your backend**, then use the partner-treasury USDC flow above. A client `paid: true`, Apple Pay token, arbitrary provider receipt, or unverified webhook is not DYLI settlement. Your backend owns payment verification, refunds, disputes, idempotency and reconciliation. Inventory can disappear after payment because a quote is not a reservation; reconcile failed paid orders rather than charging again.

To build an experience focused on selected boxes, set `catalog_rules.allowed_box_ids` to their gacha product IDs and disable other catalog surfaces in `PATCH /config`. Turn off `community_settings.marketplace_enabled` and `community_settings.trading_enabled` if you do not want those features. The example's `NEXT_PUBLIC_BOXES_ONLY=true` limits primary-catalog navigation; it does not override enabled marketplace or trading capabilities. DYLI checks catalog rules again when quoting, so UI filtering alone is not access control. Test your settings before publishing your domain.

## Physical redemption

Purchases enter Collection first. To ship later:

Create the quote with Collection token IDs and the normalized address shape:

```json
{
  "external_customer_id": "customer_123",
  "items": [{ "token_id": "67890", "quantity": 1 }],
  "address": {
    "name": "Example Buyer",
    "email": "buyer@example.com",
    "phone": "+1 555 555 0100",
    "address_line_1": "123 Main Street",
    "address_line_2": null,
    "city": "New York",
    "state": "NY",
    "postal_code": "10001",
    "country_alpha2": "US"
  }
}
```

1. `POST /redemptions/address-validation` to normalize the destination.
2. `POST /redemptions` to verify Collection ownership and quote current rates.
3. `POST /redemptions/{id}/prepare` to refresh pricing, submit rate selections and item-specific responses, and receive an Abstract transaction.
4. The customer submits that exact transaction from `redemption.wallet_address`.
5. `POST /redemptions/{id}/confirm` verifies the transaction and creates fulfillment records.

Shipping payment is USDC on Abstract; check balance and allowance using returned payment.token_address, payment.spender and payment.amount_cents. No Stripe shipping-checkout endpoint exists in this flow. Poll GET /redemptions/{id} or the customer's GET /redemptions list for refreshed result.orders: shipment_id, carrier, tracking_number, tracking_url, shipment_status, delivered, tracking_unavailable, and tracking_updated_at when available. Carrier data can be cached up to 15 minutes. Redemption status completed is not parcel delivery: delivered is true only when confirmed by the carrier, and null when unknown. On provider failure, known tracking remains available with tracking_unavailable:true. Manual shipments show pending or tracking_available as staff add tracking.

For Easyship shipments, tracking_url opens the shipment's tracking page even before a carrier tracking_number is assigned. Use shipment_id (for example ESUS360467167) as the link text and show the carrier number separately when available. Multiple items can share one shipment_id; show one tracking entry per package. For split fulfillment, shipments contains each package's tracking details. Only label the whole request delivered when all packages are delivered.

Select shipping by `courier_id` when available, otherwise `id`. DYLI preserves the selected service across repricing; `shipping_option_unavailable` means the customer must choose a fresh rate. Quotes last 10 minutes; `prepare` returns a fresh payment expiry and total to show before payment. Refresh unpaid expired quotes with the same items/address and a new idempotency key. Never replace a shipment with a transaction hash or uncertain submission. Read its state and confirm the original transaction, even after expiry.

Redemption quotes expire and are idempotent. Do not rebuild the customer transaction.

## Signed partner events

Partner webhooks are optional; polling orders works. For push delivery, configure an HTTPS URL through `PATCH /config` and store the one-time secret.

Headers:

```http
x-dyli-event-id: EVENT_UUID
x-dyli-event-type: order.completed
x-dyli-timestamp: 1786720800
x-dyli-signature: v1=HEX_HMAC_SHA256
```

Verification:

```text
expected = HMAC-SHA256(webhook_secret, timestamp + "." + raw_request_body)
```

- Compare signatures in constant time.
- Reject stale timestamps.
- Deduplicate by `x-dyli-event-id` before changing state.
- Return `2xx` quickly and process asynchronously.
- Delivery is at least once; event consumers must be idempotent.
- Reconcile missed delivery with `GET /events`.

## Errors, retries, and rate limits

Errors use:

```json
{
  "error": "payment_pending",
  "message": "Payment needs more confirmations",
  "details": { "confirmations": 0, "required_confirmations": 1 },
  "request_id": "7473e546-60b3-4d30-8520-4accbf5a57df"
}
```

- Persist each idempotency key with the exact body until terminal state.
- Retry `409 payment_pending` after chain confirmation.
- Do not retry `409 idempotency_conflict` with a changed body under the same key.
- Retry `429` after `Retry-After`.
- Retry `502` and `503` with capped exponential backoff and jitter.
- `504 upstream_timeout` means an internal service exceeded its response deadline, not that the request was invalid. For reads, wait for `Retry-After` and retry with capped exponential backoff and jitter. For writes, check the operation's status before retrying and preserve its original idempotency key and body.
- Do not automatically retry validation/authentication failures.
- Log `x-request-id` and the response `request_id`.
- Treat transaction, external-order, and inventory-claim reuse conflicts as terminal for that attempted payment/order identity.

## Test before launch

The starter uses production by default, including on localhost. If DYLI supplies a separate lab, use its assigned URL and key together. The lab keeps payments, inventory and fulfillment separate from production; it isn't created automatically when you register an app.

Recommended sequence:

1. Call readiness and validate this document's OpenAPI schema.
2. Read a purchasable box from GET /catalog/boxes.
3. Synchronize a test customer/delivery wallet.
4. Create a quote.
5. Complete card and balance checkout as separate purchases. Use Stripe test cards only in an assigned lab, never against production.
6. Complete the box opening and confirm Vault/Sell settlement. Check holdings and USDC, then redeem eligible items and verify the shipment records.
7. Test same-body idempotency and changed-body conflict.
8. Test duplicate webhook delivery.
9. If needed, test one-transaction-per-quote treasury USDC behavior.
10. Check production login, network, sponsorship and approved domains, then arrange a small production purchase with DYLI to verify the deployed flow.

Human-readable docs: `https://www.dyli.io/docs/api/commerce`  
Testing guide: `https://www.dyli.io/docs/api/commerce-testing`  
Read API reference: `https://www.dyli.io/docs/llms/read-api.md`  
3PL API reference: `https://www.dyli.io/docs/llms/3pl-api.md`
## Optional managed login

Managed login supports shared DYLI identity (identity_scope:dyli) through a domain-restricted Privy client, or isolated identity (identity_scope:partner) through a separate Privy app. Shared identity reuses the account and embedded wallet, but does not guarantee an existing browser session crosses domains. Request activation at https://www.dyli.io/docs/api/commerce-support#managed-login with your company, app slug, primary HTTPS origin and additional origins. After DYLI activates the app/client and approves its domains, the managed starter needs only DYLI_API_KEY for the production connection. The key identifies your app and its settings. A separate lab also needs its assigned URL. Keep all credentials on the server.

GET /bootstrap returns bootstrap_version:1, environment, app configuration, integration choices, auth, wallet chain/chain_id, and storefront.name plus storefront.community readiness and inclusion settings. The legacy storefront.boxes_only field describes the primary catalog, not marketplace or trading availability. Forward only the fields your frontend needs; keep API keys and configured webhook URLs on the backend. For your own login or Privy app, auth returns mode:existing or privy, available:true, verification:partner_server and session_endpoint:null. You still need to connect and test your auth adapter. Managed auth returns mode:dyli_managed, available, provider:privy, public app_id, client_id, identity_scope, allowed_origins, storefront_origin, session_endpoint:/auth/session, sponsor_transactions:false. Missing or invalid managed settings return auth.available:false. Check root readiness before checkout. Native Abstract paymaster settings are returned separately in wallet.gas_sponsorship.

The starter attaches the returned paymaster settings to the customer's exact-total USDC payment transfer and Box transactions. An accepted sponsored transaction does not require customer ETH for gas. Check wallet.gas_sponsorship.available before checkout; if false, stop the sponsored checkout and do not reuse cached paymaster settings. Use the matching network. Sponsorship is subject to the deployed paymaster's policy and funding. The current native paymaster has no per-key or per-wallet gas limits; Commerce API request limits apply separately. Estimate gas before submitting and handle sponsorship failures. Login-provider sponsorship is separate.

POST /auth/session requires the server API key plus x-customer-access-token containing the customer's Privy access token. Optional JSON wallet_address must be linked to the verified user. DYLI verifies the token against the operator-selected app and fetches provider-owned wallet records. It returns identity.userId, externalCustomerId, walletAddress, email and name. Derive customer sync and ownership checks from this server result, never browser IDs. 401 invalid token, 403 unlinked wallet, 409 wallet pending, 503 unavailable configuration/provider. Do not store/log tokens. Managed Stripe return URLs must use registered origins. Operator config does not itself update Privy's dashboard.

Your own authentication and verified treasury USDC settlement are also supported. Arbitrary third-party paid flags/receipts are not accepted. Disable the added fee with fee_rules.enabled:false; this doesn't waive processor fees, gas, shipping or box prices. Empty box filters mean unrestricted, not no boxes. Set explicit box IDs if you want to limit the storefront, and follow the launch checklist before inviting customers.

### Managed wallets with existing login

Existing login may use wallet_mode:managed after activation. Login remains auth.mode:existing and verification:partner_server; wallet.managed_auth supplies available, public app_id/client_id, allowed_origins and session_endpoint:/auth/session. Initialize the wallet provider with those public settings and connect your existing JWT login using Privy custom authentication. Your backend verifies its own customer session, then POST /auth/session with x-customer-access-token containing the Privy wallet access token and JSON external_customer_id equal to that verified external subject. DYLI requires exactly one provider-owned custom_auth record matching that subject, verifies the linked wallet, and returns the original external customer ID. Missing/ambiguous links or a different customer return 403. No caller-supplied ID establishes ownership. Never change an existing external customer ID to a Privy ID.
