Improved

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:

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": "[email protected]" }
      ],
      "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

{
  "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[]):

FieldRule
channelrequired
portalUrlrequired when channel == PORTAL; must be https://...; max 512 chars; must be omitted otherwise
contacts[].namerequired
contacts[]at least one of email or phone
contacts[].emailstandard email format
contacts[].phoneE.164 (+ then 8 to 15 digits)
instructionsmax 2000 chars
referenceNumbersup to 10 entries, each up to 64 chars
leadTimeHoursinteger 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.

FieldRule
channelmost strict wins (PORTAL > EMAIL > PHONE)
portalUrlorder-level wins; falls back to place-level when order is null
contactsunion of both, deduplicated by (email, phone)
instructionswhen both have a value, concatenated as place\n\n---\n\norder
referenceNumbersunion; place-level entries first; first occurrence wins
leadTimeHoursmax of the two

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

{
  "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 [email protected] and we will coordinate with the carrier.


Error responses

All errors follow our standard ProblemDetail shape:

{
  "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:

StatusreasonCodeWhen
400validation_failedBad shape on PUT or inline appointments block
400appointment_document_not_user_uploadableTried to upload a system-only document type
404order_not_foundOrder doesn't exist or isn't owned by your shipper company
409order_accepted_appointment_lockedOrder is past ACCEPTED
413file_too_largeDocument upload over 10 MB
415unsupported_media_typeUpload 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 [email protected].