Partner API integration guide

Use the Vylvet Partner API to sync stores, products, and orders with your system. This document is the single source of truth for integrating with Vylvet—everything you need is here.

You will: list stores and categories, read or sync products, create orders (either by redirecting customers to Vylvet-hosted Stripe Checkout or by reporting already-paid orders), and receive delivery updates via webhooks or polling.

Quick start

  1. Get your API key — Vylvet creates a partner client and gives you an API key once. Store it securely (e.g. environment variable).
  2. Call the API — Send requests to https://vylvet.co/api with header x-api-key: <your_key>.
  3. VerifyGET https://vylvet.co/api/v1/health returns your client ID and confirms the key works.
  4. Try live requests — Open the API Playground, paste your key, and exercise endpoints directly from your browser.
  5. Implement — Use this guide for auth, data formats, order flows, and each endpoint.

The Playground is for manual testing only. In production, call the Partner API from your backend, not directly from browser code.

Base URL

Use this URL for all requests. All routes live under /v1/.

EnvironmentURL
Production https://vylvet.co/api

Example: GET https://vylvet.co/api/v1/stores

Test Mode

Vylvet supports a full sandbox environment using Stripe test keys. No real money is charged. No real deliveries are dispatched. Everything else — store, products, pricing, order format, webhooks — is identical to production.

Option 1 — Test API key (recommended)

Ask your Vylvet admin contact to generate a test API key for your partner client. It starts with sk_test_. Using this key automatically routes all requests through Stripe test mode — no changes required anywhere else in your integration.

// Use your test key in the header — everything else stays the same
x-api-key: sk_test_your_test_key_here

Option 2 — dev flag per request

If you want to use your live key but have a specific request use test mode, add "dev": true to the request body of POST /v1/checkout/session or POST /v1/orders/report:

Request body
{
  "dev": true,
  "store_id": "your_store_id",
  "items": [{ "product_id": "abc", "quantity": 1 }],
  "success_url": "https://yourapp.com/success",
  "cancel_url": "https://yourapp.com/cancel"
}

Test Stripe card numbers

Card numberResult
4242 4242 4242 4242Payment succeeds
4000 0000 0000 0002Payment declined
4000 0025 0000 31553DS authentication required

Use any future expiry date (e.g. 12/34) and any 3-digit CVC.

What's different in test mode

  • Stripe uses test keys — payment intents are clearly marked in your Stripe test dashboard
  • Orders appear in Vylvet merchant CMS with a TEST badge and "dev": true
  • Uber Direct uses sandbox credentials — no real courier dispatched
  • Merchants can refund, cancel, and manage test orders from the Payments dashboard

Stripe webhook for test events

Register the webhook URL in your Stripe test dashboard (under Developers → Webhooks) pointing to:

https://us-central1-revero-beautygogo.cloudfunctions.net/cms-stripe_webhook

The endpoint verifies both test and live signatures automatically.

Authentication

Every request must include your partner API key in a header. You receive it once when your partner client is created; store it in a secure secret store and never expose it in frontend code or source control.

HeaderRequiredDescription
x-api-key Yes Your partner API key (e.g. sk_live_...). Sent on every request.
x-trace-id No Your correlation ID. If sent, we echo it in the response for debugging.
Idempotency-Key Yes for some writes Required for POST /v1/orders/report and POST /v1/inventory/adjustments. Recommended for POST /v1/checkout/session. Use a unique value per logical operation (e.g. your order ID) to avoid duplicates.

All responses include x-trace-id. Include it when contacting support.

Example: GET /v1/health

GET /v1/health HTTP/1.1
Host: vylvet.co
x-api-key: sk_live_your_key_here
x-trace-id: myapp-req-12345
Response (200)
{
  "ok": true,
  "status": "healthy",
  "partner_client_id": "client_abc",
  "timestamp": "2026-02-27T15:04:05.000Z",
  "trace_id": "ABC123XYZ"
}
TypeScript interface
interface HealthResponse {
  ok: boolean;
  status: string;
  partner_client_id: string;
  timestamp: string;  // ISO 8601 UTC
  trace_id: string;
}

Data formats

Consistent formats across the API.

Currency

All monetary amounts are in the smallest currency unit. The API uses USD; amounts are in cents (e.g. 1999 = $19.99). Product price, order item price, total, subtotal, tax_amount, delivery_fee are all in cents. There is no separate currency field; the system is single-currency (USD).

Timestamps

  • Numeric (API responses)created_on, updated_on, and updated_since (query) are Unix time in milliseconds (UTC). Example: 1709123456789.
  • Webhook payloads — The timestamp field in webhook JSON is ISO 8601 UTC (e.g. 2026-02-27T15:04:05.000Z).

Two order flows

There are two ways to create orders. Use the right one for how the customer pays.

flowchart LR subgraph Card["Card payment (Vylvet processes)"] A[POST /v1/checkout/session] --> B[Order pending_payment] B --> C[Redirect to Stripe] C --> D[Customer pays] D --> E[Webhook: order → processed] E --> F[Inventory deducted] F --> G[Delivery created] end subgraph Other["Already paid (e.g. cash)"] H[POST /v1/orders/report] --> I[Order processed] I --> J[Inventory deducted in same request] J --> K[Delivery created if delivery] end

Flow 1: Card payment — POST /v1/checkout/session

  • You send cart, delivery address, success_url, cancel_url.
  • We create an order in status pending_payment and a Stripe Checkout Session. We check product availability at this time; if insufficient we return 409.
  • You redirect the customer to the returned checkout_url. The customer pays on Stripe.
  • On payment success, our Stripe webhook sets the order to processed. Only then do we deduct inventory and create delivery. If the customer never pays, the order stays pending_payment and inventory is not deducted.

