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 namefile.- 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 (always204).
The AppointmentRequirement shape
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[]):
| 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: trueon 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:
- Set a place-level default on the saved address. Recommended for recurring lanes: set it once, every future order on that address inherits it.
- PUT an order-level requirement to
/v1/shipper/shipment/{orderNumber}/appointment/{stop}. - Include the
appointmentsblock 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:
{
"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:
| 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:
- 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.
- Send the
appointmentsblock 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].
