openapi: 3.1.0
info:
  title: Xatern One API
  version: "1.0.0"
  description: |
    Public API for companies that use Xatern One. Xatern One holds the catalog, orders and customers. Your website owns the design.

    Send a key with every call, except /health. Use `Authorization: Bearer <key>` or `X-Api-Key: <key>`.
servers:
  - url: https://api.xatern.com/v1
security:
  - bearer: []
  - apiKey: []
tags:
  - name: Basics
  - name: Catalog
  - name: Orders
  - name: Customers
  - name: Subscribers
  - name: Leads
  - name: Webhooks
paths:
  /health:
    get:
      tags: [Basics]
      summary: Check the API is up
      security: []
      operationId: getHealth
      responses:
        "200":
          description: The API is up.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean, const: true }
  /me:
    get:
      tags: [Basics]
      summary: Show the company and scopes of the key
      description: Works with any valid key.
      operationId: getMe
      responses:
        "200":
          description: The key's company, kind and scopes.
          content:
            application/json:
              schema:
                type: object
                required: [ok, company, kind, scopes]
                properties:
                  ok: { type: boolean, const: true }
                  company: { type: string, example: Example Shop Co }
                  kind: { type: string, enum: [publishable, secret, legacy] }
                  scopes:
                    type: array
                    items: { $ref: "#/components/schemas/Scope" }
        default: { $ref: "#/components/responses/Error" }
  /catalog:
    get:
      tags: [Catalog]
      summary: Read the catalog
      description: Visible products with prices and stock. Cache it for about a minute. Needs scope `catalog:read`.
      operationId: getCatalog
      responses:
        "200":
          description: The catalog.
          content:
            application/json:
              schema:
                type: object
                required: [ok, catalog]
                properties:
                  ok: { type: boolean, const: true }
                  catalog: { $ref: "#/components/schemas/Catalog" }
        default: { $ref: "#/components/responses/Error" }
  /orders:
    post:
      tags: [Orders]
      summary: Place an order
      description: |
        Needs scope `orders:create`. Send the same `idempotency_key` again and you get the first order back. The reply holds the order `number` and a secret `token`. The body is up to 64 KB.
      operationId: createOrder
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [order]
              properties:
                order: { $ref: "#/components/schemas/OrderInput" }
      responses:
        "200":
          description: The order was placed, or the first order for this idempotency key.
          content:
            application/json:
              schema:
                type: object
                required: [ok, order]
                properties:
                  ok: { type: boolean, const: true }
                  order: { $ref: "#/components/schemas/PlacedOrder" }
        default: { $ref: "#/components/responses/Error" }
    get:
      tags: [Orders]
      summary: List orders
      description: Newest first. Needs scope `orders:read`.
      operationId: listOrders
      parameters:
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/OrderStatus" }
        - name: payment_status
          in: query
          schema: { $ref: "#/components/schemas/PaymentStatus" }
        - name: since
          in: query
          description: Only orders placed after this time.
          schema: { type: string, format: date-time }
        - name: updated_since
          in: query
          description: Only orders changed after this time.
          schema: { type: string, format: date-time }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of orders.
          content:
            application/json:
              schema:
                type: object
                required: [ok, orders, next_cursor]
                properties:
                  ok: { type: boolean, const: true }
                  orders:
                    type: array
                    items: { $ref: "#/components/schemas/Order" }
                  next_cursor:
                    type: [string, "null"]
                    description: Pass as `cursor` for the next page. Null when there are no more.
        default: { $ref: "#/components/responses/Error" }
  /orders/{number}:
    get:
      tags: [Orders]
      summary: Get one order
      description: |
        With `token`, returns the shopper view and needs scope `orders:create`. Without `token`, returns the full order and needs scope `orders:read`.
      operationId: getOrder
      parameters:
        - $ref: "#/components/parameters/OrderNumber"
        - name: token
          in: query
          description: The order token from `POST /orders`.
          schema: { type: string }
      responses:
        "200":
          description: The order.
          content:
            application/json:
              schema:
                type: object
                required: [ok, order]
                properties:
                  ok: { type: boolean, const: true }
                  order: { $ref: "#/components/schemas/Order" }
        default: { $ref: "#/components/responses/Error" }
  /orders/{number}/payment-proof:
    post:
      tags: [Orders]
      summary: Upload a payment screenshot
      description: Needs scope `orders:create`. One payment is checked at a time.
      operationId: uploadPaymentProof
      parameters:
        - $ref: "#/components/parameters/OrderNumber"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [token, reference, file]
              properties:
                token: { type: string, description: The order token. }
                reference:
                  type: string
                  maxLength: 60
                  description: The reference number on the payment receipt.
                file:
                  type: string
                  format: binary
                  description: JPG, PNG, WebP or HEIC image, up to 8 MB.
      responses:
        "200":
          description: The order is now waiting for a payment check.
          content:
            application/json:
              schema:
                type: object
                required: [ok, order]
                properties:
                  ok: { type: boolean, const: true }
                  order: { $ref: "#/components/schemas/Order" }
        default: { $ref: "#/components/responses/Error" }
  /orders/{number}/waybill:
    get:
      tags: [Orders]
      summary: Get a link to the waybill photo
      description: Needs scope `orders:create` and the order token. The link works for 10 minutes.
      operationId: getWaybill
      parameters:
        - $ref: "#/components/parameters/OrderNumber"
        - name: token
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: The link, or null when there is no waybill yet.
          content:
            application/json:
              schema:
                type: object
                required: [ok, url]
                properties:
                  ok: { type: boolean, const: true }
                  url: { type: [string, "null"], format: uri }
        default: { $ref: "#/components/responses/Error" }
  /customers:
    get:
      tags: [Customers]
      summary: List customers
      description: Needs scope `customers:read`.
      operationId: listCustomers
      parameters:
        - name: q
          in: query
          description: Matches name, phone or email.
          schema: { type: string }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: A page of customers.
          content:
            application/json:
              schema:
                type: object
                required: [ok, customers, next_cursor]
                properties:
                  ok: { type: boolean, const: true }
                  customers:
                    type: array
                    items: { $ref: "#/components/schemas/Customer" }
                  next_cursor: { type: [string, "null"] }
        default: { $ref: "#/components/responses/Error" }
  /subscribers:
    post:
      tags: [Subscribers]
      summary: Add an email to the mailing list
      description: Needs scope `subscribers:create`. Send it only after the person agreed. The answer is the same whether the email was new or not.
      operationId: createSubscriber
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email, maxLength: 254, example: maria@example.test }
                source: { type: string, enum: [footer, checkout], default: footer }
      responses:
        "200":
          description: Subscribed.
          content:
            application/json:
              schema:
                type: object
                required: [ok, subscribed]
                properties:
                  ok: { type: boolean, const: true }
                  subscribed: { type: boolean, const: true }
        default: { $ref: "#/components/responses/Error" }
  /leads:
    post:
      tags: [Leads]
      summary: Send a website enquiry into the CRM
      description: Needs scope `leads:create`. The body is the same as the lead form accepts today.
      operationId: createLead
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              properties:
                name: { type: string, example: Jon Reyes }
                email: { type: string, format: email, example: jon@example.test }
                phone: { type: string, example: "09181234567" }
                message: { type: string, example: "Do you do bulk orders?" }
      responses:
        "200":
          description: The enquiry was received.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean, const: true }
        default: { $ref: "#/components/responses/Error" }
  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      description: Needs scope `webhooks:manage`.
      operationId: listWebhooks
      responses:
        "200":
          description: Your endpoints.
          content:
            application/json:
              schema:
                type: object
                required: [ok, endpoints]
                properties:
                  ok: { type: boolean, const: true }
                  endpoints:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
        default: { $ref: "#/components/responses/Error" }
    post:
      tags: [Webhooks]
      summary: Make a webhook endpoint
      description: Needs scope `webhooks:manage`. Up to 10 endpoints per company. The signing secret is shown once.
      operationId: createWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { $ref: "#/components/schemas/WebhookUrl" }
                events:
                  type: array
                  items: { $ref: "#/components/schemas/EventType" }
                description: { type: string }
      responses:
        "200":
          description: The endpoint and its signing secret.
          content:
            application/json:
              schema:
                type: object
                required: [ok, endpoint, secret]
                properties:
                  ok: { type: boolean, const: true }
                  endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
                  secret: { type: string, example: whsec_0123456789abcdef0123456789abcdef0123456789abcdef }
        default: { $ref: "#/components/responses/Error" }
  /webhooks/{id}:
    patch:
      tags: [Webhooks]
      summary: Change a webhook endpoint
      description: Needs scope `webhooks:manage`. Send only what changes. Set `enabled` to true to turn a disabled endpoint back on.
      operationId: updateWebhook
      parameters:
        - $ref: "#/components/parameters/WebhookId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { $ref: "#/components/schemas/WebhookUrl" }
                events:
                  type: array
                  items: { $ref: "#/components/schemas/EventType" }
                description: { type: string }
                enabled: { type: boolean }
      responses:
        "200":
          description: The changed endpoint.
          content:
            application/json:
              schema:
                type: object
                required: [ok, endpoint]
                properties:
                  ok: { type: boolean, const: true }
                  endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        default: { $ref: "#/components/responses/Error" }
    delete:
      tags: [Webhooks]
      summary: Remove a webhook endpoint
      description: Needs scope `webhooks:manage`.
      operationId: deleteWebhook
      parameters:
        - $ref: "#/components/parameters/WebhookId"
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        default: { $ref: "#/components/responses/Error" }
  /webhooks/{id}/test:
    post:
      tags: [Webhooks]
      summary: Send a ping event
      description: Needs scope `webhooks:manage`.
      operationId: testWebhook
      parameters:
        - $ref: "#/components/parameters/WebhookId"
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        default: { $ref: "#/components/responses/Error" }
  /webhooks/{id}/deliveries:
    get:
      tags: [Webhooks]
      summary: List deliveries for an endpoint
      description: Newest first. Needs scope `webhooks:manage`.
      operationId: listWebhookDeliveries
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      responses:
        "200":
          description: Recent deliveries.
          content:
            application/json:
              schema:
                type: object
                required: [ok, deliveries]
                properties:
                  ok: { type: boolean, const: true }
                  deliveries:
                    type: array
                    items: { $ref: "#/components/schemas/Delivery" }
        default: { $ref: "#/components/responses/Error" }
  /webhooks/{id}/roll-secret:
    post:
      tags: [Webhooks]
      summary: Make a new signing secret
      description: Needs scope `webhooks:manage`. The old secret stops working at once. The new one is shown once.
      operationId: rollWebhookSecret
      parameters:
        - $ref: "#/components/parameters/WebhookId"
      responses:
        "200":
          description: The new secret.
          content:
            application/json:
              schema:
                type: object
                required: [ok, secret]
                properties:
                  ok: { type: boolean, const: true }
                  secret: { type: string, example: whsec_fedcba9876543210fedcba9876543210fedcba9876543210 }
        default: { $ref: "#/components/responses/Error" }
  /webhooks/deliveries/{delivery_id}/resend:
    post:
      tags: [Webhooks]
      summary: Send a delivery again
      description: Needs scope `webhooks:manage`.
      operationId: resendWebhookDelivery
      parameters:
        - name: delivery_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200": { $ref: "#/components/responses/Ok" }
        default: { $ref: "#/components/responses/Error" }