Flow 2: Already paid — POST /v1/orders/report

  • Use for orders already paid outside our system (e.g. cash, or payment taken by you under a separate agreement).
  • You send external_order_id, store_id, items (with price in cents), and optionally delivery_address.
  • We create the order in status processed and deduct inventory in a single transaction. If stock is insufficient we return 409 and do not create the order.
  • For delivery orders we then create the Uber delivery. You receive order_id and can poll or use webhooks for status.

Before creating any order, call POST /v1/products/availability with the cart to avoid 409. For checkout/session, availability is checked again when you create the session.

Order state machine

Order status values and how they relate. For partner Stripe flow, orders go pending_paymentprocessed on payment success (we do not use paid for partner orders). The paid status is used in the native app flow before moving to processed.

stateDiagram-v2 [*] --> pending_payment: checkout/session pending_payment --> processed: Stripe payment success pending_payment --> failed_payment: session creation failed [*] --> processed: orders/report processed --> shipped: fulfillment processed --> out_for_delivery: delivery picked up out_for_delivery --> delivered: delivery completed processed --> ready_for_pickup: pickup ready ready_for_pickup --> picked_up: customer picked up processed --> cancelled: cancelled delivered --> refunded: refunded processed --> refund_requested

Statuses: pending_payment, paid, processed, failed_payment, shipped, out_for_delivery, delivered, ready_for_pickup, picked_up, cancelled, refunded, refund_requested. Terminal states: delivered, picked_up, cancelled, refunded.

FromToTrigger
pending_paymentPOST /v1/checkout/session
processedPOST /v1/orders/report
pending_paymentprocessedStripe payment success (webhook)
pending_paymentfailed_paymentStripe session creation failed
processedout_for_deliveryDelivery picked up
out_for_deliverydeliveredDelivery completed
processedready_for_pickupPickup order ready
ready_for_pickuppicked_upCustomer collected
processedcancelledCancellation
deliveredrefundedRefund completed

Stores & categories

List stores and categories to map locations and taxonomy.

Store access: Your API key can be restricted to specific stores (configured in the Vylvet admin). When restricted, GET /v1/stores returns only those stores, and any request that uses a store_id (categories, products, checkout, orders, inventory) returns 403 Access denied to this store if the store is not allowed for your key.

GET /v1/stores

GET /v1/stores

Query: active (default true), limit (default 100, max 300).

Response (200)
{
  "ok": true,
  "count": 2,
  "stores": [
    {
      "id": "store_1",
      "name": "Main Store",
      "slug": "main-store",
      "active": true,
      "open": true,
      "city": "San Francisco",
      "state": "CA",
      "zip_code": "94102",
      "address": "123 Market St",
      "pickup_enabled": true,
      "vylvet_delivery_enabled": true,
      "updated_on": 1709123456789
    }
  ]
}
TypeScript interfaces
interface StoresResponse {
  ok: boolean;
  count: number;
  stores: Store[];
}

interface Store {
  id: string;
  name: string;
  slug: string;
  active: boolean;
  open: boolean;
  city: string;
  state: string;
  zip_code: string;
  address: string;
  pickup_enabled: boolean;
  vylvet_delivery_enabled: boolean;
  updated_on: number;  // Unix ms UTC
}

GET /v1/categories

GET /v1/categories

Query: store_id (optional), active (default true). When store_id is provided, the response also includes store_id.

Response (200)
{
  "ok": true,
  "count": 3,
  "categories": [
    { "id": "cat_1", "name": "Hair Care", "active": true }
  ]
}

When store_id query is set, the response also includes store_id.

TypeScript interfaces
interface CategoriesResponse {
  ok: boolean;
  count: number;
  categories: Category[];
  store_id?: string;  // present when store_id query param was provided
}

interface Category {
  id: string;
  name: string;
  active: boolean;
}

GET /v1/stores/:store_id/categories

GET /v1/stores/:store_id/categories

Categories that have products in this store. Response shape same as GET /v1/categories.

Products

Read and optionally sync the catalog. Check availability before creating orders.

GET /v1/products

GET /v1/products

Query: store_id, category_id, active (default true), available_only (default false), limit (default 100, max 300), updated_since (Unix ms), cursor (use next_cursor from previous response for pagination).

Response (200)
{
  "ok": true,
  "count": 10,
  "next_cursor": "1709123456789",
  "filters": { "store_id": "store_1" },
  "products": [
    {
      "id": "prod_1",
      "name": "Shampoo",
      "description": "Gentle formula",
      "store_id": "store_1",
      "category_id": "cat_1",
      "price": 1999,
      "sku": "SH-001",
      "active": true,
      "available_quantity": 50,
      "images": ["https://..."],
      "updated_on": 1709123456789
    }
  ]
}
TypeScript interfaces
interface ProductsResponse {
  ok: boolean;
  count: number;
  next_cursor?: string;
  filters?: Record<string, string>;
  products: Product[];
}

interface Product {
  id: string;
  name: string;
  description?: string;
  store_id: string;
  category_id?: string;
  price: number;           // cents USD
  sku?: string;
  active: boolean;
  available_quantity: number;
  featured_image?: string | null;
  images?: string[];
  updated_on: number;      // Unix ms UTC
}

GET /v1/products/:product_id

GET /v1/products/:product_id

Single product. Returns 404 if not found. Response is always { "ok": true, "product": Product } with shape matching Product above.

POST /v1/products/availability

POST /v1/products/availability

Call before creating an order to avoid 409. Pass an optional store_id to scope the check — any product whose store_id doesn't match will come back with wrong_store: true and available: false.

