# quipteams API v1 reference

Machine-readable version of https://app.quipteams.com/docs - paste this document (or its URL) into an AI assistant to build an integration. Base URL: `https://app.quipteams.com/api/v1`. Support: it@quipteams.com.

## Authentication

Every request is authenticated with an API key sent as a bearer token. Keys are issued by the quipteams team and are scoped to a single company. Live keys are prefixed `qt_live_` and sandbox keys `qt_sandbox_` (see Sandbox below). Treat a key like a password - never expose it in client-side code.

```bash
curl https://app.quipteams.com/api/v1/quotes \
  -H "Authorization: Bearer qt_live_your_api_key"
```

Each key carries a set of scopes. An endpoint returns `403 FORBIDDEN` if the key is missing the scope it requires.

| Scope | Grants |
| --- | --- |
| `quotes:read` | Read quotes, items, recipients, and alternatives. |
| `quotes:write` | Create quotes and accept/reject quote items. |
| `assets:read` | Read stored inventory assets. |
| `assets:write` | Add devices to inventory. |
| `device-actions:read` | Read logistics device actions. |
| `device-actions:write` | Create device actions (store, assign, reassign, sell, support). |
| `products:read` | Read the shared product catalog. |
| `kits:read` | Read kit bundles and their devices. |
| `roles:read` | Read company roles and their assigned kit ids. |
| `equip:write` | Equip an employee with a kit — assigns stored devices when the whole kit is in stock, otherwise creates a quote. |
| `employees:read` | Read HRIS-synced employees. |
| `offices:read` | Read company offices. |
| `scheduling-links:read` | Read delivery scheduling links. |
| `webhooks:read` | List and inspect webhook endpoints. |
| `webhooks:write` | Create, update, delete, and test webhook endpoints. |

## Sandbox

Sandbox keys (`qt_sandbox_`) run against an isolated copy of your workspace, pre-seeded with realistic demo data: quotes, assets, device actions, employees, kits, roles, offices, and scheduling links. Every endpoint behaves exactly like production and uses the same base URL - only the key decides which world you are in. Writes persist until the sandbox is reset. Create sandbox keys (and reset the data) from the API tab of the admin panel; your first sandbox key provisions the sandbox automatically.

On a sandbox write:

- Quotes and device actions go through the real operational pipeline - a task appears on our staging boards (prefixed `[SANDBOX]`) just like a production order would.
- Webhook endpoints registered with a sandbox key receive real, signed deliveries for sandbox events - the recommended way to develop and test your integration.
- No emails or Slack messages are ever sent from sandbox activity.

Two behaviors differ from production by design: status transitions in the sandbox are driven by the API itself (operational updates from our logistics team are not simulated), and `POST /equip` always resolves to `mode: "quoted"` rather than assigning a stored device.

## Rate limiting

Two sliding windows apply to every request: a per-IP limit of 60 requests/minute and a per-key limit (default 60 requests/minute). Every response includes `x-ratelimit-limit`, `x-ratelimit-remaining`, and `x-ratelimit-reset`; a `429` also includes `retry-after` (seconds). Tighter per-key overrides:

| Endpoint | Limit |
| --- | --- |
| `POST /quotes` | 20 / min |
| `POST /equip` | 20 / min |
| `PATCH /quotes/{id}/items/{itemId}` | 30 / min |
| `POST /quotes/{id}/items/{itemId}/action` | 30 / min |
| `POST /device-actions` | 30 / min |
| `POST /device-actions/external` | 30 / min |
| `POST /webhooks/{id}/test` | 30 / min |

## Pagination

List endpoints return a `data` array and a `meta` object, and paginate with keyset cursors: `meta.has_more` says whether another page exists, `meta.next_cursor` is an opaque cursor to pass back as `?cursor=`, and `meta.total` is the total row count for the current filters (`null` when unknown). `limit` defaults to 25 (max 100). Sortable lists take `sort` with a `-` prefix for descending (e.g. `-created_at`); a field outside the endpoint's allowlist returns `400 VALIDATION_ERROR`. `GET /products` is the exception - it is unpaginated and its `meta` carries `available_filters` instead.

```json
{
  "data": [],
  "meta": { "has_more": true, "next_cursor": "eyJpZCI6Ii4uLiJ9", "total": 42 }
}
```

## Errors

Errors use conventional HTTP status codes and a consistent body. `error.code` is a stable string you can branch on; `error.details` carries structured context and is present only when set.

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "items must be a non-empty array"
  }
}
```

| Code | Status | Meaning |
| --- | --- | --- |
| `UNAUTHORIZED` | 401 | Missing, malformed, invalid, expired, or revoked API key. |
| `FORBIDDEN` | 403 | The key is valid but lacks the required scope, or a required feature is not enabled for your company. |
| `VALIDATION_ERROR` | 400 | The request body or a query parameter failed validation. May carry a details payload. |
| `NOT_FOUND` | 404 | The resource does not exist. Ids that belong to another company also read as not found. |
| `CONFLICT` | 409 | The request conflicts with current state - e.g. a duplicate serial number, or an action already open on a device. |
| `RATE_LIMITED` | 429 | Too many requests. Wait for the retry-after header before retrying. |
| `INTERNAL_ERROR` | 500 | Unexpected server error. Safe to retry with backoff. |
| `BAD_GATEWAY` | 502 | An upstream dependency failed. Safe to retry with backoff. |

## Webhook signatures

Register endpoints with the Webhooks API. When an event fires, quipteams POSTs a JSON body `{ id, type, created_at, data }` to your URL with these headers: `X-Quip-Event`, `X-Quip-Delivery-Id`, and `X-Quip-Signature`. The signature is `sha256=<hex>`, an HMAC-SHA256 of the raw request body keyed on the endpoint's `whsec_` secret (returned once, when you create the endpoint). Verify it before trusting a payload:

```typescript
import { createHmac, timingSafeEqual } from 'crypto'