webhooks:
  event:
    post:
      summary: An event sent to your endpoint
      description: |
        Xatern One sends this to your URL. Check the `Xatern-Signature` header: `t=<unix seconds>,v1=<hex HMAC-SHA256 of "t.body">` using the endpoint signing secret. Reply with any 2xx within 10 seconds.
      parameters:
        - name: Xatern-Event
          in: header
          schema: { $ref: "#/components/schemas/EventType" }
        - name: Xatern-Delivery
          in: header
          schema: { type: string, format: uuid }
        - name: Xatern-Signature
          in: header
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Event" }
      responses:
        "200":
          description: Received.
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      description: "Send the key as `Authorization: Bearer <key>`."
    apiKey:
      type: apiKey
      in: header
      name: X-Api-Key
      description: Send the key in the `X-Api-Key` header.
  parameters:
    OrderNumber:
      name: number
      in: path
      required: true
      schema: { type: string, example: NT-482913 }
    WebhookId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    Cursor:
      name: cursor
      in: query
      description: The `next_cursor` from the previous page.
      schema: { type: string }
  responses:
    Ok:
      description: Done.
      content:
        application/json:
          schema:
            type: object
            required: [ok]
            properties:
              ok: { type: boolean, const: true }
    Error:
      description: An error. See the `code` field.
      headers:
        X-Request-Id:
          description: Send this id when you ask for help.
          schema: { type: string }
        Retry-After:
          description: Seconds to wait. Only on 429.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Scope:
      type: string
      enum: ["catalog:read", "orders:create", "subscribers:create", "leads:create", "orders:read", "customers:read", "webhooks:manage"]
    Error:
      type: object
      required: [ok, code, error, request_id]
      properties:
        ok: { type: boolean, const: false }
        code:
          type: string
          description: |
            400 bad_request, 401 invalid_key, 403 missing_scope, browser_not_allowed, origin_not_allowed or company_inactive, 404 not_found, 405 method_not_allowed, 409 conflict, 413 too_large, 422 invalid, 429 rate_limited, 500 server_error.
          enum: [bad_request, invalid_key, missing_scope, browser_not_allowed, origin_not_allowed, company_inactive, not_found, method_not_allowed, conflict, too_large, invalid, rate_limited, server_error]
        error: { type: string, example: "This key cannot read orders." }
        request_id: { type: string, example: req_0123456789abcdef0123 }
    OrderStatus:
      type: string
      enum: [awaiting_payment, proof_received, confirmed, downpayment_confirmed, balance_due, preparing, ready_for_pickup, shipped, completed, cancelled]
    PaymentStatus:
      type: string
      description: Ignore values you do not know. More can be added.
      enum: [unpaid, checking, part_paid, paid, refund_due, refunded]
    Money:
      type: number
      multipleOf: 0.01
      example: 1020
    Catalog:
      type: object
      properties:
        currency: { type: string, example: PHP }
        at: { type: string, format: date-time }
        products:
          type: array
          items:
            type: object
            properties:
              name: { type: string, example: Rose Mist }
              category: { type: string, example: Mists }
              tags: { type: array, items: { type: string } }
              allow_preorder: { type: boolean }
              discount: { type: [object, "null"] }
              sizes:
                type: array
                items:
                  type: object
                  properties:
                    sku: { type: string, example: SKU-ROSE-50 }
                    label: { type: string, example: 50 ml }
                    price: { $ref: "#/components/schemas/Money" }
                    sale_price:
                      oneOf:
                        - { $ref: "#/components/schemas/Money" }
                        - { type: "null" }
                    available: { type: integer }
    Contact:
      type: object
      properties:
        name: { type: string, example: Maria Santos }
        phone: { type: string, example: "+639171234567" }
        email: { type: [string, "null"], format: email, example: maria@example.test }
        social: { type: [string, "null"] }
    OrderDelivery:
      type: object
      description: Where and how the order is delivered.
      properties:
        method: { type: string, description: "`pickup` or one of the shop couriers.", example: jnt }
        region: { type: string, example: NCR }
        city: { type: string, example: Quezon City }
        street: { type: string, example: 12 Sample St }
        landmark: { type: string, example: Blue gate }
        courier: { type: [string, "null"] }
        tracking_number: { type: [string, "null"] }
    Line:
      type: object
      required: [product_id, name, qty, unit_price, line_total]
      properties:
        product_id: { type: string, example: SKU-ROSE-50 }
        name: { type: string, example: Rose Mist 50 ml }
        qty: { type: integer, minimum: 1 }
        unit_price: { $ref: "#/components/schemas/Money" }
        line_total: { $ref: "#/components/schemas/Money" }
        preorder: { type: boolean }
    Payment:
      type: object
      properties:
        kind: { type: string }
        method: { type: string }
        amount: { $ref: "#/components/schemas/Money" }
        reference: { type: [string, "null"] }
        status: { type: string, enum: [pending, verified, rejected] }
        reason: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
    Order:
      type: object
      properties:
        number: { type: string, example: NT-482913 }
        status: { $ref: "#/components/schemas/OrderStatus" }
        payment_status: { $ref: "#/components/schemas/PaymentStatus" }
        payment_method: { type: string, example: bank }
        placed_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        currency: { type: string, example: PHP }
        total: { $ref: "#/components/schemas/Money" }
        due_today: { $ref: "#/components/schemas/Money" }
        paid: { $ref: "#/components/schemas/Money" }
        balance: { $ref: "#/components/schemas/Money" }
        freight: { $ref: "#/components/schemas/Money" }
        sale_saving: { $ref: "#/components/schemas/Money" }
        promo_code: { type: [string, "null"] }
        promo_saving: { $ref: "#/components/schemas/Money" }
        contact: { $ref: "#/components/schemas/Contact" }
        delivery: { $ref: "#/components/schemas/OrderDelivery" }
        lines:
          type: array
          items: { $ref: "#/components/schemas/Line" }
        payments:
          type: array
          items: { $ref: "#/components/schemas/Payment" }
    PlacedOrder:
      type: object
      description: The order as returned when placed. Holds the secret token. Other fields match `Order`.
      required: [number, token]
      properties:
        number: { type: string, example: NT-482913 }
        token: { type: string, example: a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 }
        status: { $ref: "#/components/schemas/OrderStatus" }
        payment_status: { $ref: "#/components/schemas/PaymentStatus" }
        total: { $ref: "#/components/schemas/Money" }
        due_today: { $ref: "#/components/schemas/Money" }
        balance: { $ref: "#/components/schemas/Money" }
      additionalProperties: true
    OrderInput:
      type: object
      required: [idempotency_key, payment_method, contact, delivery, lines, total, due_today, balance]
      properties:
        idempotency_key: { type: string, format: uuid, description: Make once per checkout. }
        payment_method: { type: string, example: bank }
        contact:
          type: object
          required: [name, phone]
          properties:
            name: { type: string }
            phone: { type: string, description: Local or international mobile number. }
            email: { type: [string, "null"], format: email }
            social: { type: [string, "null"] }
        delivery:
          type: object
          required: [method]
          properties:
            method: { type: string, description: "`pickup` or one of the shop couriers." }
            region: { type: string }
            city: { type: string }
            street: { type: string }
            landmark: { type: string }
        lines:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: "#/components/schemas/Line" }
        freight: { $ref: "#/components/schemas/Money" }
        promo_code: { type: [string, "null"] }
        promo_saving: { $ref: "#/components/schemas/Money" }
        sale_saving: { $ref: "#/components/schemas/Money" }
        total:
          allOf:
            - $ref: "#/components/schemas/Money"
          description: Sum of line totals, minus promo saving, plus freight.
        due_today: { $ref: "#/components/schemas/Money" }
        balance:
          allOf:
            - $ref: "#/components/schemas/Money"
          description: Total minus due today.
    Customer:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: Maria Santos }
        phone: { type: string, example: "+639171234567" }
        email: { type: [string, "null"], format: email, example: maria@example.test }
        social: { type: [string, "null"] }
        orders_count: { type: integer }
        total_spent: { $ref: "#/components/schemas/Money" }
        first_order_at: { type: [string, "null"], format: date-time }
        last_order_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
    EventType:
      type: string
      enum: [order.created, order.paid, order.shipped, order.completed, order.cancelled, order.updated, product.updated, ping]
    WebhookUrl:
      type: string
      format: uri
      maxLength: 500
      description: https only. No localhost or private addresses.
      example: https://hooks.example.test/xatern
    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { $ref: "#/components/schemas/WebhookUrl" }
        events:
          type: array
          items: { $ref: "#/components/schemas/EventType" }
        description: { type: [string, "null"], example: Fulfilment system }
        enabled: { type: boolean }
        disabled_at: { type: [string, "null"], format: date-time }
        disabled_reason: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
    Delivery:
      type: object
      description: One attempt series to send an event to a webhook endpoint.
      properties:
        id: { type: string, format: uuid }
        event_id: { type: string, example: evt_0123456789abcdef01234567 }
        type: { $ref: "#/components/schemas/EventType" }
        status: { type: string, enum: [pending, sending, succeeded, failed] }
        attempt: { type: integer }
        last_status: { type: [integer, "null"] }
        last_error: { type: [string, "null"] }
        last_ms: { type: [integer, "null"] }
        created_at: { type: string, format: date-time }
        delivered_at: { type: [string, "null"], format: date-time }
    Event:
      type: object
      required: [id, type, created, company, data]
      properties:
        id: { type: string, example: evt_0123456789abcdef01234567 }
        type: { $ref: "#/components/schemas/EventType" }
        created: { type: string, format: date-time }
        company: { type: string, format: uuid }
        data:
          type: object
          description: "`order` for order events, `product` ({id, name, status, skus}) for product.updated, empty for ping."
          properties:
            order: { $ref: "#/components/schemas/Order" }
            product:
              type: object
              properties:
                id: { type: string }
                name: { type: string }
                status: { type: string }
                skus: { type: array, items: { type: string } }