Request body
{
  "store_id": "store_1",            // optional — scope the check
  "items": [
    { "product_id": "prod_1", "quantity": 2 },
    { "product_id": "prod_2", "quantity": 1 }
  ]
}
Response (200)
{
  "ok": true,
  "store_id": "store_1",
  "all_available": false,
  "items": [
    {
      "product_id": "prod_1",
      "found": true,
      "store_id": "store_1",
      "wrong_store": false,
      "active": true,
      "available": true,
      "requested_quantity": 2,
      "available_quantity": 50
    },
    {
      "product_id": "prod_2",
      "found": true,
      "store_id": "store_2",
      "wrong_store": true,          // belongs to a different store
      "active": true,
      "available": false,
      "requested_quantity": 1,
      "available_quantity": 0
    }
  ]
}
TypeScript interfaces
interface AvailabilityRequest {
  store_id?: string;
  items: { product_id: string; quantity: number }[];
}

interface AvailabilityResponse {
  ok: boolean;
  store_id: string | null;
  all_available: boolean;
  items: {
    product_id: string;
    found: boolean;
    store_id?: string | null;
    wrong_store?: boolean;          // only when request specified store_id
    available: boolean;
    active?: boolean;
    requested_quantity: number;
    available_quantity: number;
  }[];
}

POST /v1/products/upsert

POST /v1/products/upsert

Create or update a single product. Requires write_products scope and, if the API key has partner_store_ids, the target store must be allowed. Pass id to update an existing product; omit it to create a new one (a product_id is generated). The request body may be either the product object directly or wrapped as {"product": {...}}.

Request body
{
  "store_id": "store_1",
  "category_id": "cat_1",
  "name": "Hydrating Serum",
  "description": "Daily hyaluronic acid serum",
  "price": 29.99,
  "available_quantity": 25,
  "active": true,
  "hot": false,
  "featured_image": "https://cdn.example.com/serum.jpg",
  "images": ["https://cdn.example.com/serum.jpg"],
  "sku": "SKU-001"
}
Response (200)
{
  "ok": true,
  "product_id": "prod_abc123",
  "product": {
    "id": "prod_abc123",
    "name": "Hydrating Serum",
    "description": "Daily hyaluronic acid serum",
    "store_id": "store_1",
    "category_id": "cat_1",
    "featured_image": "https://cdn.example.com/serum.jpg",
    "images": ["https://cdn.example.com/serum.jpg"],
    "active": true,
    "hot": false,
    "price": 29.99,
    "available_quantity": 25,
    "sku": "SKU-001",
    "sold_count": 0,
    "sold_amount": 0,
    "source": "myavana",
    "created_on": 1713561600000,
    "updated_on": 1713561600000
  }
}
TypeScript interfaces
interface ProductUpsertRequest {
  // Required
  store_id: string;
  category_id: string;
  name: string;
  // Optional
  id?: string;                     // set to update a specific product
  description?: string;
  featured_image?: string;
  images?: string[];
  active?: boolean;                // defaults to true
  hot?: boolean;
  price?: number;                  // major units (e.g. USD)
  available_quantity?: number;     // absolute quantity (not a delta)
  sku?: string;
  sold_count?: number;
  sold_amount?: number;
  created_on?: number;             // epoch ms; defaults to now
}

// You may also wrap the payload: { "product": ProductUpsertRequest }

interface ProductUpsertResponse {
  ok: boolean;
  product_id: string;
  product: {
    id: string;
    name: string;
    description: string;
    store_id: string;
    category_id: string;
    featured_image: string;
    images: string[];
    active: boolean;
    hot: boolean;
    price: number;
    available_quantity: number;
    sku: string;
    sold_count: number;
    sold_amount: number;
    source: "myavana";
    created_on: number;
    updated_on: number;
  };
}

Note: available_quantity is an absolute stock quantity (overwrites current stock). For deltas (restock / correction), use POST /v1/inventory/adjustments.

Errors: 400 if store_id, category_id, or name is missing; 403 if the key lacks write_products or the store is outside partner_store_ids.

Delivery address & quote

Before a customer checks out, let them choose a nearby store and preview the delivery fee. Both endpoints accept either a free-form address string (which we geocode) or an explicit { lat, lng } pair.

POST /v1/stores/nearby

POST /v1/stores/nearby

Returns stores within a given radius of a delivery address, sorted by distance. Requires read_stores scope. Honours the API key's store restrictions (partner_store_ids) and each store's own delivery radius (vylvet_delivery_radius_miles) — the effective cutoff is the minimum of the store's radius and max_distance_miles.

Request body (either form)
{
  "delivery_address": "1 Infinite Loop, Cupertino, CA 95014",
  "max_distance_miles": 10,
  "limit": 20
}
{
  "lat": 37.3318,
  "lng": -122.0312,
  "max_distance_miles": 10
}
Response (200)
{
  "ok": true,
  "count": 2,
  "query": {
    "lat": 37.3318,
    "lng": -122.0312,
    "formatted_address": "1 Infinite Loop, Cupertino, CA 95014, USA",
    "max_distance_miles": 10
  },
  "stores": [
    {
      "id": "store_1",
      "name": "Main Store",
      "slug": "main-store",
      "address": "123 Market St",
      "city": "San Francisco",
      "state": "CA",
      "zip_code": "94102",
      "lat": 37.78,
      "lng": -122.41,
      "distance_miles": 2.45,
      "vylvet_delivery_enabled": true,
      "vylvet_delivery_radius_miles": 8,
      "open": true
    }
  ]
}
TypeScript interfaces
interface StoresNearbyRequest {
  delivery_address?: string;        // geocoded server-side
  lat?: number;                     // alternative to delivery_address
  lng?: number;
  max_distance_miles?: number;      // default 10
  limit?: number;                   // default 20, max 50
}

interface StoresNearbyResponse {
  ok: boolean;
  count: number;
  query: {
    lat: number;
    lng: number;
    formatted_address?: string;
    max_distance_miles: number;
  };
  stores: NearbyStore[];
}

