---
agentTools:
  projectIndex: https://docs.oway.io/llms.txt
---

# Structured Appointments are here

The Oway Shipper API now accepts and returns first-class appointment metadata on every stop, on every appointment-required shipment.

You tell us **how** to book, **where** to book, **who** to call, and **what** to say. We pass it straight to the carrier the moment they accept the load, so the driver shows up to the right gate, with the right paperwork, at the right time. No back-and-forth, no Slack threads, no missed appointments.

This release is fully backwards-compatible. Nothing in your existing integration breaks. Send the new fields when you have them; leave them out when you don't.

***

## What you can do now

### Capture appointment data at order creation

Add an `appointments` block to `POST /v1/shipper/shipment` and ship a fully-described load in a single call:

```json
POST /v1/shipper/shipment
{
  "pickupAddressData":   { "...": "..." },
  "deliveryAddressData": { "...": "..." },
  "...":                 "...other order fields",

  "appointments": {
    "pickup": {
      "channel":          "PHONE",
      "contacts": [
        { "name": "Maria Lopez", "phone": "+15555550101", "role": "Warehouse Manager" }
      ],
      "instructions":     "Driver must check in at gate B.",
      "referenceNumbers": ["PO-12345"],
      "leadTimeHours":    24
    },
    "delivery": {
      "channel":          "PORTAL",
      "portalUrl":        "https://retaillink.example.com/scheduling",
      "contacts": [
        { "name": "Site Receiving", "email": "receiving@bigboxretailer.com" }
      ],
      "instructions":     "Schedule via portal at least 24 hours in advance.",
      "referenceNumbers": ["81CRR", "DOCK-12"],
      "leadTimeHours":    24
    }
  }
}
```

Each entry is applied only if that stop's address has `appointmentRequired: true`. We validate every field up front. If anything is malformed, the order is not created and you get a `400` listing each violation, so you never end up with half-saved appointment data.

### Update appointment data after the fact

```
PUT /v1/shipper/shipment/{orderNumber}/appointment/{stop}
GET /v1/shipper/shipment/{orderNumber}/appointment/{stop}
```

`{stop}` is `pickup` or `delivery`. The PUT body is the same shape as the `appointments.pickup` block above. The whole requirement is replaced on each call (idempotent, not a partial merge), so you can safely retry. Both endpoints respond with the **effective merged requirement** (more on that below).

### Attach supporting documents

```
POST   /v1/shipper/shipment/{orderNumber}/appointment/{stop}/document/{type}
DELETE /v1/shipper/shipment/{orderNumber}/appointment/{stop}/document/{type}
```

Where `type` is `PACKING_SLIP` or `CUSTOMER_BOL`.

* `Content-Type: multipart/form-data`, field name `file`.
* Accepts `application/pdf`, `image/jpeg`, `image/png`. Images are auto-wrapped to a single-page PDF on upload, so the carrier always receives a PDF.
* 10 MB max.
* Re-posting the same `(stop, type)` replaces the prior file. DELETE is idempotent (always `204`).

***

## The `AppointmentRequirement` shape

```json json
{
  "channel":     "PHONE | EMAIL | PORTAL",       // how the appointment is booked
  "portalUrl":   "https://...",                  // required when channel == PORTAL
  "contacts": [
    {
      "name":    "Required",
      "email":   "optional",
      "phone":   "+15555550101",                 // E.164
      "role":    "Warehouse Manager"             // free text
    }
  ],
  "instructions":     "Free text up to 2000 chars",
  "referenceNumbers": ["PO-12345", "DOCK-12"],   // up to 10 entries, 64 chars each
  "leadTimeHours":    24                          // 0 to 168
}
```

Field rules (all return `400` with structured `violations[]`):

| Field              | Rule                                                                                               |
| ------------------ | -------------------------------------------------------------------------------------------------- |
| `channel`          | required                                                                                           |
| `portalUrl`        | required when `channel == PORTAL`; must be `https://...`; max 512 chars; must be omitted otherwise |
| `contacts[].name`  | required                                                                                           |
| `contacts[]`       | at least one of `email` or `phone`                                                                 |
| `contacts[].email` | standard email format                                                                              |
| `contacts[].phone` | E.164 (`+` then 8 to 15 digits)                                                                    |
| `instructions`     | max 2000 chars                                                                                     |
| `referenceNumbers` | up to 10 entries, each up to 64 chars                                                              |
| `leadTimeHours`    | integer in `[0, 168]`                                                                              |

***

## Confirm-time enforcement

This is the most important change for shippers with `appointmentRequired` loads.

> An order with `appointmentRequired: true` on a stop is **blocked from confirming** until that stop has a valid appointment requirement.

