This is the documentation of the Spoke Connect API HTTP endpoints. The Connect API is how a retailer creates, tracks, and hands off its deliveries in Spoke programmatically.
The Spoke Connect API is an HTTP API that lets you operate on your orders programmatically. Use it to create, edit, delete, list, and retrieve orders, submit them to a courier and withdraw them before they ship, and download printable PDF labels. Read-only endpoints expose the couriers and pickup locations your orders reference.
Spoke offers no programming SDKs for the Connect API. Call the endpoints documented on this page directly, or generate a client from the OpenAPI spec.
For worked examples of the flows a retailer integration needs, see the Usage Examples.
The base address for this version of the API, to be used before every endpoint listed here, is:
https://api.spoke.com/connect/v1
Notice the https:// prefix. The Connect API will not accept plain HTTP
requests.
Every request must be authenticated with an API key. The following request lists your orders:
curl "https://api.spoke.com/connect/v1/orders" \
-H "Authorization: Bearer <api-key>"
The response contains an orders array, plus a nextPageToken cursor to pass
as pageToken when there are more results to fetch.
Every Connect API request must be authenticated with an API key.
API keys are created and expired from the Integrations page in your Connect settings, by retailer admins. You can have up to 5 active keys at a time. Expired keys don't count towards the limit.
Treat your keys as secrets: don't embed them in client-side code, and expire any key you suspect has leaked.
Pass the key in the Authorization header. Both the Bearer and Basic
schemes are accepted:
Authorization: Bearer <api-key>
Authorization: Basic <base64(<api-key>:)>
For Basic, use the API key as the username and leave the password empty.
curl builds the encoded header for you with -u "<api-key>:":
curl "https://api.spoke.com/connect/v1/orders" -u "<api-key>:"
Requests with a missing, invalid, or expired key are rejected with a
401 Unauthorized response.
Every response from the Connect API is a JSON object, and every request that
carries a body must send it as JSON with the Content-Type: application/json
header.
The API exposes four resource types:
| Resource | What it is | Where |
|---|---|---|
Order |
A package, or set of packages, you are sending to a recipient through a courier. Everything else in the API exists to describe or act on an order. | Read and write, /orders |
Courier |
A courier your retailer account is connected to, and that you can submit orders to. | Read-only, /couriers |
PickupLocation |
One of your own locations a courier can collect from, referenced by an order's deliveryInstructions.handOffInfo.pickupLocationId. |
Read-only, /pickupLocations |
OrderShippingLabels |
A short-lived signed link to an order's printable labels, rendered as a single PDF. | Read-only, /orders/{id}/shippingLabels |
Couriers and pickup locations are managed in the Spoke Connect app, not through this API. The API reads them so you can reference them by id when you create and submit orders.
What every field on these resources means is documented on the Models pages.
Every resource in the Connect API has a unique id that Spoke generates. Every resource representation the API returns shows that id in the following format:
{
"id": "collectionName/resourceId"
}
So an order comes back as:
{
"id": "orders/W3kJpzQm9q4LV2BdH7sNuT"
}
Ids are unique per collection, so orders/..., couriers/..., and
pickupLocations/... name an order, a courier, and a pickup location
respectively.
This means that to act on a resource directly you append its id to the base address exactly as it came back:
curl "https://api.spoke.com/connect/v1/`serializedId`" \
-H "Authorization: Bearer <api-key>"
For the order above that is:
curl "https://api.spoke.com/connect/v1/orders/W3kJpzQm9q4LV2BdH7sNuT" \
-H "Authorization: Bearer <api-key>"
Sub-resources hang off the same id, so that order's labels are at
orders/W3kJpzQm9q4LV2BdH7sNuT/shippingLabels.
The endpoint reference below writes the same paths in OpenAPI template
notation, as /orders/{id}. That describes the URL you just built, so there is
no second id format to keep track of.
Send ids back in the form you received them. courierId on
POST /orders:submit, and pickupLocationId on an order, both take the full
prefixed value.
Every list endpoint is paginated. One request returns at most one page, and the
response carries a nextPageToken you pass back as the pageToken query
parameter to fetch the next one.
Page size is set with maxPageSize. Each endpoint has its own default and
maximum:
| Endpoint | Default | Maximum |
|---|---|---|
GET /orders |
50 | 50 |
GET /couriers |
100 | 100 |
GET /pickupLocations |
100 | 100 |
A maxPageSize above the maximum is rejected with 400 Bad Request rather
than silently clamped.
Walking a whole collection looks like this:
curl "https://api.spoke.com/connect/v1/orders?maxPageSize=50" \
-H "Authorization: Bearer <api-key>"
{
"orders": [],
"nextPageToken": "MTc3ODUwODUwMnxXM2tKcHpRbTlxNExWMkJkSDdzTnVU"
}
pageToken, keeping every other query parameter identical to the
first request:curl "https://api.spoke.com/connect/v1/orders?maxPageSize=50&pageToken=MTc3ODUwODUwMnxXM2tKcHpRbTlxNExWMkJkSDdzTnVU" \
-H "Authorization: Bearer <api-key>"
nextPageToken comes back null. That is the only signal that
the collection is exhausted.A short page is not the last page. Every list endpoint filters its page
after reading it: GET /orders drops deleted and demo orders, and
GET /couriers drops connections whose courier no longer exists. A page can
therefore come back with fewer items than maxPageSize, or with none at all,
while nextPageToken is still set. Stop when nextPageToken is null, never
when a page looks short.
Tokens are opaque, and only the token a list endpoint handed you is meaningful
to it. Do not parse one, build one, or carry one across walks. GET /orders
rejects a malformed token with 400 Bad Request. The other list endpoints
resume from whatever position the token decodes to, so a hand-made one returns
the wrong slice rather than an error.
GET /orders also accepts an exact-match filter on the order's
packageInfo.externalId, written either as filter[externalId]= or
filter.externalId=. External ids are not unique-constrained, so a value you
have reused returns every order carrying it.
PATCH /orders/{id} is a partial update. What you leave out of the body is
left untouched, so you can change one nested field without resending the rest
of the order.
Three rules cover every field:
{}) is the
same as omitting it, and a body that sets nothing returns the order
unchanged.null clears a nullable field. Sending
{"deliveryInstructions": {"notes": null}} removes the notes.Changing only the delivery notes, for example:
curl -X PATCH "https://api.spoke.com/connect/v1/orders/W3kJpzQm9q4LV2BdH7sNuT" \
-H "Authorization: Bearer <api-key>" \
-H "Content-Type: application/json" \
-d '{"deliveryInstructions": {"notes": "Leave with the neighbour"}}'
Three exceptions are worth knowing before you write an integration:
address is replaced, not merged. An address you send replaces the
stored address in full and is geocoded again exactly as on create, so it has
to carry at least one field that can locate the delivery. Sending only zip
discards the street lines rather than amending them.deliveryInstructions.handOffInfo.handOffType cannot be cleared. It
accepts ship_to_courier or pickup_by_courier, and omitting it leaves the
stored value alone. Once a hand-off type is set there is no way to put the
order back on your account default. Switching to ship_to_courier also
clears the stored pickupLocationId.null on an edit. Clear the individual leaves
instead, for example {"deliveryInstructions": {"proofOfAttemptRequirements": {"enabled": null}}}.Only an editable order can be updated. An order becomes read-only once the
courier puts it on a route, and an order the courier added on your behalf
(source: "courier") is read-only from the start. Both answer 409 Conflict
with code: "order_read_only".
The Connect API is rate-limited per source IP, at the edge, before a request
reaches the application. Exceed a limit and the request is rejected with
429 Too Many Requests. That rejection happens ahead of the API, so it carries
no JSON body: match on the status code, not on an error code.
The limits are:
| Requests | Limit |
|---|---|
Reads (GET) |
10 per second |
Writes (POST, PATCH, DELETE) |
5 per second |
POST /orders:import |
10 per minute |
POST /orders:submit |
10 per minute |
:import and :submit are counted in their own separate buckets, and each
request carries up to 100 orders, so each endpoint tops out at 1000 orders per
minute. That is the reason to prefer the batch endpoints over a loop of
single-resource calls: importing 100 orders spends one request against the
per-minute budget, where 100 creates spend 100 requests against the
5-per-second write budget. Every batch endpoint caps the array at 100 and
rejects a longer one with 400 Bad Request before processing any item, so size
your batches accordingly.
Keep breaching a limit and the ban lengthens: sustained breach over roughly
three minutes triggers a temporary block of about twenty minutes, so a client
that keeps hammering after a 429 locks itself out for longer than it would
have waited. Handle a 429 by backing off instead:
4xx other than 429. Those describe the request itself, and
resending it unchanged fails the same way.Raising a limit is a backwards-compatible change under the versioning policy, so the numbers can go up without a new API version. If you have a use case that needs sustained higher throughput, tell us about it before you build it and we will size the limits around it.
Below this section you will find every Connect API endpoint. The resources they accept and return are documented field by field on the Models pages.
List the authenticated retailer's orders, newest first. Supports cursor pagination and an exact-match filter on packageInfo.externalId.
| pageToken | string [ 1 .. 255 ] characters The page token to continue from. |
| maxPageSize | integer [ 1 .. 50 ] Default: 50 The maximum number of orders to return. Defaults to 50 and is capped at 50. |
object Filters narrowing the orders returned. |
{- "orders": [
- {
- "id": "string",
- "createdAt": 0,
- "recipient": {
- "name": "string",
- "email": "string",
- "phone": "string",
- "externalId": "string"
}, - "packageInfo": {
- "products": [
- "string"
], - "packageCount": 0,
- "packageLoadAmount": 0,
- "externalId": "string"
}, - "address": {
- "address": "string",
- "addressLineOne": "string",
- "addressLineTwo": "string",
- "latitude": 0,
- "longitude": 0,
- "placeId": "string",
- "placeTypes": [
- "string"
]
}, - "deliveryInstructions": {
- "notes": "string",
- "expectedDeliveryDate": {
- "day": 1,
- "month": 1,
- "year": 2000
}, - "proofOfAttemptRequirements": {
- "enabled": true
}, - "handOffInfo": {
- "handOffType": "ship_to_courier",
- "pickupLocationId": "string"
}
}, - "courier": "string",
- "submitted": true,
- "submittedAt": 0,
- "progress": {
- "trackingLink": "string",
- "eta": {
- "status": "available",
- "value": {
- "estimatedArrivalAt": 0,
- "estimatedEarliestArrivalAt": 0,
- "estimatedLatestArrivalAt": 0
}
}, - "attempted": true,
- "attemptedAt": 0,
- "state": "delivered_to_recipient",
- "routeStartDate": {
- "day": 1,
- "month": 1,
- "year": 2000
}, - "routeStarted": true,
- "succeeded": true,
- "photoUrls": [
- "string"
], - "signatureUrl": "string",
- "signeeName": "string",
- "driverProvidedRecipientNotes": "string"
}, - "readOnly": true,
- "source": "retailer",
- "barcodes": [
- "string"
]
}
], - "nextPageToken": "string"
}Create a new order for the authenticated retailer.
Input shape accepted by POST /orders and POST /orders:import. Only address is required.
required | object Address of the delivery. Every field is optional on its own, but you must provide at least one that can locate the delivery. |
object or null | |
object or null | |
object or null |
{- "address": {
- "addressLineOne": "string",
- "addressLineTwo": "string",
- "city": "string",
- "state": "string",
- "zip": "string",
- "country": "string",
- "addressName": "string",
- "latitude": -90,
- "longitude": -180
}, - "recipient": {
- "name": "string",
- "email": "string",
- "phone": "string",
- "externalId": "string"
}, - "packageInfo": {
- "products": [
- "string"
], - "packageCount": 1,
- "packageLoadAmount": 0,
- "externalId": "string"
}, - "deliveryInstructions": {
- "notes": "string",
- "expectedDeliveryDate": {
- "day": 1,
- "month": 1,
- "year": 2000
}, - "proofOfAttemptRequirements": {
- "enabled": true
}, - "handOffInfo": {
- "handOffType": "ship_to_courier",
- "pickupLocationId": "string"
}
}
}{- "id": "string",
- "createdAt": 0,
- "recipient": {
- "name": "string",
- "email": "string",
- "phone": "string",
- "externalId": "string"
}, - "packageInfo": {
- "products": [
- "string"
], - "packageCount": 0,
- "packageLoadAmount": 0,
- "externalId": "string"
}, - "address": {
- "address": "string",
- "addressLineOne": "string",
- "addressLineTwo": "string",
- "latitude": 0,
- "longitude": 0,
- "placeId": "string",
- "placeTypes": [
- "string"
]
}, - "deliveryInstructions": {
- "notes": "string",
- "expectedDeliveryDate": {
- "day": 1,
- "month": 1,
- "year": 2000
}, - "proofOfAttemptRequirements": {
- "enabled": true
}, - "handOffInfo": {
- "handOffType": "ship_to_courier",
- "pickupLocationId": "string"
}
}, - "courier": "string",
- "submitted": true,
- "submittedAt": 0,
- "progress": {
- "trackingLink": "string",
- "eta": {
- "status": "available",
- "value": {
- "estimatedArrivalAt": 0,
- "estimatedEarliestArrivalAt": 0,
- "estimatedLatestArrivalAt": 0
}
}, - "attempted": true,
- "attemptedAt": 0,
- "state": "delivered_to_recipient",
- "routeStartDate": {
- "day": 1,
- "month": 1,
- "year": 2000
}, - "routeStarted": true,
- "succeeded": true,
- "photoUrls": [
- "string"
], - "signatureUrl": "string",
- "signeeName": "string",
- "driverProvidedRecipientNotes": "string"
}, - "readOnly": true,
- "source": "retailer",
- "barcodes": [
- "string"
]
}Create up to 100 orders for the authenticated retailer in one request. Always returns 200; per-item outcomes are split across success and failed.
Input shape accepted by POST /orders:import.
required | Array of objects [ 1 .. 100 ] items The orders to create. At most 100 per request. | ||||||||
Array ([ 1 .. 100 ] items)
| |||||||||
{- "orders": [
- {
- "address": {
- "addressLineOne": "string",
- "addressLineTwo": "string",
- "city": "string",
- "state": "string",
- "zip": "string",
- "country": "string",
- "addressName": "string",
- "latitude": -90,
- "longitude": -180
}, - "recipient": {
- "name": "string",
- "email": "string",
- "phone": "string",
- "externalId": "string"
}, - "packageInfo": {
- "products": [
- "string"
], - "packageCount": 1,
- "packageLoadAmount": 0,
- "externalId": "string"
}, - "deliveryInstructions": {
- "notes": "string",
- "expectedDeliveryDate": {
- "day": 1,
- "month": 1,
- "year": 2000
}, - "proofOfAttemptRequirements": {
- "enabled": true
}, - "handOffInfo": {
- "handOffType": "ship_to_courier",
- "pickupLocationId": "string"
}
}
}
]
}{- "success": [
- "string"
], - "failed": [
- {
- "order": {
- "address": {
- "addressLineOne": "string",
- "addressLineTwo": "string",
- "city": "string",
- "state": "string",
- "zip": "string",
- "country": "string",
- "addressName": "string",
- "latitude": -90,
- "longitude": -180
}, - "recipient": {
- "name": "string",
- "email": "string",
- "phone": "string",
- "externalId": "string"
}, - "packageInfo": {
- "products": [
- "string"
], - "packageCount": 1,
- "packageLoadAmount": 0,
- "externalId": "string"
}, - "deliveryInstructions": {
- "notes": "string",
- "expectedDeliveryDate": {
- "day": 1,
- "month": 1,
- "year": 2000
}, - "proofOfAttemptRequirements": {
- "enabled": true
}, - "handOffInfo": {
- "handOffType": "ship_to_courier",
- "pickupLocationId": "string"
}
}, - "index": -9007199254740991
}, - "error": {
- "code": "string",
- "message": "string"
}
}
]
}Submit up to 100 draft orders to a connected courier in one request. Submitted orders are picked up at the pickup location (or shipped to the courier) and delivered from the courier's depot. Always returns 200 when the courier is valid; per-order outcomes are split across success and failed.
Input shape accepted by POST /orders:submit.
| orderIds required | Array of strings [ 1 .. 100 ] items [ items^orders\/[a-zA-Z0-9_-]{1,50}$ ] The orders to submit. At most 100 per request. |
| courierId required | string^couriers\/[a-zA-Z0-9_-]{1,50}$ The courier to submit the orders to. Must be a courier connected to the retailer. |
{- "orderIds": [
- "string"
], - "courierId": "string"
}{- "success": [
- "string"
], - "failed": [
- {
- "orderId": "string",
- "error": {
- "code": "order_not_found",
- "message": "string"
}
}
]
}Withdraw up to 100 submitted orders back to draft in one request. An order that has already been added to a route can no longer be withdrawn. Always returns 200; per-order outcomes are split across success and failed.
Input shape accepted by POST /orders:withdraw.
| orderIds required | Array of strings [ 1 .. 100 ] items [ items^orders\/[a-zA-Z0-9_-]{1,50}$ ] The orders to withdraw. At most 100 per request. |
{- "orderIds": [
- "string"
]
}{- "success": [
- "string"
], - "failed": [
- {
- "orderId": "string",
- "error": {
- "code": "order_not_submitted",
- "message": "string"
}
}
]
}Retrieve a single order for the authenticated retailer.
| id required | string [ 1 .. 50 ] characters ^[a-zA-Z0-9_-]{1,50}$ The order id. |
{- "id": "string",
- "createdAt": 0,
- "recipient": {
- "name": "string",
- "email": "string",
- "phone": "string",
- "externalId": "string"
}, - "packageInfo": {
- "products": [
- "string"
], - "packageCount": 0,
- "packageLoadAmount": 0,
- "externalId": "string"
}, - "address": {
- "address": "string",
- "addressLineOne": "string",
- "addressLineTwo": "string",
- "latitude": 0,
- "longitude": 0,
- "placeId": "string",
- "placeTypes": [
- "string"
]
}, - "deliveryInstructions": {
- "notes": "string",
- "expectedDeliveryDate": {
- "day": 1,
- "month": 1,
- "year": 2000
}, - "proofOfAttemptRequirements": {
- "enabled": true
}, - "handOffInfo": {
- "handOffType": "ship_to_courier",
- "pickupLocationId": "string"
}
}, - "courier": "string",
- "submitted": true,
- "submittedAt": 0,
- "progress": {
- "trackingLink": "string",
- "eta": {
- "status": "available",
- "value": {
- "estimatedArrivalAt": 0,
- "estimatedEarliestArrivalAt": 0,
- "estimatedLatestArrivalAt": 0
}
}, - "attempted": true,
- "attemptedAt": 0,
- "state": "delivered_to_recipient",
- "routeStartDate": {
- "day": 1,
- "month": 1,
- "year": 2000
}, - "routeStarted": true,
- "succeeded": true,
- "photoUrls": [
- "string"
], - "signatureUrl": "string",
- "signeeName": "string",
- "driverProvidedRecipientNotes": "string"
}, - "readOnly": true,
- "source": "retailer",
- "barcodes": [
- "string"
]
}Edit an order for the authenticated retailer. An omitted field is left unchanged, an explicit null clears a nullable field, an empty nested object changes nothing, and arrays replace the whole stored array. A provided address replaces the stored address in full and is re-geocoded exactly as on create. A read-only order (added by the courier, or already on a route) cannot be edited.
| id required | string [ 1 .. 50 ] characters ^[a-zA-Z0-9_-]{1,50}$ The order id. |
Input shape accepted by PATCH /orders/{id}. An omitted field is left unchanged, an explicit null clears a nullable field, an empty nested object changes nothing, and arrays replace the whole stored array.
object The address of the delivery. A provided address replaces the stored address in full and is re-geocoded exactly as on create. | |
object The recipient of the delivery. | |
object Package details: products, quantities, and the retailer's own id. | |
object How and when to deliver. |
{- "address": {
- "addressLineOne": "string",
- "addressLineTwo": "string",
- "city": "string",
- "state": "string",
- "zip": "string",
- "country": "string",
- "addressName": "string",
- "latitude": -90,
- "longitude": -180
}, - "recipient": {
- "name": "string",
- "email": "string",
- "phone": "string",
- "externalId": "string"
}, - "packageInfo": {
- "products": [
- "string"
], - "packageCount": 1,
- "packageLoadAmount": 0,
- "externalId": "string"
}, - "deliveryInstructions": {
- "notes": "string",
- "expectedDeliveryDate": {
- "day": 1,
- "month": 1,
- "year": 2000
}, - "proofOfAttemptRequirements": {
- "enabled": true
}, - "handOffInfo": {
- "handOffType": "ship_to_courier",
- "pickupLocationId": "string"
}
}
}{- "id": "string",
- "createdAt": 0,
- "recipient": {
- "name": "string",
- "email": "string",
- "phone": "string",
- "externalId": "string"
}, - "packageInfo": {
- "products": [
- "string"
], - "packageCount": 0,
- "packageLoadAmount": 0,
- "externalId": "string"
}, - "address": {
- "address": "string",
- "addressLineOne": "string",
- "addressLineTwo": "string",
- "latitude": 0,
- "longitude": 0,
- "placeId": "string",
- "placeTypes": [
- "string"
]
}, - "deliveryInstructions": {
- "notes": "string",
- "expectedDeliveryDate": {
- "day": 1,
- "month": 1,
- "year": 2000
}, - "proofOfAttemptRequirements": {
- "enabled": true
}, - "handOffInfo": {
- "handOffType": "ship_to_courier",
- "pickupLocationId": "string"
}
}, - "courier": "string",
- "submitted": true,
- "submittedAt": 0,
- "progress": {
- "trackingLink": "string",
- "eta": {
- "status": "available",
- "value": {
- "estimatedArrivalAt": 0,
- "estimatedEarliestArrivalAt": 0,
- "estimatedLatestArrivalAt": 0
}
}, - "attempted": true,
- "attemptedAt": 0,
- "state": "delivered_to_recipient",
- "routeStartDate": {
- "day": 1,
- "month": 1,
- "year": 2000
}, - "routeStarted": true,
- "succeeded": true,
- "photoUrls": [
- "string"
], - "signatureUrl": "string",
- "signeeName": "string",
- "driverProvidedRecipientNotes": "string"
}, - "readOnly": true,
- "source": "retailer",
- "barcodes": [
- "string"
]
}Delete an order for the authenticated retailer. Only a draft order can be deleted: a submitted order must be withdrawn first, and a read-only order (added by the courier, or already on a route) cannot be deleted.
| id required | string [ 1 .. 50 ] characters ^[a-zA-Z0-9_-]{1,50}$ The order id. |
{- "message": "string",
- "code": "string",
- "param": "string",
- "url": "string"
}Render the order's shipping labels as a single PDF (A6, one Code 128 barcode per page, one page per package) and return a signed URL to it, valid for one hour. Every call renders the labels afresh and signs a new URL, so fetch it promptly server-side: the link is too short-lived to embed anywhere a recipient might open later.
| id required | string [ 1 .. 50 ] characters ^[a-zA-Z0-9_-]{1,50}$ The order id. |
{- "url": "string",
- "expiresAt": 0
}List the couriers connected to the authenticated retailer.
| pageToken | string [ 1 .. 255 ] characters The page token to continue from. |
| maxPageSize | integer [ 1 .. 100 ] Default: 100 The maximum number of couriers to return. |
{- "couriers": [
- {
- "id": "string",
- "name": "string"
}
], - "nextPageToken": "string"
}Retrieve a single courier connected to the authenticated retailer.
| id required | string^[a-zA-Z0-9_-]{1,50}$ The courier id. |
{- "id": "string",
- "name": "string"
}List the authenticated retailer's pickup locations.
| pageToken | string [ 1 .. 255 ] characters The page token to continue from. |
| maxPageSize | integer [ 1 .. 100 ] Default: 100 The maximum number of pickup locations to return. |
{- "pickupLocations": [
- {
- "id": "string",
- "name": "string"
}
], - "nextPageToken": "string"
}Retrieve a single one of the authenticated retailer's pickup locations.
| id required | string^[a-zA-Z0-9_-]{1,50}$ The pickup location id. |
{- "id": "string",
- "name": "string"
}