interface NearbyStore {
  id: string;
  name: string;
  slug: string;
  address: string;
  city: string;
  state: string;
  zip_code: string;
  lat: number;
  lng: number;
  distance_miles: number;
  vylvet_delivery_enabled: boolean;
  vylvet_delivery_radius_miles?: number;
  open: boolean;
}

Errors: 400 if neither delivery_address nor {lat,lng} is provided, or if the address cannot be geocoded.

POST /v1/delivery/quote

POST /v1/delivery/quote

Get a live Uber Direct delivery fee and ETA from a specific store to a customer address. Requires read_delivery or write_orders scope. Quotes are short-lived — typically valid for a few minutes — so request one just before starting checkout.

Request body
{
  "store_id": "store_1",
  "delivery_address": {
    "phone": "+15551234567",
    "address": {
      "line1": "1 Infinite Loop",
      "city": "Cupertino",
      "state": "CA",
      "postal_code": "95014",
      "country": "US"
    }
  },
  "items": [                           // optional — improves Uber's sizing
    { "product_id": "prod_1", "quantity": 1, "name": "Serum" }
  ],
  "subtotal": 2999,                    // optional, cents
  "dev": true                          // optional — use Uber sandbox
}
Response (200)
{
  "ok": true,
  "quote_id": "dqt_abc123",
  "fee_cents": 599,
  "currency": "usd",
  "dropoff_eta": "2026-02-27T15:30:00.000Z",  // ISO 8601 UTC
  "eta_seconds": 1320,                        // seconds from now
  "duration_seconds": 1200,
  "pickup_duration_seconds": 600,
  "expires": "2026-02-27T15:10:00.000Z",      // quote expiry, ISO 8601 UTC
  "distance_miles": 2.45,
  "trace_id": "..."
}
TypeScript interfaces
interface DeliveryQuoteRequest {
  store_id: string;
  delivery_address: {
    phone?: string;
    address: {
      line1: string;
      line2?: string;
      city: string;
      state: string;
      postal_code: string;
      country: string;
    };
  };
  items?: { product_id?: string; quantity?: number; name?: string }[];
  subtotal?: number;                   // cents
  dev?: boolean;
}

interface DeliveryQuoteResponse {
  ok: boolean;
  quote_id: string;
  fee_cents: number;
  currency: string;                    // e.g. "usd"
  dropoff_eta: string;                 // ISO 8601 UTC
  eta_seconds: number;
  duration_seconds: number;
  pickup_duration_seconds: number;
  expires: string;                     // ISO 8601 UTC
  distance_miles: number | null;
  trace_id: string;
}

Errors: 400 for missing/invalid address or when Uber Direct cannot quote this route (out-of-zone, no couriers); 403 if the store is not allowed for your API key or lacks vylvet_delivery_enabled; 404 if the store is not found.

Orders & delivery

For card payments use POST /v1/checkout/session and redirect to Stripe. For already-paid orders use POST /v1/orders/report. Then poll or use webhooks for status.

POST /v1/checkout/preflight

POST /v1/checkout/preflight

One-shot eligibility check for a cart. Combines store status + fulfillment capability + item availability (+ a live delivery quote when fulfillment_type: "delivery") so you can decide whether to proceed to checkout without making three separate calls. Non-destructive: no order is written, no idempotency key required. Requires read_stores and read_products; for delivery also read_delivery or write_orders.

Request body
{
  "store_id": "store_1",
  "fulfillment_type": "delivery",     // or "pickup"
  "items": [
    { "product_id": "prod_1", "quantity": 2 }
  ],
  "delivery_address": {                // required for delivery
    "phone": "+15551234567",
    "address": {
      "line1": "123 Main St",
      "city": "San Francisco",
      "state": "CA",
      "postal_code": "94102",
      "country": "US"
    }
  },
  "subtotal": 5998,                    // optional — lets Uber weight the quote
  "dev": true                          // optional — use Uber sandbox
}
Response (200) — eligible
{
  "ok": true,
  "eligible": true,
  "reasons": [],
  "fulfillment_type": "delivery",
  "store": {
    "id": "store_1",
    "name": "Radiance Beauty Supply",
    "active": true,
    "open": true,
    "city": "San Francisco",
    "state": "CA",
    "pickup_enabled": true,
    "vylvet_delivery_enabled": true,
    "vylvet_delivery_radius_miles": 15,
    "distance_miles": 1.82
  },
  "availability": {
    "all_available": true,
    "items": [
      { "product_id": "prod_1", "found": true, "store_id": "store_1",
        "wrong_store": false, "active": true, "available": true,
        "requested_quantity": 2, "available_quantity": 50 }
    ]
  },
  "quote": {
    "quote_id": "dqt_abc123",
    "fee_cents": 799,
    "currency": "usd",
    "dropoff_eta": "2026-04-20T22:15:00Z",
    "eta_seconds": 1800,
    "duration_seconds": 1200,
    "pickup_duration_seconds": 600,
    "expires": "2026-04-20T22:00:00Z"
  },
  "trace_id": "..."
}
Response (200) — ineligible
{
  "ok": true,
  "eligible": false,
  "reasons": ["item_unavailable"],     // e.g. also: "store_inactive",
                                       // "store_delivery_disabled",
                                       // "store_pickup_disabled",
                                       // "out_of_radius",
                                       // "quote_unavailable"
  "fulfillment_type": "delivery",
  "store": { "id": "store_1", /* ... */ },
  "availability": { "all_available": false, "items": [ /* ... */ ] },
  "quote": null,
  "trace_id": "..."
}

Once eligible: true, you can safely call POST /v1/checkout/session (or POST /v1/orders/report for externally-paid orders). The quote returned here is for display — checkout/session and orders/report will re-quote when they create the delivery.