If your confirm call hits a stop with missing or malformed appointment data, you get a `400` with `violations[]` pointing at the gaps. The fix is one of:

1. **Set a place-level default on the saved address.** Recommended for recurring lanes: set it once, every future order on that address inherits it.
2. **PUT an order-level requirement** to `/v1/shipper/shipment/{orderNumber}/appointment/{stop}`.
3. **Include the `appointments` block** on the create call.

Once data is present at either level, confirm succeeds. The gate fires uniformly across every confirm path, so carriers never see an appointment-required order that lacks the data they need to book the dock.

***

## Place-level defaults + order-level overrides

For each stop, the **effective** requirement is the merge of:

* **Place-level default** on the saved address (`Address.defaultAccessorials.appointment`): your "this is how we always book at this dock" data.
* **Order-level override** on the specific shipment: your "this load has a special PO, use this contact, different lead time" data.

Merge rule: **more-strict wins**.

| Field              | Rule                                                            |
| ------------------ | --------------------------------------------------------------- |
| `channel`          | most strict wins (`PORTAL` > `EMAIL` > `PHONE`)                 |
| `portalUrl`        | order-level wins; falls back to place-level when order is null  |
| `contacts`         | union of both, deduplicated by `(email, phone)`                 |
| `instructions`     | when both have a value, concatenated as `place\n\n---\n\norder` |
| `referenceNumbers` | union; place-level entries first; first occurrence wins         |
| `leadTimeHours`    | max of the two                                                  |

The GET response includes a `_merged` diagnostic block showing per-field provenance, which makes debugging trivial:

```json
{
  "channel":          "PORTAL",
  "portalUrl":        "https://retaillink.example.com/scheduling",
  "contacts":         [ /* ... */ ],
  "instructions":     "Always use loading dock 4.\n\n---\n\nDriver must check in at gate B.",
  "referenceNumbers": ["DOCK-12", "81CRR"],
  "leadTimeHours":    48,

  "_merged": {
    "appointmentRequired": true,
    "fields": {
      "channel":          "ORDER",
      "portalUrl":        "ORDER",
      "contacts":         "PLACE_AND_ORDER",
      "instructions":     "PLACE_AND_ORDER",
      "referenceNumbers": "PLACE_AND_ORDER",
      "leadTimeHours":    "PLACE"
    }
  }
}
```

`fields[*]` values: `PLACE`, `ORDER`, `PLACE_AND_ORDER`, `NONE`.

***

## Mutability

Appointment data is mutable through the normal order lifecycle until a carrier **accepts** the load. After that, both the PUT and the document endpoints return:

```
409 Conflict
problem.reasonCode = "order_accepted_appointment_locked"
```

The lock exists to protect carriers from last-minute changes that would invalidate the booked dock slot. If you need to change appointment data on an accepted order, contact `help@oway.io` and we will coordinate with the carrier.

***

## Error responses

All errors follow our standard `ProblemDetail` shape:

```json
{
  "type":    "https://oway.io/problems/validation",
  "title":   "Validation failed",
  "status":  400,
  "detail":  "One or more fields failed validation",
  "violations": [
    {
      "key":    "appointments.pickup.contacts[0].phone",
      "entity": "appointment",
      "label":  "Phone must be in E.164 format (e.g. +15555550101)"
    }
  ]
}
```

Reference table:

| Status | `reasonCode`                               | When                                                                    |
| ------ | ------------------------------------------ | ----------------------------------------------------------------------- |
| 400    | `validation_failed`                        | Bad shape on PUT or inline `appointments` block                         |
| 400    | `appointment_document_not_user_uploadable` | Tried to upload a system-only document type                             |
| 404    | `order_not_found`                          | Order doesn't exist or isn't owned by your shipper company              |
| 409    | `order_accepted_appointment_locked`        | Order is past `ACCEPTED`                                                |
| 413    | `file_too_large`                           | Document upload over 10 MB                                              |
| 415    | `unsupported_media_type`                   | Upload content type not in `application/pdf`, `image/jpeg`, `image/png` |

***

## Two ways to adopt this

You don't have to change anything to keep your current integration working. When you're ready to opt in:

1. **Set place-level defaults once per saved address.** If your receivers' scheduling behavior is stable (same portal, same phone, same lead time every time), put it on the address and forget it. Order creation requires no changes on your side.
2. **Send the `appointments` block on create** for loads where the override matters: a special PO, a one-off contact, a different lead time, a portal URL that only applies to this shipment.

Both paths satisfy the confirm-time gate. Most integrations end up doing some of each.

Questions, edge cases, or want help mapping your appointment data model to ours? Reach out at `help@oway.io`.