openapi: 3.1.0
info:
  title: Ridge Arc Network partner APIs
  version: "2026-08-26"
  description: |
    Partner-facing surfaces of Ridge Arc Network: the Inbound API (leads, appointments,
    contractor job pull) and the Installer API v1. Webhook envelope and headers are
    documented as components. Authentication is an API key in the `x-api-key` header,
    presented as `<keyId>.<secret>`. Scopes are fixed by key kind.
  contact:
    email: developers@ridgearcnetwork.com
servers:
  - url: https://vesta-staging.hbjeffery.workers.dev/api
    description: Sandbox (synthetic data, ran_test_ keys)
security:
  - apiKey: []
tags:
  - name: Inbound
    description: General and installer-contractor keys owned by a company
  - name: Installer v1
    description: Installer-company keys

paths:
  /inbound/leads:
    post:
      tags: [Inbound]
      summary: Create or merge a lead
      description: Scope `leads:write`. De-duplicated by normalised address within the company.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LeadIn" }
      responses:
        "201":
          description: Created
          content: { application/json: { schema: { $ref: "#/components/schemas/LeadCreated" } } }
        "200":
          description: Merged into an existing lead or project
          content: { application/json: { schema: { $ref: "#/components/schemas/LeadMerged" } } }
        "400": { $ref: "#/components/responses/Validation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /inbound/appointments:
    post:
      tags: [Inbound]
      summary: Book an appointment
      description: Scope `appointments:write`. Idempotent on `idempotencyKey`; a replay with a different body returns 409.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AppointmentIn" }
      responses:
        "201": { description: Created, content: { application/json: { schema: { type: object, properties: { appointmentId: { type: string } } } } } }
        "200": { description: Replay of an identical request }
        "409": { $ref: "#/components/responses/Conflict" }
        "400": { $ref: "#/components/responses/Validation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /inbound/jobs:
    get:
      tags: [Inbound]
      summary: List sold jobs for the key's contractor
      description: Scope `jobs:read`, installer-contractor key.
      parameters:
        - in: query
          name: take
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: Jobs, most recently updated first
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobs:
                    type: array
                    items: { $ref: "#/components/schemas/ContractorJob" }

  /inbound/jobs/{id}/status:
    post:
      tags: [Inbound]
      summary: Append an installer note to a job
      description: Scope `jobs:status:write`, installer-contractor key.
      parameters: [{ in: path, name: id, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [note]
              properties:
                note: { type: string, minLength: 1, maxLength: 2000 }
      responses:
        "200": { description: Recorded }
        "404": { description: Job not visible to this key }

  /partner/v1/jobs:
    get:
      tags: [Installer v1]
      summary: Change feed of jobs assigned to your company
      description: Scope `jobs:read`. Persist `nextCursor`; without a cursor the latest page is returned.
      parameters:
        - { in: query, name: cursor, schema: { type: string } }
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
      responses:
        "200":
          description: Page of changes
          headers:
            x-ridge-arc-api-version: { schema: { type: string, example: "2026-08-26" } }
          content:
            application/json:
              schema:
                type: object
                properties:
                  apiVersion: { type: string }
                  resources:
                    type: array
                    items:
                      type: object
                      properties:
                        sequence: { type: integer }
                        changedAt: { type: string, format: date-time }
                        resource: { $ref: "#/components/schemas/InstallerJob" }
                  nextCursor: { type: [string, "null"] }
                  hasMore: { type: boolean }

  /partner/v1/jobs/{id}:
    get:
      tags: [Installer v1]
      summary: One job
      parameters: [{ in: path, name: id, required: true, schema: { type: string } }]
      responses:
        "200":
          description: The installer-safe job
          content: { application/json: { schema: { type: object, properties: { apiVersion: { type: string }, resource: { $ref: "#/components/schemas/InstallerJob" } } } } }
        "404": { description: Not assigned to your company }

  /partner/v1/jobs/{id}/status-events:
    post:
      tags: [Installer v1]
      summary: Post a status event
      description: Scope `jobs:status:write`. Requires `Idempotency-Key`. Transitions are validated; 409 on stale `expectedVersion` or idempotency mismatch.
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StatusEventIn" }
      responses:
        "201": { description: Recorded; returns the job at its new version }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { description: Transition not allowed from the current status }

  /partner/v1/jobs/{id}/schedule:
    put:
      tags: [Installer v1]
      summary: Set or cancel the schedule
      description: Scope `jobs:schedule:write`. Requires `Idempotency-Key`.
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScheduleIn" }
      responses:
        "200": { description: Updated job }
        "409": { $ref: "#/components/responses/Conflict" }

  /partner/v1/technicians:
    get:
      tags: [Installer v1]
      summary: Your technicians
      description: Scope `technicians:read`.
      responses:
        "200":
          description: Memberships
          content:
            application/json:
              schema:
                type: object
                properties:
                  technicians:
                    type: array
                    items:
                      type: object
                      properties:
                        membershipId: { type: string }
                        displayName: { type: string }
                        active: { type: boolean }

  /partner/v1/jobs/{id}/assignments:
    put:
      tags: [Installer v1]
      summary: Replace technician assignments
      description: Scope `jobs:assignment:write`. Requires `Idempotency-Key`.
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
        - { $ref: "#/components/parameters/IdempotencyKey" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [membershipIds, expectedVersion]
              properties:
                membershipIds: { type: array, maxItems: 20, uniqueItems: true, items: { type: string } }
                expectedVersion: { type: integer, minimum: 1 }
      responses:
        "200": { description: Updated job }
        "409": { $ref: "#/components/responses/Conflict" }

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: x-api-key
      description: "`<keyId>.<secret>`; secrets are prefixed ran_test_ (sandbox) or ran_live_ (production)"
  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: true
      schema: { type: string, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$" }
  responses:
    Validation: { description: Validation failed; the message names the field, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Unauthorized: { description: Missing, malformed, revoked or wrong-surface key, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Forbidden: { description: Key lacks the scope, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Conflict: { description: Idempotency-Key reused with a different request, or expectedVersion is stale, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    RateLimited: { description: Rate limited; honour retry-after, headers: { retry-after: { schema: { type: integer } } } }
  schemas:
    Error:
      type: object
      properties: { error: { type: string } }
    LeadIn:
      type: object
      required: [fullName, email, phone, address]
      properties:
        fullName: { type: string, minLength: 1, maxLength: 120 }
        email: { type: string, format: email, maxLength: 254 }
        phone: { type: string, minLength: 7, maxLength: 40 }
        address: { type: string, minLength: 3, maxLength: 500 }
        smsConsent: { type: boolean }
    LeadCreated:
      type: object
      properties: { status: { const: created }, leadId: { type: string } }
    LeadMerged:
      type: object
      properties:
        status: { const: merged }
        leadId: { type: string }
        projectId: { type: string }
        reason: { type: string, example: existing project }
    AppointmentIn:
      type: object
      required: [leadId, startsAt, endsAt, idempotencyKey]
      properties:
        leadId: { type: string }
        projectId: { type: string }
        startsAt: { type: string, format: date-time }
        endsAt: { type: string, format: date-time }
        notes: { type: string, maxLength: 500 }
        idempotencyKey: { type: string, minLength: 8, maxLength: 160 }
    ContractorJob:
      type: object
      properties:
        id: { type: string }
        workOrderNo: { type: [string, "null"] }
        address: { type: string }
        city: { type: string }
        state: { type: string }
        zip: { type: string }
        stage: { type: string }
        installerNote: { type: [string, "null"] }
        lastUpdated: { type: string, format: date-time }
    StatusEventIn:
      type: object
      required: [status, occurredAt, expectedVersion]
      properties:
        status: { type: string, enum: [ACKNOWLEDGED, EN_ROUTE, ON_SITE, BLOCKED, IN_PROGRESS, COMPLETE] }
        occurredAt: { type: string, format: date-time, description: At most 5 minutes in the future, 30 days in the past }
        note: { type: string, maxLength: 2000 }
        blockerCode: { type: string, enum: [CUSTOMER_UNAVAILABLE, SITE_NOT_READY, MATERIAL_MISSING, EQUIPMENT_DAMAGED, PERMIT, WEATHER, SAFETY, OTHER], description: Required when status is BLOCKED }
        expectedVersion: { type: integer, minimum: 1 }
    ScheduleIn:
      type: object
      required: [status, startsAt, endsAt, timeZone, expectedVersion]
      properties:
        status: { type: string, enum: [TENTATIVE, CONFIRMED, CANCELLED] }
        startsAt: { type: [string, "null"], format: date-time }
        endsAt: { type: [string, "null"], format: date-time }
        timeZone: { type: string, description: IANA time zone name, example: America/Denver }
        expectedVersion: { type: integer, minimum: 1 }
    InstallerJob:
      type: object
      description: Installer-safe projection. Customer email, prices, costs, margin, financing, payments and signatures are never present.
      properties:
        id: { type: string }
        version: { type: integer }
        status: { type: string, enum: [ASSIGNED, ACKNOWLEDGED, EN_ROUTE, ON_SITE, BLOCKED, IN_PROGRESS, COMPLETE, CANCELLED] }
        company: { type: object, properties: { id: { type: string }, name: { type: string } } }
        customer:
          type: object
          properties:
            name: { type: string }
            phone: { type: string }
            address: { type: object, properties: { line1: { type: string }, city: { type: string }, state: { type: string }, zip: { type: string } } }
        scope:
          type: object
          properties:
            packageName: { type: string }
            systemType: { type: string }
            equipment: { type: array, items: { type: object } }
            accessories: { type: array, items: { type: object } }
            supplierSources: { type: array, items: { type: object } }
            scopeVersion: { type: integer }
        schedule:
          type: object
          properties:
            status: { type: string, enum: [UNSCHEDULED, TENTATIVE, CONFIRMED, CANCELLED] }
            startsAt: { type: [string, "null"], format: date-time }
            endsAt: { type: [string, "null"], format: date-time }
            timeZone: { type: [string, "null"] }
        readiness: { type: object, properties: { ready: { type: boolean }, blockerCodes: { type: array, items: { type: string } } } }
        documents: { type: array, items: { type: object, properties: { id: { type: string }, category: { type: string, enum: [SCOPE, PERMIT, MANUAL, WARRANTY, COMPLETION, CONTRACT_METADATA] }, mime: { type: string }, availableAt: { type: string, format: date-time } } } }
        technicianAssignments: { type: array, items: { type: object, properties: { membershipId: { type: string }, assignedAt: { type: string, format: date-time } } } }
        updatedAt: { type: string, format: date-time }
    WebhookEnvelope:
      type: object
      description: |
        Delivered to your HTTPS endpoint. Headers: x-ridge-arc-event, x-ridge-arc-delivery-id,
        x-ridge-arc-timestamp (unix seconds), x-ridge-arc-secret-version,
        x-ridge-arc-signature ("v1=" + hex HMAC-SHA256(secret, timestamp + "." + rawBody)).
        Reject deliveries more than 300 seconds old; de-duplicate on the delivery id;
        respond 2xx within 10 seconds only after durable accept. Retries: 8 attempts,
        exponential backoff from 1 minute, capped at 6 hours, then dead-letter.
      properties:
        id: { type: string }
        event: { type: string, example: install.status_changed }
        apiVersion: { type: string, example: "2026-08-26" }
        schemaVersion: { type: integer, example: 1 }
        occurredAt: { type: string, format: date-time }
        resource: { type: object, properties: { type: { type: string }, id: { type: string }, version: { type: integer } } }
        data: { type: object }