POST /v1/checkout/session

POST /v1/checkout/session

Creates order in pending_payment and a Stripe Checkout Session. Redirect the customer to the returned checkout_url. After payment we set the order to processed, deduct inventory, and create delivery. Requires write_orders. Headers: Idempotency-Key recommended.

Request body
{
  "store_id": "store_1",
  "items": [
    { "product_id": "prod_1", "quantity": 2 }
  ],
  "fulfillment_type": "delivery",
  "success_url": "https://yourapp.com/success",
  "cancel_url": "https://yourapp.com/cancel",
  "delivery_address": {
    "name": "Jane Doe",
    "email": "jane@example.com",
    "phone": "+15551234567",
    "address": {
      "line1": "123 Main St",
      "city": "San Francisco",
      "state": "CA",
      "postal_code": "94102",
      "country": "US"
    }
  },
  "delivery_fee": 500,
  "customer_email": "jane@example.com",
  "pickup_instructions": "",
  "dropoff_instructions": "",
  "dev": false
}
Response (201)
{
  "ok": true,
  "order_id": "ABC123XYZ",
  "session_id": "cs_test_...",
  "checkout_url": "https://checkout.stripe.com/c/pay/cs_test_...",
  "trace_id": "TRACE_ABC"
}
TypeScript interfaces
interface CheckoutSessionRequest {
  store_id: string;
  items: { product_id: string; quantity: number }[];
  fulfillment_type: "delivery" | "pickup";
  success_url: string;
  cancel_url: string;
  delivery_address?: DeliveryAddress;  // required if delivery
  delivery_fee?: number;               // cents
  customer_email?: string;
  pickup_instructions?: string;
  dropoff_instructions?: string;
  dev?: boolean;
}

interface DeliveryAddress {
  name: string;
  email?: string;
  phone?: string;
  address: {
    line1: string;
    line2?: string;
    city: string;
    state: string;
    postal_code: string;
    country: string;
  };
}

interface CheckoutSessionResponse {
  ok: boolean;
  order_id: string;
  session_id: string;
  checkout_url: string;
  trace_id: string;
}

Errors: 400 missing/invalid body or URLs; 409 insufficient stock.

After payment: redirect the customer to your success_url, then poll GET /v1/orders/:order_id/tracking to drive a live order-status screen. See Polling cadence for recommended intervals. For a preview of the courier's progress, embed the uber_tracking_url returned by /tracking in a webview.

POST /v1/orders/report

POST /v1/orders/report

Headers: Idempotency-Key required.

Request body
{
  "external_order_id": "EXT_123",
  "store_id": "store_1",
  "items": [
    { "product_id": "prod_1", "quantity": 2, "price": 1999 }
  ],
  "fulfillment_type": "delivery",
  "total": 4500,
  "subtotal": 4000,
  "tax_amount": 400,
  "tax_rate": 10,
  "delivery_fee": 100,
  "delivery_address": {
    "name": "Customer Name",
    "email": "customer@example.com",
    "phone": "+15551234567",
    "address": {
      "line1": "123 Main St",
      "city": "Los Angeles",
      "state": "CA",
      "postal_code": "90001",
      "country": "US"
    }
  },
  "pickup_instructions": "",
  "dropoff_instructions": "",
  "external_payment_id": "pay_abc",
  "dev": false
}
Response (201)
{
  "ok": true,
  "order_id": "ORD_XYZ",
  "external_order_id": "EXT_123",
  "status": "processed",
  "delivery": {
    "created": true,
    "uber_delivery_id": "ud_...",
    "uber_tracking_url": "https://..."
  },
  "trace_id": "TRACE_ABC"
}
TypeScript interfaces
interface OrdersReportRequest {
  external_order_id: string;
  store_id: string;
  items: { product_id: string; quantity: number; price: number }[];
  fulfillment_type?: "delivery" | "pickup";
  total?: number;
  subtotal?: number;
  tax_amount?: number;
  tax_rate?: number;
  delivery_fee?: number;
  delivery_address?: DeliveryAddress;
  pickup_instructions?: string;
  dropoff_instructions?: string;
  external_payment_id?: string;
  dev?: boolean;
}

interface OrdersReportResponse {
  ok: boolean;
  order_id: string;
  external_order_id: string;
  status: string;
  delivery?: {
    created: boolean;
    uber_delivery_id?: string;
    uber_tracking_url?: string;
  };
  trace_id: string;
}

Errors: 409 insufficient stock; 400 invalid store/product or missing fields.

GET /v1/orders/:order_id

GET /v1/orders/:order_id

Full order details. You can only access orders that belong to your partner client.

Response (200)
{
  "ok": true,
  "order": {
    "id": "ORD_XYZ",
    "store_id": "store_1",
    "created_on": 1709123456789,
    "customer_id": "partner_client_ORD_XYZ",
    "items": [
      { "product_id": "prod_1", "quantity": 2, "price": 2199, "original_price": 1999 }
    ],
    "source": "myavana",
    "partner_client_id": "client_abc",
    "fulfillment_type": "delivery",
    "delivery_address": { ... },
    "delivery_fee": 500,
    "subtotal": 4398,
    "tax_amount": 440,
    "total": 5338,
    "status": "processed",
    "updated_on": 1709123500000,
    "uber_delivery_id": "ud_...",
    "uber_tracking_url": "https://..."
  }
}
TypeScript interface
interface OrderResponse {
  ok: boolean;
  order: Order;
}

interface Order {
  id: string;
  store_id: string;
  created_on: number;
  customer_id: string;
  items: { product_id: string; quantity: number; price: number; original_price?: number }[];
  source?: "vylvet" | "shopify" | "myavana";
  partner_client_id?: string;
  external_order_id?: string;
  fulfillment_type: "delivery" | "pickup";
  delivery_address?: DeliveryAddress | null;
  delivery_fee: number;
  subtotal: number;
  tax_amount: number;
  total: number;
  status: OrderStatus;
  updated_on: number;
  uber_delivery_id?: string;
  uber_tracking_url?: string;
  // ... other fields
}

