Skip to main content

Spoke Connect API (v1)

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.

Introduction

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.

Using the API

Address

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.

Your first request

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.

Authentication

Every Connect API request must be authenticated with an API key.

Creating API keys

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.

Using your key

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

Authentication errors

Requests with a missing, invalid, or expired key are rejected with a 401 Unauthorized response.

On resource types

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.

On resource IDs

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.

On List endpoints

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:

  1. Request the first page. Orders come back newest first.
curl "https://api.spoke.com/connect/v1/orders?maxPageSize=50" \
  -H "Authorization: Bearer <api-key>"
  1. The response carries a cursor when more results exist:
{
  "orders": [],
  "nextPageToken": "MTc3ODUwODUwMnxXM2tKcHpRbTlxNExWMkJkSDdzTnVU"
}
  1. Pass it as 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>"
  1. Repeat until 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.

On Update endpoints (HTTP PATCH verb)

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:

  • An omitted field is left unchanged. An empty nested object ({}) is the same as omitting it, and a body that sets nothing returns the order unchanged.
  • An explicit null clears a nullable field. Sending {"deliveryInstructions": {"notes": null}} removes the notes.
  • An array replaces the stored array in full. There is no partial update of an array, so send the complete list you want to end up with.

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.
  • Nested objects are never 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".

On Rate-Limiting

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:

  • Wait and retry, using exponential backoff.
  • Add a random delay to each attempt, so a fleet of your own workers does not retry in lockstep and cause a thundering herd.
  • Do not retry a 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.

On the models

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.

Orders

Create, submit, and track delivery orders, and download their printable PDF labels.

List orders

List the authenticated retailer's orders, newest first. Supports cursor pagination and an exact-match filter on packageInfo.externalId.

Authorizations:
HTTP: BearerAuthHTTP: BasicAuth
query Parameters
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.

Responses

Response samples

Content type
application/json
{
  • "orders": [
    ],
  • "nextPageToken": "string"
}

Create an order

Create a new order for the authenticated retailer.

Authorizations:
HTTP: BearerAuthHTTP: BasicAuth
Request Body schema: application/json
required

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. addressName is a display-only label and is never used for geocoding, so it does not count. When latitude and longitude are set they must be set together and override the other fields for geocoding.

object or null
object or null
object or null

Responses

Request samples

Content type
application/json
{
  • "address": {
    },
  • "recipient": {
    },
  • "packageInfo": {
    },
  • "deliveryInstructions": {
    }
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "createdAt": 0,
  • "recipient": {
    },
  • "packageInfo": {
    },
  • "address": {
    },
  • "deliveryInstructions": {
    },
  • "courier": "string",
  • "submitted": true,
  • "submittedAt": 0,
  • "progress": {
    },
  • "readOnly": true,
  • "source": "retailer",
  • "barcodes": [
    ]
}

Batch import orders

Create up to 100 orders for the authenticated retailer in one request. Always returns 200; per-item outcomes are split across success and failed.

Authorizations:
HTTP: BearerAuthHTTP: BasicAuth
Request Body schema: application/json
required

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)
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. addressName is a display-only label and is never used for geocoding, so it does not count. When latitude and longitude are set they must be set together and override the other fields for geocoding.

object or null
object or null
object or null

Responses

Request samples

Content type
application/json
{
  • "orders": [
    ]
}

Response samples

Content type
application/json
{
  • "success": [
    ],
  • "failed": [
    ]
}

Batch submit orders

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.

Authorizations:
HTTP: BearerAuthHTTP: BasicAuth
Request Body schema: application/json
required

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.

Responses

Request samples

Content type
application/json
{
  • "orderIds": [
    ],
  • "courierId": "string"
}

Response samples

Content type
application/json
{
  • "success": [
    ],
  • "failed": [
    ]
}

Batch withdraw orders

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.

Authorizations:
HTTP: BearerAuthHTTP: BasicAuth
Request Body schema: application/json
required

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.