function verify(rawBody: string, header: string, secret: string): boolean {
  const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(header)
  return a.length === b.length && timingSafeEqual(a, b)
}
```

Event types:

- Quotes: `quote.created`, `quote.submitted`, `quote.status_changed`, `quote.pending`, `quote.accepted`, `quote.delivered`, `quote.alternative_offered`, `quote.response_added`, `quote.message`, `quote.action_required`, `quote.tracking_added`, `quote.serial_added`
- Device actions: `device_action.status`, `device_action.requested`, `device_action.completed`, `device_action.tracking_added`, `device_action.action_required`, `device_action.message`
- Scheduling: `scheduling.created`, `scheduling.submitted`, `scheduling.reminder`, `scheduling.cancelled`
- Invoices: `invoice.payment_marked`, `invoice.approved`
- Wildcards: `quote.*`, `device_action.*`, `scheduling.*`, `invoice.*`, `*`

## Quotes

Create equipment quotes from catalog products or kit bundles, list them, read full detail, and accept or reject individual items.

Scopes: `quotes:read`, `quotes:write`
### GET /quotes

List quotes

Returns quotes for your company, most recent first, with keyset cursor pagination. `items_count` is the number of items on each quote - read a single quote for the items themselves.

Scope: `quotes:read` · Success status: `200`

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | no | Filter by quote status (in_progress, pending, approved, shipping, delivered, cancelled, rejected). |
| `country` | string | no | Filter by quote country. Matches any spelling of the country (e.g. `AR` and `Argentina` both match). |
| `recipient_email` | string | no | Only quotes with at least one recipient whose email matches (case-insensitive exact match). |
| `created_after` | string | no | ISO date/datetime lower bound on created_at (inclusive). |
| `created_before` | string | no | ISO date/datetime upper bound on created_at (inclusive). |
| `sort` | string | no | One of: created_at, id, status, company_name. Prefix with `-` for descending. Default `-created_at`. Unknown fields return 400 VALIDATION_ERROR. |
| `limit` | number | no | Rows per page. Default 25, max 100. |
| `cursor` | string | no | Opaque keyset cursor - pass `meta.next_cursor` from the previous page. |

**Example response**

```json
{
  "data": [
    {
      "order_id": "1741345200000",
      "status": "in_progress",
      "countries": ["United States", "Mexico"],
      "created_at": "2026-02-15T10:30:00.000Z",
      "updated_at": "2026-02-18T14:22:00.000Z",
      "requester": { "email": "jane@acme.com", "name": "Jane Smith" },
      "items_count": 2,
      "po_number": "PO-9981"
    }
  ],
  "meta": { "has_more": false, "next_cursor": null, "total": 1 }
}
```

### POST /quotes

Create a quote

Creates a quote and starts fulfillment asynchronously (returns 202 with the full quote detail). Each item references EITHER a catalog `product_id` OR a `kit_id` - exactly one. Per item, the sum of recipient quantities must equal the item quantity.

Scope: `quotes:write` · Success status: `202` · Rate limit: 20 / min

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `requester_email` | string | yes | Email of the person requesting the quote. |
| `requester_name` | string | no | Requester display name. |
| `po_number` | string | no | Purchase order reference. |
| `notification_emails` | string[] | no | Extra addresses to notify about this quote. |
| `items` | object[] | yes | 1-50 items. Each has product_id OR kit_id, quantity, comments, and recipients[]. |
| `items[].recipients` | object[] | yes | name, country, address are required — unless office_id is set; email is also required unless the recipient is an office or storage. phone_number, hire_date, when_to_contact (YYYY-MM-DD), storage, quantity are optional. |
| `items[].recipients[].office_id` | string | no | Ship to a saved office (id from GET /api/v1/offices) — the office supplies the country and address, so sending either alongside it (or storage) is rejected. name, email and phone_number may still be supplied to name a person at that office. |

**Example request**

```json
{
  "requester_email": "jane@acme.com",
  "requester_name": "Jane Smith",
  "po_number": "PO-9981",
  "notification_emails": ["it@acme.com"],
  "items": [
    {
      "product_id": "6f9d0e2a-1b3c-4d5e-8f70-2a1b3c4d5e6f",
      "quantity": 2,
      "comments": "Ship together if possible",
      "recipients": [
        {
          "name": "Alex Doe",
          "email": "alex@acme.com",
          "country": "United States",
          "address": "123 Market St, San Francisco, CA 94103",
          "phone_number": "+1 415 555 0100",
          "when_to_contact": "2026-03-01",
          "quantity": 1
        },
        {
          "office_id": "3f2b9c4e-6a1d-4f8b-9c2e-7d5a1b3c9e4f",
          "quantity": 1
        }
      ]
    }
  ]
}
```

**Example response**

```json
{
  "data": {
    "order_id": "1741345200000",
    "status": "in_progress",
    "status_timestamps": { "in_progress": "2026-02-15T10:30:00.000Z" },
    "countries": ["United States"],
    "requester": { "email": "jane@acme.com", "name": "Jane Smith" },
    "items": [
      {
        "id": "8a2b1c3d-4e5f-6071-8293-a4b5c6d7e8f9",
        "product_type": "Laptop",
        "quantity": 2,
        "status": "pending",
        "price": null,
        "specification": {
          "brand": "Apple", "model": "MacBook Pro 14", "os": "macOS",
          "cpu": "M5 Pro", "ram": "18GB", "storage": "512GB",
          "screen_size": "14", "keyboard_layout": "US"
        },
        "comments": "Ship together if possible",
        "recipients": [
          {
            "id": "b1c2d3e4-f506-4718-92a3-b4c5d6e7f809",
            "name": "Alex Doe",
            "email": "alex@acme.com",
            "country": "United States",
            "status": "pending",
            "status_timestamps": {},
            "phone_number": "+1 415 555 0100",
            "address": "123 Market St, San Francisco, CA 94103",
            "storage": false,
            "serial_number": null,
            "tracking_code": null,
            "tracking_link": null,
            "invoice_number": null,
            "hire_date": null,
            "schedule_link": null,
            "is_office": false,
            "office_id": null
          }
        ],
        "alternatives": [],
        "created_at": "2026-02-15T10:30:00.000Z"
      }
    ],
    "created_at": "2026-02-15T10:30:00.000Z",
    "updated_at": "2026-02-15T10:30:00.000Z",
    "po_number": "PO-9981"
  }
}
```

### GET /quotes/{id}

Get a quote

Returns the full quote by order id or internal id, including items, recipients, and alternatives. Item status uses the legacy vocabulary (pending, accepted, rejected, unavailable). An id belonging to another company responds 404 - never 403.

Scope: `quotes:read` · Success status: `200`

**Example response**

```json
{
  "data": {
    "order_id": "1741345200000",
    "status": "in_progress",
    "status_timestamps": { "in_progress": "2026-02-15T10:30:00.000Z" },
    "countries": ["United States"],
    "requester": { "email": "jane@acme.com", "name": "Jane Smith" },
    "items": [
      {
        "id": "8a2b1c3d-4e5f-6071-8293-a4b5c6d7e8f9",
        "product_type": "Laptop",
        "quantity": 2,
        "status": "pending",
        "price": null,
        "specification": {
          "brand": "Apple", "model": "MacBook Pro 14", "os": "macOS",
          "cpu": "M5 Pro", "ram": "18GB", "storage": "512GB",
          "screen_size": "14", "keyboard_layout": "US"
        },
        "comments": "Ship together if possible",
        "recipients": [
          {
            "id": "b1c2d3e4-f506-4718-92a3-b4c5d6e7f809",
            "name": "Alex Doe",
            "email": "alex@acme.com",
            "country": "United States",
            "status": "pending",
            "status_timestamps": {},
            "phone_number": "+1 415 555 0100",
            "address": "123 Market St, San Francisco, CA 94103",
            "storage": false,
            "serial_number": null,
            "tracking_code": null,
            "tracking_link": null,
            "invoice_number": null,
            "hire_date": null,
            "schedule_link": null,
            "is_office": false,
            "office_id": null
          }
        ],
        "alternatives": [],
        "created_at": "2026-02-15T10:30:00.000Z"
      }
    ],
    "created_at": "2026-02-15T10:30:00.000Z",
    "updated_at": "2026-02-15T10:30:00.000Z",
    "po_number": "PO-9981"
  }
}
```

### POST /quotes/{id}/items/{itemId}/action

Accept or reject an item

Marks a quote item as accepted or rejected. `{itemId}` may be a line item id OR an alternative id (auto-detected). Accepting an item rejects its pending alternatives; accepting an alternative rejects its siblings.

Scope: `quotes:write` · Success status: `200` · Rate limit: 30 / min

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | "accept" | "reject" | yes | The decision. Case-sensitive - exactly `accept` or `reject`. |

**Example request**

```json
{ "action": "accept" }
```

**Example response**

```json
{
  "data": {
    "id": "8a2b1c3d-4e5f-6071-8293-a4b5c6d7e8f9",
    "type": "item",
    "status": "accepted"
  }
}
```

### PATCH /quotes/{id}/items/{itemId}

Decide an item with recipients

Same decision cascade as the action endpoint, with two extras: `alternative_id` targets one of the item's alternatives, and `recipients` (required on accept) replaces the item's recipient list before the decision runs.

Scope: `quotes:write` · Success status: `200` · Rate limit: 30 / min

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `action` | "accept" | "reject" | yes | The decision. Case-sensitive - exactly `accept` or `reject`. |
| `alternative_id` | string | no | Apply the decision to this alternative of the item (must belong to it). |
| `recipients` | object[] | no | Required when action is accept. Replaces the item recipients. Each needs `name` and `country` — or pass `office_id` (GET /api/v1/offices) to ship to a saved office instead. A slot that names no new office_id or address keeps an existing office destination. email, address, phone_number, phone_country_code, hire_date, storage, comments, recipient_id are optional. |

**Example request**

```json
{
  "action": "accept",
  "recipients": [
    {
      "name": "Alex Doe",
      "email": "alex@acme.com",
      "country": "United States",
      "address": "123 Market St, San Francisco, CA 94103"
    }
  ]
}
```

**Example response**

```json
{
  "data": {
    "id": "8a2b1c3d-4e5f-6071-8293-a4b5c6d7e8f9",
    "type": "item",
    "status": "accepted"
  }
}
```

## Assets

Read your company inventory devices - in storage, in use, or both - by list or by id / serial number.

Scopes: `assets:read`
### GET /assets

List assets

Returns inventory devices. `status` defaults to `in_storage`; `in_use` rows additionally carry `purchase_order` and `assigned_date`.

Scope: `assets:read` · Success status: `200`

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | no | in_storage (default), in_use, or all. |
| `country` | string | no | Filter by country. |
| `device_type` | string | no | Match on device_type or model (e.g. Laptop, Monitor). |
| `serial_number` | string | no | Filter by serial number. |
| `search` | string | no | Free-text search over serial, description, and employee. |
| `item_id` | string | no | Only devices linked to this quote item (UUID). |
| `updated_after` | string | no | ISO timestamp lower bound on updated_at. |
| `sort` | string | no | One of: created_at, updated_at, id, status, serial_number, device_type, country. Prefix with `-` for descending. Default `-created_at`. Unknown fields return 400 VALIDATION_ERROR. |
| `limit` | number | no | Rows per page. Default 25, max 100. |
| `cursor` | string | no | Opaque keyset cursor - pass `meta.next_cursor` from the previous page. |

**Example response**

```json
{
  "data": [
    {
      "id": "c3d4e5f6-0718-4293-a4b5-c6d7e8f90a1b",
      "serial_number": "C02X5K3JMD6T",
      "company": "Acme",
      "country": "United States",
      "device_type": "Laptop",
      "description": "MacBook Pro 14 M5 Pro",
      "employee": null,
      "condition": "Good",
      "status": "in_storage",
      "quantity": 1,
      "completion_date": "2026-01-20",
      "created_at": "2026-01-20T09:00:00.000Z",
      "updated_at": "2026-02-01T12:00:00.000Z"
    }
  ],
  "meta": { "has_more": false, "next_cursor": null, "total": 1 }
}
```

### GET /assets/{id_or_serial}

Get an asset

Returns a single asset. `{id_or_serial}` is matched as a UUID against the device id, otherwise looked up by serial number. Responds 403 if the asset belongs to another company.

Scope: `assets:read` · Success status: `200`

**Example response**

```json
{
  "data": {
    "id": "c3d4e5f6-0718-4293-a4b5-c6d7e8f90a1b",
    "serial_number": "C02X5K3JMD6T",
    "company": "Acme",
    "country": "United States",
    "device_type": "Laptop",
    "description": "MacBook Pro 14 M5 Pro",
    "employee": "Alex Doe",
    "condition": "Good",
    "status": "in_use",
    "quantity": 1,
    "completion_date": "2026-01-20",
    "created_at": "2026-01-20T09:00:00.000Z",
    "updated_at": "2026-02-01T12:00:00.000Z",
    "purchase_order": null,
    "assigned_date": "2026-01-20"
  }
}
```

## Devices

Add one or more devices to your company inventory. Each is created with status `in_use`.

Scopes: `assets:write`
### POST /devices

Add devices

Accepts a single device object, a bare array, or `{ "devices": [...] }` (max 100). A single-object body returns a single asset; a bulk body returns an array. A serial already registered to your company returns 409 CONFLICT.

Scope: `assets:write` · Success status: `201`

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `serial_number` | string | yes | Device serial number. |
| `device_type` | string | yes | e.g. Laptop, Monitor, Keyboard. |
| `country` | string | yes | Country the device is held in. |
| `description` | string | yes | Human-readable description, e.g. "MacBook Pro 14 M3". |
| `employee` | string | no | Assigned employee name. |
| `condition` | string | no | e.g. New, Good, Fair. |
| `cost` | number | no | Unit cost. Non-numeric values are rejected. Never echoed back. |

**Example request**

```json
{
  "devices": [
    {
      "serial_number": "C02X5K3JMD6T",
      "device_type": "Laptop",
      "country": "United States",
      "description": "MacBook Pro 14 M5 Pro",
      "condition": "Good",
      "cost": 2199
    }
  ]
}
```

**Example response**

```json
{
  "data": [
    {
      "id": "c3d4e5f6-0718-4293-a4b5-c6d7e8f90a1b",
      "serial_number": "C02X5K3JMD6T",
      "company": "Acme",
      "country": "United States",
      "device_type": "Laptop",
      "description": "MacBook Pro 14 M5 Pro",
      "employee": null,
      "condition": "Good",
      "status": "in_use",
      "quantity": 1,
      "completion_date": null,
      "created_at": "2026-02-20T08:00:00.000Z",
      "updated_at": "2026-02-20T08:00:00.000Z",
      "purchase_order": null,
      "assigned_date": "2026-02-20"
    }
  ]
}
```

## Device actions

Logistics operations on devices: Store, Assign, Reassign, Sell, Support. Use `/device-actions` for a device already in inventory, or `/device-actions/external` for one that is not.

Scopes: `device-actions:read`, `device-actions:write`
### GET /device-actions

List device actions

Returns device actions for your company, most recent first, with keyset cursor pagination. `status` uses the legacy display vocabulary (In Progress, Coordinated, Completed, Cancelled).

Scope: `device-actions:read` · Success status: `200`

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | no | In Progress, Coordinated, Completed, or Cancelled (case-insensitive; `in_progress` also works). |
| `action_type` | string | no | Store, Assign, Reassign, Sell, or Support. |
| `serial_number` | string | no | Substring match on the device serial number. |
| `recipient_email` | string | no | Case-insensitive exact match on the current user OR new recipient email. |
| `created_after` | string | no | ISO lower bound on created_at (inclusive). |
| `created_before` | string | no | ISO upper bound on created_at (inclusive). |
| `include_asset` | boolean | no | When true, embeds the matching inventory device as `asset` on each row. |
| `sort` | string | no | One of: created_at, id, status, action_type. Prefix with `-` for descending. Default `-created_at`. Unknown fields return 400 VALIDATION_ERROR. |
| `limit` | number | no | Rows per page. Default 25, max 100. |
| `cursor` | string | no | Opaque keyset cursor - pass `meta.next_cursor` from the previous page. |

**Example response**

```json
{
  "data": [
    {
      "id": "e5f60718-2934-4b5c-8d6e-7f8091a2b3c4",
      "action_id": "AST74830192",
      "action_type": "Assign",
      "status": "In Progress",
      "company": "Acme",
      "device": {
        "serial_number": "C02X5K3JMD6T",
        "model": "MacBook Pro 14",
        "type": "Laptop",
        "country": "United States"
      },
      "current_user": {
        "name": "Alex Doe",
        "email": "alex@acme.com",
        "country": "United States",
        "address": "123 Market St, San Francisco, CA 94103",
        "phone": null,
        "notification_date": null,
        "is_office": false,
        "office_id": null,
        "office_name": null,
        "contact_name": null
      },
      "new_recipient": {
        "name": "Sam Lee",
        "email": "sam@acme.com",
        "country": "United States",
        "address": "500 Congress Ave, Austin, TX 78701",
        "phone": null,
        "notification_date": null,
        "is_office": false,
        "office_id": null,
        "office_name": null,
        "contact_name": null
      },
      "wipe": false,
      "wipe_completed": false,
      "tracking_code": null,
      "tracking_link": null,
      "created_at": "2026-02-19T15:00:00.000Z",
      "updated_at": "2026-02-19T15:00:00.000Z"
    }
  ],
  "meta": { "has_more": false, "next_cursor": null, "total": 1 }
}
```

### POST /device-actions

Create a device action

Creates an action for a device already in your inventory (looked up by serial). `Assign`/`Reassign` require `new_recipient`. Returns 409 if an action is already open on the device (or a wipe is pending, for any action except Sell). Starts fulfillment asynchronously (202) and returns the detail shape with `comments: []`.

Scope: `device-actions:write` · Success status: `202` · Rate limit: 30 / min

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `serial_number` | string | yes | Serial of a device in your inventory. |
| `action_type` | string | yes | Store, Assign, Reassign, Sell, or Support. |
| `current_user` | object | no | Current holder ({ name, email, country?, address?, phone?, notification_date? }). Required unless the device is in storage. Or pass { office_id } to name a saved office (GET /api/v1/offices) — the office supplies country + address (rejected alongside it), must sit in the device's country, and needs a contact email on file or an email in this object. |
| `new_recipient` | object | no | New holder ({ name, email, ..., notification_date? }) — or { office_id } for a saved office, same rules as current_user. Required for Assign/Reassign. When every side is an office, the action lands coordinated with no scheduling form (the address is on file). |
| `wipe` | boolean | no | Request a remote wipe. |
| `return_box` | boolean | no | Include a return box. |
| `notes` | string | no | Free-text notes. |
| `notification_emails` | string[] | no | Extra addresses to notify. |

**Example request**

```json
{
  "serial_number": "C02X5K3JMD6T",
  "action_type": "Reassign",
  "current_user": {
    "name": "Alex Doe",
    "email": "alex@acme.com",
    "country": "United States",
    "address": "123 Market St, San Francisco, CA 94103"
  },
  "new_recipient": {
    "name": "Sam Lee",
    "email": "sam@acme.com",
    "country": "United States",
    "address": "500 Congress Ave, Austin, TX 78701"
  },
  "wipe": true,
  "notes": "Employee change - wipe before reassigning"
}
```

**Example response**

```json
{
  "data": {
    "id": "e5f60718-2934-4b5c-8d6e-7f8091a2b3c4",
    "action_id": "AST74830192",
    "action_type": "Reassign",
    "status": "In Progress",
    "company": "Acme",
    "device": {
      "serial_number": "C02X5K3JMD6T",
      "model": "MacBook Pro 14 M5 Pro",
      "type": "Laptop",
      "country": "United States"
    },
    "current_user": {
      "name": "Alex Doe",
      "email": "alex@acme.com",
      "country": "United States",
      "address": "123 Market St, San Francisco, CA 94103",
      "phone": null,
      "notification_date": null
    },
    "new_recipient": {
      "name": "Sam Lee",
      "email": "sam@acme.com",
      "country": "United States",
      "address": "500 Congress Ave, Austin, TX 78701",
      "phone": null,
      "notification_date": null
    },
    "wipe": true,
    "wipe_completed": false,
    "tracking_code": null,
    "tracking_link": null,
    "created_at": "2026-02-19T15:00:00.000Z",
    "updated_at": "2026-02-19T15:00:00.000Z",
    "notes": "Employee change - wipe before reassigning",
    "status_timestamps": { "in_progress": "2026-02-19T15:00:00.000Z" },
    "notification_emails": [],
    "batch_id": null,
    "requested_by": { "name": "Alex Doe", "email": "alex@acme.com" },
    "user_email": "alex@acme.com",
    "comments": []
  }
}
```

### POST /device-actions/external

Create an external device action

Like `POST /device-actions`, but for a device that is NOT in your inventory. Requires device metadata (model or description, type or device_type, country) and always requires `current_user`. Returns 400 if the serial is actually in inventory. Responds 202 with the same detail shape.

Scope: `device-actions:write` · Success status: `202` · Rate limit: 30 / min

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `serial_number` | string | yes | Serial of a device NOT in your inventory. |
| `action_type` | string | yes | Store, Assign, Reassign, Sell, or Support. |
| `current_user` | object | yes | Current holder ({ name, email, ..., notification_date? }) — or { office_id } for a saved office. |
| `new_recipient` | object | no | Required for Assign/Reassign. Accepts { office_id } like current_user. |
| `model` | string | no | Device model. Provide model OR description. |
| `description` | string | no | Device description. Provide model OR description. |
| `type` | string | no | Device type. Provide type OR device_type. |
| `device_type` | string | no | Device type. Provide type OR device_type. |
| `country` | string | yes | Country the device is in. |
| `wipe` | boolean | no | Request a remote wipe. |
| `return_box` | boolean | no | Include a return box. |
| `notes` | string | no | Free-text notes. |
| `notification_emails` | string[] | no | Extra addresses to notify. |

**Example request**

```json
{
  "serial_number": "5CD1234ABC",
  "action_type": "Support",
  "device_type": "Laptop",
  "model": "Dell XPS 13",
  "country": "United Kingdom",
  "current_user": {
    "name": "Jordan Price",
    "email": "jordan@acme.com",
    "country": "United Kingdom",
    "address": "10 King St, London EC2V 8EA"
  },
  "notes": "Keyboard replacement"
}
```

### GET /device-actions/{id}

Get a device action

Returns a single action by customer-facing action id (AST...) or internal id. The detail shape adds notes, status_timestamps, notification_emails, batch_id, requested_by, user_email, and comments[]. Pass `include_asset=true` to embed the linked inventory device as `asset` (null when unmatched). An id belonging to another company responds 404.

Scope: `device-actions:read` · Success status: `200`

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `include_asset` | boolean | no | When true, embeds the linked device as `asset`. |

## Products

Read the shared product catalog and its configurations. The catalog is global (no company scoping) and the list is unpaginated.

Scopes: `products:read`
### GET /products

List products

Returns active products by default, with only their active, quotable configurations. Spec filters take comma-separated values (OR within a key, AND across keys, case-insensitive substring match); products left with no matching configuration drop out. `meta.available_filters` lists every filterable value in the catalog.

Scope: `products:read` · Success status: `200`

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `product_type` | string | no | Filter by type (e.g. Laptop, Monitor). |
| `brand` | string | no | Filter by brand (exact match). |
| `search` | string | no | Free-text search over brand and model. |
| `include_inactive` | boolean | no | When true, includes inactive products. |
| `cpu` | string | no | Spec filter, e.g. `cpu=M3,M4`. Same for ram, storage, screen_size, resolution, connection_type. |
| `ram` | string | no | Spec filter (comma-separated values). |
| `storage` | string | no | Spec filter (comma-separated values). |
| `screen_size` | string | no | Spec filter (comma-separated values). |
| `resolution` | string | no | Spec filter (comma-separated values). |
| `connection_type` | string | no | Spec filter (comma-separated values). |

**Example response**

```json
{
  "data": [
    {
      "id": "6f9d0e2a-1b3c-4d5e-8f70-2a1b3c4d5e6f",
      "product_type": "Laptop",
      "brand": "Apple",
      "model": "MacBook Pro 14",
      "image_url": "https://cdn.quipteams.com/products/mbp14.png",
      "is_active": true,
      "configurations": [
        {
          "id": "1a2b3c4d-5e6f-4708-9192-a3b4c5d6e7f8",
          "cpu": "M5 Pro", "ram": "18GB", "storage": "512GB",
          "screen_size": "14", "series_variant": null, "resolution": null,
          "connection_type": null, "layout": "US", "category": null
        }
      ]
    }
  ],
  "meta": {
    "available_filters": {
      "product_types": ["Laptop", "Monitor"],
      "brands": ["Apple", "Dell"],
      "cpu": ["M5 Pro", "M5"],
      "ram": ["18GB", "32GB"],
      "storage": ["512GB", "1TB"],
      "screen_size": ["14", "16"],
      "resolution": ["4K"],
      "connection_type": ["USB-C"]
    }
  }
}
```

### GET /products/{id}

Get a product

Returns a single product with its active configurations. Any miss (including a malformed id) responds 404 "Product not found".

Scope: `products:read` · Success status: `200`

## Kits

Read kit bundles (reusable equipment sets) and the devices they contain.

Scopes: `kits:read`
### GET /kits

List kits

Returns kits for your company with keyset cursor pagination. List rows carry `devices_count` - read a single kit for the devices themselves.

Scope: `kits:read` · Success status: `200`

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `search` | string | no | Filter by kit name. |
| `tag` | string | no | Filter by tag. |
| `region` | string | no | Filter by region. |
| `sort` | string | no | One of: created_at, updated_at, name. Prefix with `-` for descending. Default `-created_at`. Unknown fields return 400 VALIDATION_ERROR. |
| `limit` | number | no | Rows per page. Default 25, max 100. |
| `cursor` | string | no | Opaque keyset cursor - pass `meta.next_cursor` from the previous page. |

**Example response**

```json
{
  "data": [
    {
      "id": "7a8b9c0d-1e2f-4304-9516-273849a5b6c7",
      "name": "Engineer Starter Kit",
      "tags": ["engineering", "standard"],
      "region": "AMER",
      "departments": ["Engineering"],
      "devices_count": 2,
      "created_at": "2026-01-05T00:00:00.000Z",
      "updated_at": "2026-01-05T00:00:00.000Z"
    }
  ],
  "meta": { "has_more": false, "next_cursor": null, "total": 1 }
}
```

### GET /kits/{id}

Get a kit

Returns a single kit with its devices. Each device carries a sparse `specification` object (camelCase keys - only set values appear). An id belonging to another company responds 404.

Scope: `kits:read` · Success status: `200`

**Example response**

```json
{
  "data": {
    "id": "7a8b9c0d-1e2f-4304-9516-273849a5b6c7",
    "name": "Engineer Starter Kit",
    "tags": ["engineering", "standard"],
    "region": "AMER",
    "departments": ["Engineering"],
    "devices": [
      {
        "id": "0d1e2f30-4152-4637-a8b9-c0d1e2f30415",
        "device_type": "Laptop",
        "specification": {
          "model": "MacBook Pro 14",
          "company": "Apple",
          "cpu": "M5 Pro",
          "ram": "18GB",
          "storage": "512GB",
          "screenSize": "14"
        }
      }
    ],
    "created_at": "2026-01-05T00:00:00.000Z",
    "updated_at": "2026-01-05T00:00:00.000Z"
  }
}
```

## Roles

Read company defined roles. A role bundles one or more kits and is the input to equip by role.

Scopes: `roles:read`
### GET /roles

List roles

Returns roles for your company with keyset cursor pagination. List rows carry `kit_ids`. Read a single role for kit names.

Scope: `roles:read` · Success status: `200`

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `search` | string | no | Filter by role name. |
| `sort` | string | no | One of: created_at, updated_at, name. Prefix with `-` for descending. Default `-created_at`. Unknown fields return 400 VALIDATION_ERROR. |
| `limit` | number | no | Rows per page. Default 25, max 100. |
| `cursor` | string | no | Opaque keyset cursor - pass `meta.next_cursor` from the previous page. |

**Example response**

```json
{
  "data": [
    {
      "id": "3c4d5e6f-7a8b-4910-a1b2-c3d4e5f60718",
      "name": "Engineer",
      "kit_ids": ["7a8b9c0d-1e2f-4304-9516-273849a5b6c7"],
      "created_at": "2026-01-05T00:00:00.000Z",
      "updated_at": "2026-01-05T00:00:00.000Z"
    }
  ],
  "meta": { "has_more": false, "next_cursor": null, "total": 1 }
}
```

### GET /roles/{id}

Get a role

Returns a single role with its kits. An id belonging to another company responds 404.

Scope: `roles:read` · Success status: `200`

**Example response**

```json
{
  "data": {
    "id": "3c4d5e6f-7a8b-4910-a1b2-c3d4e5f60718",
    "name": "Engineer",
    "kits": [
      { "id": "7a8b9c0d-1e2f-4304-9516-273849a5b6c7", "name": "Engineer Starter Kit" }
    ],
    "created_at": "2026-01-05T00:00:00.000Z",
    "updated_at": "2026-01-05T00:00:00.000Z"
  }
}
```

## Equip

Equip one employee with a saved kit in a single call. When every device in the kit has a new unit stored in the employee's region, the endpoint assigns those exact devices and sends the recipient a scheduling link. Used or returned warehouse stock is never assigned. When anything is missing it orders the whole kit as a quote instead — all-or-nothing, so a partially-stocked kit never splits into a half-shipment plus a half-order.

Scopes: `equip:write`
### POST /equip

Equip an employee with a kit

Equips an employee with a saved kit. If a new stored device is available for every item in their region, the devices are assigned; otherwise, a quote is created for the full kit. Used or returned units are never assigned. Use `dry_run: true` to check availability without creating anything.

Scope: `equip:write` · Success status: `202` · Rate limit: 20 / min

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `kit_id` | string | yes | Kit to equip. Must belong to your company. |
| `recipient.name` | string | yes | Employee full name. Optional when office_id is set. |
| `recipient.email` | string | yes | Employee email — receives the scheduling link. Optional when office_id is set and the office has a contact email on file. |
| `recipient.country` | string | yes | ISO-3166 alpha-2 code or country name. Drives stock availability; regional aliases like "EU" are rejected. Rejected alongside office_id — the office supplies it. |
| `recipient.office_id` | string | no | Ship the kit to a saved office (id from GET /api/v1/offices) instead of a person. The office supplies country + address (rejected alongside them, and alongside national_id). Stock is drawn only from the office's own country — no EU-wide pooling — and no scheduling form is sent: the address is already on file. |
| `recipient.address` | string | no | Delivery address, if known. |
| `recipient.phone` | string | no | Contact phone number. |
| `recipient.phone_country_code` | string | no | Dial code, combined with `phone` when both are present. |
| `recipient.national_id` | string | no | Government ID (CPF / DNI / SSN) where customs requires it. Persisted on the operation; on the quote branch it is passed to ops as a note. |
| `recipient.when_to_contact` | string | no | YYYY-MM-DD — holds the scheduling link until this date. |
| `recipient.hire_date` | string | no | YYYY-MM-DD start date. Persisted on the quote branch. |
| `recipient.comments` | string | no | Notes for the logistics team. |
| `requester_email` | string | no | Who to attribute the request to. Defaults to the API key creator. |
| `requester_name` | string | no | Display name on the quote, when one is created. |
| `notification_emails` | string[] | no | Extra addresses copied on the resulting operation or quote. |
| `dry_run` | boolean | no | Preview availability only. Nothing is created; responds 200. |

**Example request**

```json
{
  "kit_id": "8c1f2d3e-4a5b-4c6d-9e70-1f2a3b4c5d6e",
  "recipient": {
    "name": "Ana Silva",
    "email": "ana.silva@acme.com",
    "country": "ES",
    "address": "Calle Gran Via 1, Madrid",
    "phone": "600123456",
    "phone_country_code": "+34",
    "when_to_contact": "2026-08-03"
  },
  "requester_email": "it@acme.com"
}
```

**Example response**

```json
{
  "data": {
    "mode": "assigned",
    "kit": { "id": "8c1f2d3e-4a5b-4c6d-9e70-1f2a3b4c5d6e", "name": "Engineering Standard" },
    "device_action": {
      "id": "3d4e5f60-7a8b-4c9d-8e1f-2a3b4c5d6e7f",
      "action_id": "AST12345678",
      "action_type": "Assign",
      "status": "in_progress",
      "device": { "serial_number": "C02XY1234ABC", "model": "Apple MacBook Pro 14" },
      "new_recipient": { "name": "Ana Silva", "email": "ana.silva@acme.com", "country": "ES" }
    },
    "lines": [
      {
        "kit_device_id": "1b2c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d",
        "item": "Apple MacBook Pro 14",
        "needed": 1,
        "available": 3,
        "reason": null
      }
    ]
  }
}
```

### POST /equip-by-role

Equip an employee by role

Equips an employee with one of a role's kits. Nothing is assigned or quoted at call time: the employee receives a scheduling link where they confirm their details and, when the role has more than one kit, pick the kit they want. The stock check runs when they submit the form. Devices in stock are assigned; otherwise the whole kit is quoted. A role with no kits answers 400 and creates nothing.

Scope: `equip:write` · Success status: `202` · Rate limit: 20 / min

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `role_id` | string | yes | Role to equip from. Must belong to your company and have at least one kit. |
| `recipient.office_id` | string | no | Not supported here — this endpoint emails a person a scheduling form. Use POST /equip with recipient.office_id to send a kit to an office. |
| `recipient.name` | string | yes | Employee full name. |
| `recipient.email` | string | yes | Employee email — receives the scheduling link. |
| `recipient.country` | string | yes | ISO-3166 alpha-2 code or country name. Sets the stock region for the equip decision, so the employee sees it prefilled and read-only on the scheduling form. |
| `recipient.address` | string | no | Delivery address, if known. |
| `recipient.phone` | string | no | Contact phone number. |
| `recipient.phone_country_code` | string | no | Dial code for the phone number. |
| `recipient.national_id` | string | no | Government ID (CPF / DNI / SSN) where customs requires it. |
| `recipient.when_to_contact` | string | no | YYYY-MM-DD — holds the scheduling link email until this date. |
| `recipient.hire_date` | string | no | YYYY-MM-DD start date. |
| `recipient.comments` | string | no | Notes for the logistics team. |
| `requester_email` | string | no | Who to attribute the request to. Defaults to the API key creator. |
| `requester_name` | string | no | Display name on the quote, when one is created. |
| `notification_emails` | string[] | no | Extra addresses copied on the resulting operation or quote. |

**Example request**

```json
{
  "role_id": "3c4d5e6f-7a8b-4910-a1b2-c3d4e5f60718",
  "recipient": {
    "name": "Ana Silva",
    "email": "ana.silva@acme.com",
    "country": "ES"
  },
  "requester_email": "it@acme.com"
}
```

**Example response**

```json
{
  "data": {
    "mode": "scheduling_requested",
    "role": { "id": "3c4d5e6f-7a8b-4910-a1b2-c3d4e5f60718", "name": "Engineer" },
    "kit_count": 2,
    "kit_choice_required": true,
    "scheduling_request": {
      "id": "9e8d7c6b-5a49-4382-b1a0-f9e8d7c6b5a4",
      "status": "pending_notification",
      "scheduling_url": "https://app.quipteams.com/scheduling/xK3...",
      "schedule_link": "https://app.quipteams.com/scheduling/xK3...?code=A7K2MP",
      "access_code": "A7K2MP",
      "email_dispatched": true,
      "notification_scheduled_for": null,
      "expires_at": null
    }
  }
}
```

## Employees

Read HRIS-synced employees for your company.

Scopes: `employees:read`
### GET /employees

List employees

Returns employees synced from your connected HRIS, ordered by display name, with keyset cursor pagination. `sort` is accepted but ignored. `work_location` is the HRIS location object (or null).

Scope: `employees:read` · Success status: `200`

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | no | Filter by employment_status (e.g. ACTIVE, TERMINATED). |
| `search` | string | no | Free-text search over name, email, and job title. |
| `limit` | number | no | Rows per page. Default 25, max 100. |
| `cursor` | string | no | Opaque keyset cursor - pass `meta.next_cursor` from the previous page. |

**Example response**

```json
{
  "data": [
    {
      "id": "2f304152-4637-4a8b-9c0d-1e2f30415263",
      "remote_id": "wf_9931",
      "first_name": "Alex",
      "last_name": "Doe",
      "display_name": "Alex Doe",
      "email": "alex@acme.com",
      "employment_status": "ACTIVE",
      "start_date": "2025-11-03",
      "termination_date": null,
      "job_title": "Software Engineer",
      "department": "Engineering",
      "work_location": { "name": "San Francisco", "country": "US" },
      "company_name": "Acme"
    }
  ],
  "meta": { "has_more": false, "next_cursor": null, "total": 1 }
}
```

### GET /employees/{id}

Get an employee

Returns a single employee. An id belonging to another company responds 404.

Scope: `employees:read` · Success status: `200`

## Offices

Read your company offices (used as delivery destinations). Pass an office id as office_id on a quote, device-action, or equip recipient to ship there — the office supplies the country and address, and no scheduling form is sent.

Scopes: `offices:read`
### GET /offices

List offices

Returns offices for your company, office name ascending by default, with keyset cursor pagination.

Scope: `offices:read` · Success status: `200`

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `country` | string | no | Filter by country (exact match). |
| `search` | string | no | Free-text search over office name and address. |
| `sort` | string | no | One of: created_at, office_name, country. Prefix with `-` for descending. Default `office_name`. Unknown fields return 400 VALIDATION_ERROR. |
| `limit` | number | no | Rows per page. Default 25, max 100. |
| `cursor` | string | no | Opaque keyset cursor - pass `meta.next_cursor` from the previous page. |

**Example response**

```json
{
  "data": [
    {
      "id": "41524637-a8b9-4c0d-9e2f-304152637485",
      "office_name": "HQ",
      "country": "United States",
      "address": "123 Market St, San Francisco, CA, 94103",
      "contact_name": "Jane Smith",
      "email": "office@acme.com",
      "phone": "415 555 0100",
      "phone_country_code": "+1",
      "tax_id": null,
      "created_at": "2025-09-01T00:00:00.000Z",
      "updated_at": "2025-09-01T00:00:00.000Z"
    }
  ],
  "meta": { "has_more": false, "next_cursor": null, "total": 1 }
}
```

## Scheduling links

Read delivery scheduling links sent to recipients. This resource is read-only, and requires the `scheduling` and `scheduling_self_managed` features on your company (403 FORBIDDEN otherwise).

Scopes: `scheduling-links:read`
### GET /scheduling-links

List scheduling links

Returns scheduling requests for your company with keyset cursor pagination. `source_type` is `quote` (with `quote_id` + `item_recipient_id` set) or `device_action` (with `device_action_id` set).

Scope: `scheduling-links:read` · Success status: `200`

**Query parameters**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | no | One of: pending, pending_notification, submitted, completed, expired. Anything else returns 400. |
| `source_type` | string | no | `quote` or `device_action`. Anything else returns 400. |
| `recipient_email` | string | no | Filter by recipient email (exact match). |
| `sort` | string | no | One of: created_at, updated_at, id, status. Prefix with `-` for descending. Default `-created_at`. Unknown fields return 400 VALIDATION_ERROR. |
| `limit` | number | no | Rows per page. Default 25, max 100. |
| `cursor` | string | no | Opaque keyset cursor - pass `meta.next_cursor` from the previous page. |

**Example response**

```json
{
  "data": [
    {
      "id": "51634637-a8b9-4c0d-9e2f-304152637485",
      "status": "pending",
      "source_type": "quote",
      "quote_id": "d4e5f607-1829-4a3b-9c4d-5e6f70819243",
      "device_action_id": null,
      "item_recipient_id": "b1c2d3e4-f506-4718-92a3-b4c5d6e7f809",
      "recipient": {
        "name": "Sam Lee",
        "email": "sam@acme.com",
        "country": "United States",
        "phone": "415 555 0100",
        "phone_country_code": "+1",
        "address": "500 Congress Ave, Austin, TX 78701",
        "personal_id": null
      },
      "scheduling_url": "https://app.quipteams.com/schedule/abc123token",
      "schedule_link": "https://app.quipteams.com/schedule/abc123token?code=4821",
      "access_code": "4821",
      "scheduling_email_target": "employee",
      "notification_scheduled_for": "2026-02-21T09:00:00.000Z",
      "notification_sent_at": "2026-02-21T09:00:03.000Z",
      "submitted_at": null,
      "expires_at": "2026-03-07T09:00:00.000Z",
      "created_at": "2026-02-20T18:00:00.000Z",
      "updated_at": "2026-02-21T09:00:03.000Z"
    }
  ],
  "meta": { "has_more": false, "next_cursor": null, "total": 1 }
}
```

### GET /scheduling-links/{id}

Get a scheduling link

Returns a single scheduling link. Any miss (unknown id, malformed id, or another company's link) responds 404 "Scheduling link not found".

Scope: `scheduling-links:read` · Success status: `200`

## Webhooks

Register HTTPS endpoints to receive events. Each company may register up to 10 endpoints. See Webhook signatures for how deliveries are signed.

Scopes: `webhooks:read`, `webhooks:write`
### GET /webhooks

List webhook endpoints

Returns all webhook endpoints for your company.

Scope: `webhooks:read` · Success status: `200`

**Example response**

```json
{
  "data": [
    {
      "id": "62744637-a8b9-4c0d-9e2f-304152637485",
      "url": "https://example.com/hooks/quipteams",
      "events": ["quote.*", "device_action.status"],
      "description": "Production order + logistics events",
      "is_active": true,
      "created_by": "api",
      "created_at": "2026-02-01T00:00:00.000Z",
      "updated_at": "2026-02-01T00:00:00.000Z"
    }
  ],
  "meta": { "total": 1 }
}
```

### POST /webhooks

Create a webhook endpoint

Registers an endpoint. The signing `secret` (prefix `whsec_`) is returned exactly once in this response - store it now; it is never shown again. `url` must be HTTPS (localhost is allowed for testing).

Scope: `webhooks:write` · Success status: `201`

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string | yes | HTTPS callback URL. |
| `events` | string[] | yes | One or more event names or wildcards (see Webhook signatures / event list). |
| `description` | string | no | Optional label. |

**Example request**

```json
{
  "url": "https://example.com/hooks/quipteams",
  "events": ["quote.*", "device_action.status"],
  "description": "Production order + logistics events"
}
```

**Example response**

```json
{
  "data": {
    "id": "62744637-a8b9-4c0d-9e2f-304152637485",
    "url": "https://example.com/hooks/quipteams",
    "events": ["quote.*", "device_action.status"],
    "description": "Production order + logistics events",
    "is_active": true,
    "created_by": "api",
    "created_at": "2026-02-01T00:00:00.000Z",
    "secret": "whsec_3f9a...store-this-now...c71e"
  }
}
```

### GET /webhooks/{id}

Get a webhook endpoint

Returns the endpoint plus its 20 most recent delivery attempts.

Scope: `webhooks:read` · Success status: `200`

**Example response**

```json
{
  "data": {
    "id": "62744637-a8b9-4c0d-9e2f-304152637485",
    "url": "https://example.com/hooks/quipteams",
    "events": ["quote.*", "device_action.status"],
    "description": "Production order + logistics events",
    "is_active": true,
    "created_by": "api",
    "created_at": "2026-02-01T00:00:00.000Z",
    "updated_at": "2026-02-01T00:00:00.000Z",
    "recent_deliveries": [
      {
        "id": "73854152-a8b9-4c0d-9e2f-304152637485",
        "event_type": "quote.created",
        "status": "success",
        "attempts": 1,
        "last_attempt_at": "2026-02-15T10:30:01.000Z",
        "last_response_status": null,
        "created_at": "2026-02-15T10:30:00.000Z"
      }
    ]
  }
}
```

### PATCH /webhooks/{id}

Update a webhook endpoint

Updates any of url, events, description, or is_active. Omitted fields are unchanged.

Scope: `webhooks:write` · Success status: `200`

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string | no | New HTTPS callback URL. |
| `events` | string[] | no | Replacement event list (non-empty, all valid). |
| `description` | string | no | New label. |
| `is_active` | boolean | no | Enable or pause deliveries. |

**Example request**

```json
{ "is_active": false }
```

### DELETE /webhooks/{id}

Delete a webhook endpoint

Removes the endpoint.

Scope: `webhooks:write` · Success status: `200`

**Example response**

```json
{
  "data": {
    "id": "62744637-a8b9-4c0d-9e2f-304152637485",
    "deleted": true
  }
}
```

### POST /webhooks/{id}/test

Test a webhook endpoint

Sends a signed `webhook.test` payload to the endpoint and reports the result. This does not record a delivery. Always responds 200.

Scope: `webhooks:write` · Success status: `200` · Rate limit: 30 / min

**Example response**

```json
{
  "data": {
    "success": true,
    "endpoint_id": "62744637-a8b9-4c0d-9e2f-304152637485",
    "response_status": 200,
    "response_body": "ok",
    "latency_ms": 142
  }
}
```