type OrderStatus =
  | "pending_payment" | "paid" | "processed" | "failed_payment" | "shipped"
  | "out_for_delivery" | "delivered" | "ready_for_pickup" | "picked_up"
  | "cancelled" | "refunded" | "refund_requested";

GET /v1/orders/by-external/:external_order_id

GET /v1/orders/by-external/:external_order_id

Look up order by your external_order_id (for orders created via orders/report). Response shape: { "ok": true, "order": Order }.

GET /v1/orders/:order_id/ping

GET /v1/orders/:order_id/ping

Lightweight order status.

Response (200)
{
  "ok": true,
  "order_id": "ORD_XYZ",
  "external_order_id": "EXT_123",
  "status": "processed",
  "fulfillment_type": "delivery",
  "uber_delivery_id": "ud_...",
  "uber_tracking_url": "https://...",
  "estimated_delivery_time": "2026-02-27T16:00:00Z",
  "updated_on": 1709123500000
}
TypeScript interface
interface OrderPingResponse {
  ok: boolean;
  order_id: string;
  external_order_id: string | null;
  status: string;
  fulfillment_type: string;
  uber_delivery_id: string | null;
  uber_tracking_url: string | null;
  estimated_delivery_time: string | null;
  updated_on: number;
}

GET /v1/orders/:order_id/driver-location

GET /v1/orders/:order_id/driver-location

Requires read_delivery scope. Returns the latest known courier position and ETA. We serve from Firestore when the last update is fresh (<60s) and the delivery is not in a terminal state (delivered/cancelled). If the stored data is stale or missing, we fall through to a live Uber Direct call so the response always reflects the most recent courier state.

Response (200)
{
  "ok": true,
  "order_id": "ORD_XYZ",
  "status": "out_for_delivery",
  "uber_delivery_status": "en_route_to_dropoff",
  "uber_delivery_id": "ud_...",
  "uber_tracking_url": "https://trk.ubereats.com/...",
  "courier_imminent": false,
  "courier": {
    "name": "Driver Name",
    "phone_number": "+1555...",
    "vehicle_type": "car",
    "img_href": "https://img.uber.com/courier/abc.jpg",
    "location": { "lat": 37.78, "lng": -122.41 }
  },
  "estimated_delivery_time": "2026-02-27T16:00:00Z",
  "source": "live"
}
TypeScript interface
interface DriverLocationResponse {
  ok: boolean;
  order_id: string;
  status: string;                      // order status
  uber_delivery_status: string | null; // Uber-level: pickup, en_route_to_dropoff, delivered, ...
  uber_delivery_id: string | null;
  uber_tracking_url: string | null;
  courier_imminent: boolean;           // courier is close to the customer
  courier: {
    name?: string;
    phone_number?: string;
    vehicle_type?: string;
    img_href?: string;                 // courier photo URL
    location?: { lat: number; lng: number };
  };
  estimated_delivery_time: string | null;
  source: "cache" | "live";            // where the data came from on this request
}

Tracking & timeline

Two endpoints to surface delivery progress in your UI. tracking is the single-call view optimised for customer-facing screens; timeline is an append-only log of every state change for audits or support tooling.

GET /v1/orders/:order_id/tracking

GET /v1/orders/:order_id/tracking

Unified response combining order status, delivery state, courier info, and the most recent timeline events. Requires read_orders. Falls through to a live Uber Direct call for non-terminal deliveries when the stored snapshot is stale.

Response (200)
{
  "ok": true,
  "order_id": "ORD_XYZ",
  "status": "out_for_delivery",
  "uber_delivery_status": "en_route_to_dropoff",
  "uber_delivery_id": "ud_...",
  "uber_tracking_url": "https://trk.ubereats.com/...",
  "estimated_delivery_time": "2026-02-27T16:00:00Z",
  "courier_imminent": false,
  "courier": {
    "name": "Driver Name",
    "phone_number": "+1555...",
    "vehicle_type": "car",
    "img_href": "https://img.uber.com/courier/abc.jpg",
    "location": { "lat": 37.78, "lng": -122.41 }
  },
  "recent_events": [
    { "event": "order.processed", "created_on": 1709123450000 },
    { "event": "delivery.created", "created_on": 1709123460000 },
    { "event": "delivery.status_changed", "uber_delivery_status": "en_route_to_dropoff", "created_on": 1709123700000 }
  ]
}
TypeScript interface
interface OrderTrackingResponse {
  ok: boolean;
  order_id: string;
  status: string;
  uber_delivery_status: string | null;
  uber_delivery_id: string | null;
  uber_tracking_url: string | null;
  estimated_delivery_time: string | null;
  courier_imminent: boolean;
  courier: {
    name?: string;
    phone_number?: string;
    vehicle_type?: string;
    img_href?: string;
    location?: { lat: number; lng: number };
  };
  recent_events: OrderTimelineEvent[];
}

GET /v1/orders/:order_id/timeline

GET /v1/orders/:order_id/timeline

Full append-only log of state changes — Stripe payment, inventory deduction, Uber delivery creation, every courier update, cancellations and refunds. Requires read_orders. Query: limit (default 100, max 500).