Responses

Request samples

Content type
application/json
{
  • "orderIds": [
    ]
}

Response samples

Content type
application/json
{
  • "success": [
    ],
  • "failed": [
    ]
}

Retrieve an order

Retrieve a single order for the authenticated retailer.

Authorizations:
HTTP: BearerAuthHTTP: BasicAuth
path Parameters
id
required
string [ 1 .. 50 ] characters ^[a-zA-Z0-9_-]{1,50}$

The order id.

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "createdAt": 0,
  • "recipient": {
    },
  • "packageInfo": {
    },
  • "address": {
    },
  • "deliveryInstructions": {
    },
  • "courier": "string",
  • "submitted": true,
  • "submittedAt": 0,
  • "progress": {
    },
  • "readOnly": true,
  • "source": "retailer",
  • "barcodes": [
    ]
}

Edit an order

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.

Authorizations:
HTTP: BearerAuthHTTP: BasicAuth
path Parameters
id
required
string [ 1 .. 50 ] characters ^[a-zA-Z0-9_-]{1,50}$

The order id.

Request Body schema: application/json

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.

Responses

Request samples

Content type
application/json
{
  • "address": {
    },
  • "recipient": {
    },
  • "packageInfo": {
    },
  • "deliveryInstructions": {
    }
}

Response samples

Content type
application/json
{
  • "id": "string",
  • "createdAt": 0,
  • "recipient": {
    },
  • "packageInfo": {
    },
  • "address": {
    },
  • "deliveryInstructions": {
    },
  • "courier": "string",
  • "submitted": true,
  • "submittedAt": 0,
  • "progress": {
    },
  • "readOnly": true,
  • "source": "retailer",
  • "barcodes": [
    ]
}

Delete an order

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.

Authorizations:
HTTP: BearerAuthHTTP: BasicAuth
path Parameters
id
required
string [ 1 .. 50 ] characters ^[a-zA-Z0-9_-]{1,50}$

The order id.

Responses

Response samples

Content type
application/json
{
  • "message": "string",
  • "code": "string",
  • "param": "string",
  • "url": "string"
}

Retrieve an order's shipping labels

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.

Authorizations:
HTTP: BearerAuthHTTP: BasicAuth
path Parameters
id
required
string [ 1 .. 50 ] characters ^[a-zA-Z0-9_-]{1,50}$

The order id.

Responses

Response samples

Content type
application/json
{
  • "url": "string",
  • "expiresAt": 0
}

Couriers

Look up the couriers that deliver your orders.

List couriers

List the couriers connected to the authenticated retailer.

Authorizations:
HTTP: BearerAuthHTTP: BasicAuth
query Parameters
pageToken
string [ 1 .. 255 ] characters

The page token to continue from.

maxPageSize
integer [ 1 .. 100 ]
Default: 100

The maximum number of couriers to return.

Responses

Response samples

Content type
application/json
{
  • "couriers": [
    ],
  • "nextPageToken": "string"
}

Retrieve a courier

Retrieve a single courier connected to the authenticated retailer.

Authorizations:
HTTP: BearerAuthHTTP: BasicAuth
path Parameters
id
required
string^[a-zA-Z0-9_-]{1,50}$

The courier id.

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string"
}

Pickup Locations

Look up the pickup locations your orders ship from.

List pickup locations

List the authenticated retailer's pickup locations.

Authorizations:
HTTP: BearerAuthHTTP: BasicAuth
query Parameters
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.

Responses

Response samples

Content type
application/json
{
  • "pickupLocations": [
    ],
  • "nextPageToken": "string"
}

Retrieve a pickup location

Retrieve a single one of the authenticated retailer's pickup locations.

Authorizations:
HTTP: BearerAuthHTTP: BasicAuth
path Parameters
id
required
string^[a-zA-Z0-9_-]{1,50}$

The pickup location id.

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "name": "string"
}