Response (200)
{
  "ok": true,
  "order_id": "ORD_XYZ",
  "count": 5,
  "events": [
    { "id": "evt_1", "event": "order.created",           "status": "pending_payment", "created_on": 1709123440000, "message": "Checkout session created" },
    { "id": "evt_2", "event": "order.processed",         "status": "processed",       "created_on": 1709123450000, "data": { "stripe_payment_intent_id": "pi_..." } },
    { "id": "evt_3", "event": "delivery.created",        "status": "processed",       "created_on": 1709123460000, "data": { "uber_delivery_id": "ud_..." } },
    { "id": "evt_4", "event": "delivery.status_changed", "uber_delivery_status": "en_route_to_dropoff", "created_on": 1709123700000 },
    { "id": "evt_5", "event": "delivery.delivered",      "status": "delivered",       "created_on": 1709124000000 }
  ]
}
TypeScript interfaces
interface OrderTimelineResponse {
  ok: boolean;
  order_id: string;
  count: number;
  events: OrderTimelineEvent[];
}

interface OrderTimelineEvent {
  id: string;
  order_id: string;
  event: OrderTimelineEventType;
  status?: string | null;
  uber_delivery_status?: string | null;
  message?: string | null;
  data?: Record<string, unknown> | null;
  created_on: number;               // Unix ms UTC
}

type OrderTimelineEventType =
  | "order.created" | "order.pending_payment" | "order.paid" | "order.processed"
  | "order.failed_payment" | "order.cancelled" | "order.refund_requested" | "order.refunded"
  | "delivery.created" | "delivery.shipped" | "delivery.out_for_delivery" | "delivery.delivered"
  | "delivery.cancelled" | "delivery.courier_assigned" | "delivery.courier_update"
  | "delivery.status_changed";

Cancel & refund

Cancel or refund an order after it has been created. Both endpoints require write_orders. Both require an Idempotency-Key header — use a stable key per logical operation (e.g. your cancellation ticket ID).

POST /v1/orders/:order_id/cancel

POST /v1/orders/:order_id/cancel

Cancels the order and, if a delivery exists, cancels the Uber Direct delivery. If inventory had been deducted (status processed, shipped, or out_for_delivery), stock is restored atomically. We emit delivery.cancelled to your webhook when the delivery is cancelled.

Refused in terminal states: delivered, picked_up, cancelled, refunded. For delivered orders, use refund instead.

Request body
{
  "reason": "customer_requested"
}
Response (200)
{
  "ok": true,
  "order_id": "ORD_XYZ",
  "status": "cancelled",
  "delivery_cancelled": true,
  "inventory_restored": true
}
TypeScript interfaces
interface OrderCancelRequest {
  reason?: string;                  // free text for audit log
}

interface OrderCancelResponse {
  ok: boolean;
  order_id: string;
  status: "cancelled";
  delivery_cancelled: boolean;
  inventory_restored: boolean;
}

Errors: 400 missing Idempotency-Key; 404 order not found; 409 order is already in a terminal state.

POST /v1/orders/:order_id/refund

POST /v1/orders/:order_id/refund

Issues a Stripe refund against the payment intent recorded on the order. Full refund by default; pass amount_cents for a partial refund. When the full order amount has been refunded, the order status becomes refunded.

Request body
{
  "amount_cents": 1000,
  "reason": "requested_by_customer"
}
Response (200)
{
  "ok": true,
  "order_id": "ORD_XYZ",
  "stripe_refund_id": "re_...",
  "amount_refunded_cents": 1000,
  "total_refunded_cents": 1000,
  "status": "processed"
}
TypeScript interfaces
interface OrderRefundRequest {
  amount_cents?: number;            // omit for full refund
  reason?: "duplicate" | "fraudulent" | "requested_by_customer" | string;
}

interface OrderRefundResponse {
  ok: boolean;
  order_id: string;
  stripe_refund_id: string;
  amount_refunded_cents: number;    // this call
  total_refunded_cents: number;     // cumulative for the order
  status: string;                   // "refunded" once total equals order total
}

Errors: 400 missing Idempotency-Key or invalid amount; 404 order not found; 409 order has no Stripe payment intent (e.g. reported via orders/report with external payment), or the order is already fully refunded; 502 Stripe returned an error — inspect the response body for the Stripe message.

For orders created via POST /v1/orders/report (paid outside Vylvet), this endpoint returns 409 — refund the customer in your own payment system.

Polling cadence

Webhooks are the primary signal for order and delivery state changes. Polling is a fallback — use it when you can't receive webhooks, or alongside webhooks for screens the customer is actively watching.

Order stateRecommended intervalEndpoint
pending_paymentstop polling — wait for the Stripe webhook
processed, shippedevery 30s/tracking or /ping
out_for_deliveryevery 10s while the customer screen is visible/tracking (or /driver-location for just courier position)
delivered, picked_up, cancelled, refundedstop polling — terminal
  • Stop on terminal states. Continued polling against delivered/cancelled/refunded orders wastes quota and will not yield new information.
  • Back off on 429. Default rate limit is 240 req/min per API key. If you hit it, double your interval until 2xx.
  • Prefer /tracking over /ping + /driver-location. The tracking endpoint returns everything in one round-trip — status, courier, recent events.
  • When the app backgrounds, stop polling. Resume on foreground with a single /tracking call to sync.

Payment processing (Stripe)

Card payments are processed by Vylvet. You must not charge the customer in your own system and then call orders/report. Instead: call POST /v1/checkout/session, then redirect the customer to the returned checkout_url (or use Stripe.js / SDK with the returned session_id and our publishable key).

What we provide

  • Stripe publishable key — Production and test keys. Use the test key in development and the production key in production. Contact Vylvet (tech@vylvet.co) to receive these; they are not in a developer dashboard.
  • Product listGET /v1/products (price in cents USD). Use for display and cart; do not send to your own payment provider.
  • Checkout sessionPOST /v1/checkout/session returns checkout_url and session_id. Redirect the customer; after payment we fulfill and send webhooks.

By platform

  • Webwindow.location.href = checkout_url or Stripe.js redirectToCheckout({ sessionId }).
  • React Native@stripe/stripe-react-native or open checkout_url in WebView. Use our publishable key and session_id.
  • Ionic / Capacitor — Open checkout_url in in-app or system browser, or Stripe SDK with sessionId + our publishable key.
  • Flutter — Open checkout_url in WebView or url_launcher, or Stripe Flutter SDK with session_id if supported.
Do not create your own Stripe Payment Intents or charge in your Stripe account and then call POST /v1/orders/report for that order. Use only the checkout session API for card payments.

Inventory

Adjust stock (restock or correction). Requires write_inventory scope. Method and path: POST /v1/inventory/adjustments. Headers: Idempotency-Key required.

Request body
{
  "adjustments": [
    { "product_id": "prod_1", "delta": 10, "reason": "restock", "external_reference": "PO-001" },
    { "product_id": "prod_2", "delta": -2, "reason": "correction" }
  ]
}
Response (200)
{
  "ok": true,
  "adjusted": 2,
  "adjustments": [
    {
      "product_id": "prod_1",
      "previous_quantity": 40,
      "next_quantity": 50,
      "delta": 10
    },
    {
      "product_id": "prod_2",
      "previous_quantity": 5,
      "next_quantity": 3,
      "delta": -2
    }
  ]
}
TypeScript interfaces
interface InventoryAdjustmentsRequest {
  adjustments: {
    product_id: string;
    delta: number;   // positive = add, negative = subtract
    reason?: string;
    external_reference?: string;
  }[];
}

interface InventoryAdjustmentsResponse {
  ok: boolean;
  adjusted: number;
  adjustments: {
    product_id: string;
    previous_quantity: number;
    next_quantity: number;
    delta: number;
  }[];
}

Negative delta reduces stock; the request fails if any result would go below zero. Atomicity: All adjustments in a single request are applied in one transaction. If any one adjustment fails (e.g. product not found or result < 0), the entire request fails and no changes are applied. Same Idempotency-Key returns the same response (idempotent).

Webhooks

Vylvet sends events to your URL when order and delivery status changes. Configure Webhook URL and optional Webhook secret on your partner client.

EventWhen
order.acceptedOrder created and inventory deducted (status is processed)
delivery.createdDelivery created; includes uber_delivery_id, uber_tracking_url
delivery.out_for_deliveryDriver is on the way
delivery.deliveredDelivery completed
delivery.cancelledDelivery cancelled
delivery.courier_updateCourier/location update

Payload: JSON with event, order_id, timestamp (ISO 8601 UTC), trace_id, data. Return 2xx quickly so we can consider the event delivered.

Webhook payload example
{
  "event": "delivery.created",
  "order_id": "ORD_XYZ",
  "timestamp": "2026-02-27T15:04:05.000Z",
  "trace_id": "TRACE_ABC",
  "data": {
    "uber_delivery_id": "ud_...",
    "uber_tracking_url": "https://..."
  }
}
TypeScript interface
interface WebhookPayload {
  event: string;   // e.g. "order.accepted" | "delivery.created" | ...
  order_id: string;
  timestamp: string;   // ISO 8601 UTC
  trace_id: string | null;
  data: Record<string, unknown>;
}

Signature verification

If you set a webhook secret, we send x-partner-signature: HMAC-SHA256 of the raw request body (UTF-8), hex-encoded. Verify before processing; use a timing-safe comparison to avoid timing attacks.

Example (Node.js)
const crypto = require('crypto');
const rawBody = req.rawBody; // or req.body as string, exactly as received
const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');
const received = req.headers['x-partner-signature'] || '';
if (expected.length !== received.length || !crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'))) {
  return res.status(401).send('Invalid signature');
}

Delivery

Implement your handler to be idempotent (e.g. use order_id + event + timestamp to deduplicate). Events may arrive out of order; process based on order state if needed. For critical flows, poll GET /v1/orders/:order_id/ping as a backup.

Errors & rate limits

StatusMeaning
400Bad request — missing or invalid body, or missing Idempotency-Key where required. Body: { "ok": false, "error": "message" }.
401Missing or invalid x-api-key.
403Valid key but missing scope for this endpoint.
404Resource not found (order, product, store).
409Conflict — e.g. insufficient stock on POST /v1/orders/report or POST /v1/checkout/session.
429Rate limit exceeded. See below.
500Server error. Include x-trace-id when contacting support.

All JSON error responses include ok: false and error (string).

Error response body (4xx / 5xx)
{
  "ok": false,
  "error": "Insufficient stock for prod_1. requested=5, available=2",
  "trace_id": "TRACE_ABC"
}
TypeScript interface
interface ErrorResponse {
  ok: false;
  error: string;
  trace_id?: string | null;
}

Rate limiting

Limits are per API key (per partner client). Default is 240 requests per minute, configurable per client. The window is a fixed 1-minute bucket (rolling). On 429, implement exponential backoff and retry; include x-trace-id from the response when reporting issues.

Scopes

Your API key can be restricted to specific scopes. Full access is *.

  • read_stores, read_categories, read_products, write_products
  • read_orders, write_orders, read_delivery, write_inventory

API versioning

The API uses path versioning (/v1/). We aim for backward compatibility within the same major version: existing response fields and behavior will not change in breaking ways without a new version. Breaking changes will be introduced under a new path (e.g. /v2/) with advance notice. Deprecations will be communicated with sufficient time to migrate.

Support

For integration help, contact technical support. Include the following so we can debug quickly:

Technical support

tech@vylvet.co

  • x-trace-id from the API response (or request)
  • Your partner client name
  • Endpoint and method (e.g. POST /v1/orders/report)
  • Short description or error message

Keep your API key secure; it is shown only once when generated. For webhooks, ensure your endpoint returns 2xx quickly and verify x-partner-signature when a secret is configured.