For developers

Build on the Owleh API

Everything the Owleh web app does — quotes, bookings, live tracking, courier groups, payments — is available over a plain JSON API. This is the complete reference for public and business endpoints.

Base URL: https://owleh.com/api

Start here

Getting started

The API lives at https://owleh.com/api. Every endpoint speaks JSON over HTTPS; send Content-Type: application/json on requests with a body. Money is in pounds sterling as decimal numbers, timestamps are unix seconds unless stated otherwise, and postcodes are full UK postcodes ("EC1A 1BB" — case and spacing are normalised for you).

There are three ways in. Anyone can price a route with the public quote endpoint. A business account can sign in with its email and password to get a JWT and do everything the web app does. And a business with API access enabled can mint long-lived API tokens from its profile page, which price routes directly and can be exchanged for a JWT without ever handling the account password — the recommended path for integrations.

Courier (driver) endpoints are not part of this reference. If you are building for couriers, get in touch.

Your first call

curl "https://owleh.com/api/quote?pickup=EC1A%201BB&dropoff=SW1A%201AA&parcels=1"
# {"success":true,"data":12.4}

Identity

Authentication

Protected endpoints (everything under /private) take an access JWT in the Authorization header, either bare or as "Bearer <jwt>". Access tokens last 30 days. Alongside the JWT you receive an opaque refresh token: present it to GET /user/refresh to get a new access token. Refresh tokens slide — each use extends them by 90 days — and they are not rotated, so concurrent refreshes are safe.

API tokens (prefixed owl_) are a separate credential for integrations. They are minted on the business profile page once an Owleh admin has enabled API access for the account, shown once, and stored hashed. They work only on the /v1 endpoints: the two-postcode quote, and the exchange that turns the API token into a normal JWT + refresh token pair. From there the integration acts as the account with its full capabilities. Deleting an API token signs out the sessions it minted; revoking API access on the account stops new exchanges. Keep tokens server-side — they are the keys to the account.

A request with a missing or invalid JWT on /private gets a 401 with an empty body. A courier token on a business-only endpoint gets a 403 with an empty body. The /v1 endpoints return JSON errors ({"success": false, "error": "…"}).

CredentialWhere it worksHow you get itLifetime
JWTEvery /private endpointPOST /user/login/jwt, or POST /v1/auth/token30 days; refreshable
Refresh tokenGET /user/refresh onlyReturned next to every JWTSliding 90 days; revoked by POST /private/logout
API token (owl_…)GET /v1/quote, POST /v1/auth/tokenProfile page → API tokens (needs API access enabled)Until deleted

Recommended: API token → session

# 1. exchange the token for a session
curl -X POST -H "Authorization: Bearer owl_…" https://owleh.com/api/v1/auth/token
# {"success":true,"token":"<jwt>","refreshToken":"<opaque>","expiresAt":1764000000}

# 2. use the JWT everywhere
curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/deliveries/business/true

# 3. when a call returns 401, refresh and retry
curl -H "Authorization: <refreshToken>" https://owleh.com/api/user/refresh

Reading the responses

Conventions and errors

Most responses are wrapped: {"success": true, "data": …}. Some endpoints return a flat object instead (login returns {token, refreshToken}; the API quote returns its fields at the top level) — each entry below shows the exact shape. Errors carry an HTTP status plus a JSON body; the common bodies are listed here. Delivery creation is the one endpoint where business-rule failures (no saved card, a group you do not own, an inside-out time window) all surface as a 500 "Internal error" — validate against the quote and your own checks first.

The API has no rate limiting beyond the edge proxy and no webhooks. Live state is read by polling: the Owleh web app refreshes delivery lists every 2 seconds and courier locations every 5 seconds; something between 5 and 30 seconds is a sensible cadence for an integration.

StatusBodyMeaning
400{"error":"Cannot unbind json","success":false}The body is not valid JSON or a field has the wrong type
400{"error":"Parameters cannot be parsed","success":false}A path parameter is malformed, or a required part of the body (e.g. parcels) is missing
401(empty) or {"error":"Unauthorized request","success":false}Missing or expired JWT — refresh and retry
403(empty)The account type is not allowed here (a courier on a business endpoint) or a permission is missing
404{"error":"Not found","success":false}The resource is not yours or does not exist
409{"success":false,"delivery":{…}}State conflict on a delivery transition; the current delivery is returned
500{"error":"Internal error","success":false}A rule was broken or a provider failed; details go to the server log

The model

Delivery lifecycle

A delivery is one pickup and one or more parcels, each with its own drop-off and recipient (one parcel is a singleDrop, more is a multiDrop — the server sets this). You choose a service (standard, or urgent at ×1.25), a pickup deadline and a delivery deadline, and optionally a courier group to send it to instead of every courier. Pricing is done server-side at creation; the quote endpoints exist so you can show the price first.

After creation the delivery moves through the states below. Couriers drive it to delivered; you approve it (with a 2-hour undo window); an Owleh admin completes it, which is when a card hold is captured or a Direct Debit is collected. You can cancel only while it is still waiting — before a courier has accepted.

deliveryStateisActiveWho moves it onWhat it means
waitingtrueA courier accepts, or an admin assignsOn the board. The only state you can cancel from.
acceptedtrueThe courierA courier has the job; driverID is set and live location starts.
arrivedtrueThe courierAt the pickup address.
pickedtrueThe courierParcels collected, en route. Recipients see live tracking.
deliveredtrueYou (approve) within the web app or via incrementEvery parcel has a proof photo. Recipients have been told.
approvedByBusinessfalseAn admin (complete), or you (unapprove ≤ 2 h)You have signed the job off.
completefalse—Payment captured or collected. Terminal.
canceledfalse—Cancelled by you while waiting, or by an admin. Terminal.

Money

How a price is built

Each leg of the route is priced per kilometre by the zone of its drop-off (central London £1.10, London £1.40, elsewhere £0.90), plus £3 per parcel. Owleh adds a 10% commission, multiplies by 1.25 for urgent, then applies a floor (£8 standard / £10 urgent by default; an admin can set different floors per account). Your account may carry a discount percent per zone, with a separate set for weekend pickups — it comes off before the floor, so a floor-priced job is never discounted below the floor. Insurance is £1 per £100 declared value per parcel (declared value capped at £300). A promo code, then any platform-wide discount, come off last as line items.

Deliveries sent to a courier group may carry a further group percent. Businesses allowed to pay couriers directly ("off-platform") are charged nothing by Owleh; the quote suggests a commission-free courier price instead, and with the right permission you may set your own.

StepStandard exampleWhere you see it
Route 5 km in London + 1 parcel5 × £1.40 + £3 = £10.00—
+10% commission, truncated to 1 dp£11.00quote data / API price
Account discount 10%, then floor £8£9.90discountedPrice
+ insurance (£150 declared)+ £2.00computed client-side from the parcels
− promo codee.g. − £1.19promoDiscountAmount on the delivery
− platform discounte.g. 0%globalDiscountAmount on the delivery

Reference

Public endpoints

No credentials needed. Quote a route, follow a shared tracking link, check the API is up.

GET
/quote
Public

Public price estimate

Prices a standard-service delivery between two full UK postcodes at the public rate, with no account discount and the default floors. It is what the price calculator on the home page calls.

When to use it

Use it to show an estimate before anyone has signed in — a "what would this cost" widget. For your own account's price (discounts, your floors, urgent) use the API quote or the business quote instead.

Query parameters

pickup

string

required

Full UK postcode of the pickup.

dropoff

string

required

Full UK postcode of the drop-off.

parcels

integer

Number of parcels going to that drop-off. Anything missing, zero or unparsable is treated as 1.

Request

curl "https://owleh.com/api/quote?pickup=EC1A%201BB&dropoff=SW1A%201AA&parcels=1"

Response

{
  "success": true,
  "data": 12.4
}

Notes

Postcodes are validated against postcodes.io and normalised ("ec1a1bb" → "EC1A 1BB"). Distances are cached per postcode pair, so repeat calls for the same pair are fast.

Errors

400

{"error":"pickup and dropoff query params required"} or {"error":"invalid postcode"} (no success field).

500

{"error":"could not calculate distance"} — the mapping provider failed for this pair.

GET
/track/:token
Public

Public tracking by share token

The data behind the shareable tracking page (https://owleh.com/track/<token>): delivery state and windows, parcel count, the courier's first name and photo, and their live position while the job is in progress. No addresses, recipient details or prices.

When to use it

Embed live tracking in your own customer-facing pages: mint a token with the tracking-link endpoint, then poll this every 5–10 seconds while state is accepted, arrived or picked.

Path parameters

token

string

required

The share token from POST /private/deliveries/tracking-link/:deliveryID (also the one in recipient notifications).

Response fields

delivery.state

string

waiting · accepted · arrived · picked · delivered · approvedByBusiness · complete · canceled.

delivery.courierName

string

First name only; empty until a courier accepts.

delivery.courierAvatarUrl

string

Public photo path (prefix with the base URL); empty if none.

delivery.distanceMeters

integer

Planned route length.

location

object | null

Latest courier position, only while accepted/arrived/picked and a point exists. updatedAt is unix milliseconds.

Request

curl https://owleh.com/api/track/q3V9k…

Response

{
  "success": true,
  "delivery": {
    "deliveryID": 1234,
    "state": "picked",
    "serviceType": "standard",
    "pickUpTimeAfter": 0,
    "pickUpTimeBefore": 1761213600,
    "deliverTimeAfter": 0,
    "deliverTimeBefore": 1761228000,
    "created": "2026-10-23T09:12:00Z",
    "parcelCount": 1,
    "distanceMeters": 17300,
    "courierName": "Ali",
    "courierAvatarUrl": "/avatars/77/abc123.jpg"
  },
  "location": {
    "latitude": 51.5007,
    "longitude": -0.1246,
    "updatedAt": 1761215000000
  }
}

Errors

404

{"error":"not found"} — unknown or regenerated token.

GET
/invite/:token
Public

Preview a courier invite link

Who is behind an invite link a courier shared with you (https://owleh.com/invite/<token>).

When to use it

Show "Sam Patel invited you" before the business accepts.

Path parameters

token

string

required

From the link.

Request

curl https://owleh.com/api/invite/k8Jf2…

Response

{
  "success": true,
  "data": {
    "id": 77,
    "name": "Sam Patel",
    "email": "sam@example.com"
  }
}

Errors

400

"the courier who shared this link is no longer an approved courier".

404

"invite link not found".

POST
/contact
Public

Contact Owleh

The website contact form. Stores the request and notifies the Owleh team.

When to use it

Ask for API access, courier groups or any other permission on your account.

Request body (JSON)

name

string

email

string

Either email or phone is required.

phone

string

message

string

Request

curl -X POST -H "Content-Type: application/json" https://owleh.com/api/contact \
  -d '{"name":"Jane Doe","email":"ops@acme.co.uk","message":"Please enable API tokens on our account."}'

Response

{
  "success": true
}

Errors

400

"email or phone is required".

GET
/alive
Public

Health check

Returns the literal JSON true when the API is up. The same probe the platform uses.

When to use it

Monitoring and connectivity checks from your side.

Request

curl https://owleh.com/api/alive

Response

true

Reference

Integration API (API tokens)

Authenticated with an owl_ API token minted on your profile page. Two endpoints: price a route for your account, or exchange the token for a session that unlocks everything else.

GET
/v1/quote
API token
needs canUseApiTokens

Account price for a route

Prices a two-postcode delivery exactly as the web app would for your account — full rate with your floors, then with your account discount, then with any platform discount — in one call, authenticated by an API token.

When to use it

The simplest integration: show your customers (or your ops team) what a booking will cost without creating one or holding a session. Choose pickupAt on a weekend to get the weekend discount set.

Query parameters

pickup

string

required

Full UK postcode.

dropoff

string

required

Full UK postcode.

parcels

integer

1–50, default 1. Each parcel adds the per-parcel fee.

service

string

standard (default) or urgent (×1.25). Case-insensitive.

pickupAt

integer

Unix seconds of the planned pickup. Only chooses the weekday or weekend discount set (London day). Default: now.

Response fields

price

number

Full rate after your floor — no discount.

discountedPrice

number

With your account discount taken off, then floored.

discountPercent

number

The effective account percent for this route (weighted by drop-off zone).

platformDiscountPercent

number

A platform-wide percent an Owleh admin may have set.

total

number

discountedPrice less the platform percent: what a plain public booking with no insurance or promo is charged.

distanceKm

number

Driving distance used for the price.

Request

curl -H "Authorization: Bearer owl_…" \
  "https://owleh.com/api/v1/quote?pickup=EC1A%201BB&dropoff=SW1A%201AA&parcels=2&service=urgent"

Response

{
  "success": true,
  "pickup": "EC1A 1BB",
  "dropoff": "SW1A 1AA",
  "parcels": 2,
  "serviceType": "urgent",
  "distanceKm": 17.3,
  "currency": "GBP",
  "price": 44.2,
  "discountedPrice": 39.7,
  "discountPercent": 10,
  "platformDiscountPercent": 0,
  "total": 39.7
}

Notes

Insurance, promo codes, courier-group and off-platform prices are not part of this quote — they depend on choices made per booking. Use the business quote for those.

Errors

400

Missing/invalid postcode, parcels outside 1–50, unknown service, or a bad pickupAt — the error names the parameter.

401

"missing or invalid API token" — no owl_ prefix, unknown token, or the token belongs to a courier.

403

"API access is not enabled for this account" — ask Owleh to enable it.

502

"could not calculate the distance between these postcodes".

POST
/v1/auth/token
API token
needs canUseApiTokens

Exchange an API token for a session

Turns an API token into the same {token, refreshToken} pair the login form receives. The JWT has the account's full capabilities on every /private endpoint — nothing is scoped down.

When to use it

Any integration that does more than quote: create bookings, read delivery lists, mint tracking links, manage courier groups. Exchange once, keep the pair, refresh on 401, and re-exchange if the refresh ever fails.

Response fields

token

string

Access JWT, 30 days.

refreshToken

string

Opaque refresh token for GET /user/refresh (sliding 90 days).

expiresAt

integer

Unix seconds when the access JWT expires.

Request

curl -X POST -H "Authorization: Bearer owl_…" https://owleh.com/api/v1/auth/token

Response

{
  "success": true,
  "token": "eyJhbGciOiJIUzI1NiIs…",
  "refreshToken": "3f9c1a…",
  "expiresAt": 1764000000
}

Notes

No request body.

The refresh token remembers which API token minted it: delete the API token on the profile page and the next refresh of that session returns 401 (an already-issued JWT runs to its own expiry).

Errors

401

"missing or invalid API token".

403

"API access is not enabled for this account".

Reference

Deliveries

Quote, create, follow and sign off deliveries. Update and hard-delete endpoints also exist in the web app's client but are not supported for integrations: use cancel, and contact support for changes after a courier has accepted.

POST
/private/deliveries/quote
JWT · business account

Quote a booking before creating it

Prices the delivery you are about to create — multi-drop included — and returns every variant you might show: full and discounted, the courier-group pair, the off-platform suggestion, your effective discount percent and floors. The web app's "Calculate price" button.

When to use it

Call it with the same body you will send to create, show the user the number, then create. Pick groupPrice/groupDiscountedPrice if a courier group will be chosen, and add insurance and promo yourself (the quote ignores both).

Request body (JSON)

pickUpLocation.postcode

string

required

Pickup postcode.

parcels[].dropOffLocation.postcode

string

required

One per parcel; the route is optimised across them.

serviceType

string

standard or urgent.

pickUpTimeBefore

integer

Picks the weekday or weekend discount set. 0 = today.

Response fields

data

number

Full price after your floor.

discountedPrice

number

With your account percent, then floored. What you are charged before insurance/promo/platform discount.

groupPrice / groupDiscountedPrice

number

The same pair with the courier-group percent folded in — use when sending to a group.

offPlatformPrice / offPlatformDiscountedPrice

number

Suggested courier price when paying the courier directly (commission-free, own floors).

userDiscountPercent

number

Effective account percent for this route.

groupDiscountPercent / globalDiscountPercent

number

The two platform-set percents. The global one is applied last at charge time and is not included in the figures above.

minPrice / minPriceUrgent

number

The floors applied to your account.

Request

curl -X POST -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  https://owleh.com/api/private/deliveries/quote \
  -d '{"serviceType":"standard","pickUpTimeBefore":1761213600,"pickUpLocation":{"postcode":"EC1A 1BB"},"parcels":[{"dropOffLocation":{"postcode":"SW1A 1AA"}}]}'

Response

{
  "success": true,
  "data": 28.8,
  "discountedPrice": 25.92,
  "groupPrice": 27.36,
  "groupDiscountedPrice": 24.62,
  "offPlatformPrice": 24,
  "offPlatformDiscountedPrice": 21.6,
  "globalDiscountPercent": 0,
  "groupDiscountPercent": 5,
  "userDiscountPercent": 10,
  "minPrice": 8,
  "minPriceUrgent": 10
}

Notes

Insurance is not in the quote: add ceil(min(declared, 300) / 100) pounds per parcel yourself. Promo codes are not applied either — validate with GET /private/promo and subtract.

Errors

400

Body is not valid JSON.

500

Routing failed (usually a bad postcode).

POST
/private/deliveries
JWT · business account
charges your account

Create a delivery

Books a delivery. The server optimises the route, prices it, applies your discounts, promo and insurance, authorises payment with the chosen method, saves the booking, notifies couriers (and recipients, if enabled) and returns the new id.

When to use it

The core write. Quote first, then create with the same body plus payment and contact details. Treat a 500 as "a rule failed" (see notes) rather than a transient error, and do not retry blindly — if a PayPal order was captured the delivery was either saved or the capture refunded.

Request body (JSON)

serviceType

string

required

"standard" or "urgent". Anything else is priced as standard.

pickUpTimeBefore

integer

required

Unix seconds: collect by. Also picks the weekday/weekend discount set.

deliverTimeBefore

integer

required

Unix seconds: deliver by. Must not be before pickUpTimeBefore.

pickUpTimeAfter

integer

Unix seconds: earliest collection (0 = not set). Only needed for a custom window.

deliverTimeAfter

integer

Unix seconds: earliest delivery (0 = not set).

pickUpLocation

{postcode, address}

required

Full UK postcode and street address. Not validated beyond trimming — a bad postcode fails routing with a 500.

pickUpInstructions

string

Shown to the courier at pickup.

pickUpContact

{name, phone, email}

Who the courier calls at pickup.

parcels

Parcel[]

required

At least one. See the parcel fields below.

courierGroupID

integer

0 or absent = every courier. A group id sends the job only to that group (needs canCreateCourierGroups and the group must be yours).

offPlatformPayment

boolean

Pay the courier directly; Owleh charges nothing. Needs canPayCourierDirectly and a courierGroupID.

price

number

Off-platform only: the courier price you set (needs canSetOffPlatformPrice; ≤ £10,000). 0 = use the suggestion. Ignored on-platform.

paymentMethod

string

"stripe", "paypal" or "gocardless". Empty = your account default. Each must be set up first (see Payments).

paypalOrderID

string

Required when paying with PayPal: the id from POST /private/deliveries/paypal/order after buyer approval.

promoCode

string

Validated and applied server-side. An invalid code is silently ignored — check it first with GET /private/promo.

parcels[].dropOffLocation

{postcode, address}

required

Full UK postcode and street address of the drop-off.

parcels[].height / width / depth

integer

Centimetres. Shown to the courier; not validated.

parcels[].weight

number

Kilograms. The web app offers Small ≤1, Medium ≤5, Large ≤10.

parcels[].insuranceDeclaredValue

number

Pounds, capped at 300 per parcel. Costs £1 per £100 (rounded up), added to the charge.

parcels[].recipient

{fullName, email, phoneNumber, dropOffInstruction}

Who receives it. With recipient notifications enabled on your account they get the tracking link by email/SMS.

Request

curl -X POST -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  https://owleh.com/api/private/deliveries \
  -d '{"serviceType":"standard","pickUpTimeBefore":1761213600,"deliverTimeBefore":1761228000,"pickUpLocation":{"postcode":"EC1A 1BB","address":"1 High Street"},"pickUpInstructions":"Rear door, ask for Jo","pickUpContact":{"name":"Jo Patel","phone":"+447700900000","email":"jo@shop.co.uk"},"courierGroupID":0,"paymentMethod":"gocardless","promoCode":"","parcels":[{"dropOffLocation":{"postcode":"SW1A 1AA","address":"5 King Street"},"height":20,"width":30,"depth":10,"weight":2.5,"insuranceDeclaredValue":150,"recipient":{"fullName":"Sam Lee","email":"sam@example.com","phoneNumber":"+447700900111","dropOffInstruction":"Leave with concierge"}}]}'

Response

{
  "success": true,
  "id": 1234
}

Notes

Payment: Stripe needs a saved card (a hold is taken now, captured on completion). PayPal needs paypalOrderID (captured immediately). GoCardless needs an active Direct Debit mandate (collected on completion). Off-platform bookings send no paymentMethod and are not charged.

Time windows: only inside-out windows are rejected (pickup after its own deadline, delivery deadline before the pickup deadline, and so on). There is no minimum lead time — the web app enforces a sensible service window client-side, and you should too.

Server-set fields in the body are ignored: deliveryType, deliveryState, driverID, businessID, courierPrice, distance, the discount amounts and created.

Every business-rule failure — no saved card, a hidden payment provider, a group you do not own, mustUseCourierGroups with courierGroupID 0, an inside-out window, routing failure — returns 500 {"error":"Internal error"}. The specific reason is logged server-side only.

Errors

400

"Cannot unbind json" for malformed JSON; "Parameters cannot be parsed" when parcels is empty.

500

Any rule or provider failure (see notes).

POST
/private/deliveries/paypal/order
JWT · business account

Create a PayPal order for a booking

Prices the delivery exactly like create and opens a PayPal order (intent CAPTURE) for that amount. Nothing is saved. The buyer approves the order in PayPal; you then create the delivery with paypalOrderID and paymentMethod "paypal", and the order is captured.

When to use it

Only when paying a booking with PayPal. Send the same body you will send to create so the amounts match — create verifies the order total against its own price.

Request body (JSON)

(Delivery)

object

required

The same body as POST /private/deliveries (parcels must not be empty).

Request

curl -X POST -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  https://owleh.com/api/private/deliveries/paypal/order -d '<delivery json>'

Response

{
  "success": true,
  "orderID": "5O190127TN364715T"
}

Errors

400

Malformed body or empty parcels.

500

PayPal not offered (not configured or hidden by an admin), an off-platform body, or pricing failed.

GET
/private/deliveries/business/:isActive
JWT

List your deliveries

Your active deliveries (waiting through delivered, oldest first) or your past ones (approvedByBusiness, complete, canceled — newest first). The full delivery object is returned, parcels and recipients included.

When to use it

Poll this to follow state. There is no single-delivery GET: fetch the list and find the id. There is no paging either, so cache the past list and poll only the active one.

Path parameters

isActive

boolean

required

true for in-flight deliveries, false for finished ones. Accepts true/false/1/0.

Response fields

data[].deliveryState

string

See the lifecycle table.

data[].driverID

integer

0 until a courier accepts. Resolve the courier with GET /private/driver/:deliveryID.

data[].price

number

Full price. Your charge = price − userDiscountAmount + insurance − promoDiscountAmount − globalDiscountAmount.

data[].courierPrice

number

What the courier earns (90% of the full price; the whole amount off-platform).

data[].distance

integer

Metres.

data[].parcels[].stage

string

waiting → onDelivery (first proof photo) → delivered.

data[].parcels[].numberOfImages

integer

Proof photos uploaded so far; fetch them with GET /private/parcels/images/:parcelID.

data[].created

string

RFC 3339 booking time.

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/deliveries/business/true

Response

{
  "success": true,
  "data": [
    {
      "ID": 1234,
      "deliveryType": "singleDrop",
      "serviceType": "standard",
      "deliveryState": "accepted",
      "isActive": true,
      "driverID": 77,
      "businessID": 12,
      "courierGroupID": 0,
      "offPlatformPayment": false,
      "pickUpTimeAfter": 0,
      "pickUpTimeBefore": 1761213600,
      "deliverTimeAfter": 0,
      "deliverTimeBefore": 1761228000,
      "pickUpLocation": {
        "postcode": "EC1A 1BB",
        "address": "1 HIGH STREET"
      },
      "pickUpInstructions": "Rear door, ask for Jo",
      "pickUpContact": {
        "name": "Jo Patel",
        "phone": "+447700900000",
        "email": "jo@shop.co.uk"
      },
      "price": 28.8,
      "courierPrice": 25.92,
      "distance": 17300,
      "insuranceDeclaredValue": 0,
      "promoCode": "",
      "promoDiscountAmount": 0,
      "globalDiscountAmount": 0,
      "userDiscountAmount": 2.88,
      "parcels": [
        {
          "ID": 555,
          "dropOffLocation": {
            "postcode": "SW1A 1AA",
            "address": "5 KING STREET"
          },
          "height": 20,
          "width": 30,
          "depth": 10,
          "weight": 2.5,
          "insuranceDeclaredValue": 150,
          "recipient": {
            "email": "sam@example.com",
            "fullName": "Sam Lee",
            "phoneNumber": "+447700900111",
            "dropOffInstruction": "Leave with concierge"
          },
          "numberOfImages": 0,
          "stage": "waiting"
        }
      ],
      "created": "2026-10-23T09:12:00Z",
      "paypalOrderID": "",
      "paymentMethod": "",
      "paymentDetailsID": 981
    }
  ]
}

Notes

The tracking share token is not in this object — mint or read it with the tracking-link endpoint. paypalOrderID and paymentMethod are always empty here (not stored).

Errors

400

"Parameters cannot be parsed" — isActive is not a boolean.

GET
/private/deliveries/stats
JWT

Spend statistics

All-time and per-month delivery counts, total spend and average price for your account. Spend is the price less every discount, excluding insurance and excluding canceled and off-platform deliveries.

When to use it

Dashboards and monthly reporting without pulling every delivery.

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/deliveries/stats

Response

{
  "success": true,
  "data": {
    "allTime": {
      "month": "",
      "deliveryCount": 42,
      "totalSpend": 980.5,
      "avgPrice": 23.35
    },
    "byMonth": [
      {
        "month": "2026-10",
        "deliveryCount": 8,
        "totalSpend": 190.2,
        "avgPrice": 23.78
      }
    ]
  }
}

Notes

byMonth is newest first and holds at most 12 months.

POST
/private/deliveries/cancel/:deliveryID
JWT · business account

Cancel a waiting delivery

Cancels a delivery that no courier has accepted yet and releases the payment: a card hold is voided, a PayPal capture is refunded, a Direct Debit has nothing to release. Once a courier has accepted, cancellation goes through Owleh support.

When to use it

Customer changed their mind, duplicate booking, wrong address — as long as deliveryState is still waiting.

Path parameters

deliveryID

integer

required

The id returned by create.

Request

curl -X POST -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/deliveries/cancel/1234

Response

{
  "success": true
}

Notes

If the payment provider refuses the release, the cancel is rolled back and a 500 is returned.

Errors

400

deliveryID is not a number.

500

Not yours, not in waiting, or the provider failed.

PUT
/private/deliveries/increment/:deliveryID
JWT

Approve a delivered job

Moves a delivery from delivered to approvedByBusiness, taking it off your active list and stamping the approval time. The same endpoint couriers use to advance their states; for a business the only valid transition is this one.

When to use it

After you have checked the proof photos. You have 2 hours to undo with unapprove; an admin then completes it, which captures payment.

Path parameters

deliveryID

integer

required

A delivery of yours in state delivered.

Request

curl -X PUT -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/deliveries/increment/1234

Response

{
  "success": true,
  "delivery": {
    "ID": 1234,
    "deliveryType": "singleDrop",
    "serviceType": "standard",
    "deliveryState": "approvedByBusiness",
    "isActive": false,
    "driverID": 77,
    "businessID": 12,
    "courierGroupID": 0,
    "offPlatformPayment": false,
    "pickUpTimeAfter": 0,
    "pickUpTimeBefore": 1761213600,
    "deliverTimeAfter": 0,
    "deliverTimeBefore": 1761228000,
    "pickUpLocation": {
      "postcode": "EC1A 1BB",
      "address": "1 HIGH STREET"
    },
    "pickUpInstructions": "Rear door, ask for Jo",
    "pickUpContact": {
      "name": "Jo Patel",
      "phone": "+447700900000",
      "email": "jo@shop.co.uk"
    },
    "price": 28.8,
    "courierPrice": 25.92,
    "distance": 17300,
    "insuranceDeclaredValue": 0,
    "promoCode": "",
    "promoDiscountAmount": 0,
    "globalDiscountAmount": 0,
    "userDiscountAmount": 2.88,
    "parcels": [
      {
        "ID": 555,
        "dropOffLocation": {
          "postcode": "SW1A 1AA",
          "address": "5 KING STREET"
        },
        "height": 20,
        "width": 30,
        "depth": 10,
        "weight": 2.5,
        "insuranceDeclaredValue": 150,
        "recipient": {
          "email": "sam@example.com",
          "fullName": "Sam Lee",
          "phoneNumber": "+447700900111",
          "dropOffInstruction": "Leave with concierge"
        },
        "numberOfImages": 0,
        "stage": "waiting"
      }
    ],
    "created": "2026-10-23T09:12:00Z",
    "paypalOrderID": "",
    "paymentMethod": "",
    "paymentDetailsID": 981
  }
}

Errors

409

{"success":false,"delivery":{…}} — not in delivered (or already approved); the current delivery is returned so you can resync.

POST
/private/deliveries/unapprove/:deliveryID
JWT · business account

Undo an approval

Puts an approvedByBusiness delivery back to delivered, within 2 hours of approving it.

When to use it

You approved by mistake or want to dispute the proof before Owleh completes the job.

Path parameters

deliveryID

integer

required

A delivery you approved less than 2 hours ago.

Request

curl -X POST -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/deliveries/unapprove/1234

Response

{
  "success": true
}

Errors

400

"delivery cannot be un-approved — either not yours, not in approved state, or the 2-hour window has passed".

POST
/private/deliveries/tracking-link/:deliveryID
JWT · business account

Get or regenerate the tracking link

Returns the delivery's share token, creating one if it does not exist yet. The public page is https://owleh.com/track/<token> and the public API is GET /track/:token. It is the same token recipients receive in their notification.

When to use it

Put live tracking in your own order page or confirmation email. Regenerate when a link was shared too widely — old links stop working immediately.

Path parameters

deliveryID

integer

required

One of your deliveries.

Query parameters

regenerate

1

Issue a new token and invalidate the previous one.

Request

curl -X POST -H "Authorization: Bearer <jwt>" "https://owleh.com/api/private/deliveries/tracking-link/1234"

Response

{
  "success": true,
  "token": "q3V9kLm2xPzR8sT4uVwX6yZ0aBcDeF1g"
}

Errors

400

deliveryID is not a number.

404

{"error":"delivery not found"} — not yours or unknown.

GET
/private/location/:deliveryID
JWT

Courier path for a delivery

The courier's recorded GPS points for one of your deliveries, oldest first, bounded to the time between acceptance and completion so you never see them before or after the job.

When to use it

Drawing the route travelled on a map in your own tools, or auditing a delivery. For a live "where are they now" use the public track endpoint with a share token.

Path parameters

deliveryID

integer

required

One of your deliveries. Unknown or not yours returns an empty list, not an error.

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/location/1234

Response

{
  "success": true,
  "data": [
    {
      "latitude": 51.4921,
      "longitude": 0.1206,
      "created_at": 0
    },
    {
      "latitude": 51.4935,
      "longitude": 0.1142,
      "created_at": 0
    }
  ]
}

Notes

created_at is always 0 on this endpoint; points are in chronological order.

GET
/private/parcels/images/:parcelID
JWT

Proof-of-delivery photos for a parcel

Lists the proof photos the courier took for a parcel, with where and when each was taken. Each url is an authenticated image path under the same base URL.

When to use it

Show proof in your own order history, or archive it. The first photo on a parcel is the moment that parcel's recipient is notified of delivery.

Path parameters

parcelID

integer

required

parcels[].ID from the delivery object.

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/parcels/images/555

Response

{
  "success": true,
  "data": [
    {
      "url": "/images/77/3fa9c2e1b7d4.jpg",
      "latitude": 51.5007,
      "longitude": -0.1246,
      "takenAt": 1761215000
    }
  ]
}

Notes

To display an image, GET /images/:driverID/:file with your JWT — a plain <img> tag cannot send the header, so fetch it as a blob. Responses are immutable and cacheable for a year.

Errors

403

The parcel belongs to another business.

404

Unknown parcel.

GET
/private/suggestions/:postcode
JWT · business account

Addresses at a postcode

Validates a postcode and returns the street addresses at it, formatted as one line each — the autocomplete behind every address field in the web app.

When to use it

Build the same postcode-first address entry in your own booking form so the address you send is one the courier can find.

Path parameters

postcode

string

required

Full UK postcode, any case or spacing.

Request

curl -H "Authorization: Bearer <jwt>" "https://owleh.com/api/private/suggestions/EC1A%201BB"

Response

{
  "data": [
    "1 High Street, London, EC1A 1BB",
    "2 High Street, London, EC1A 1BB"
  ]
}

Notes

No success field on this one. An invalid postcode comes back as a 500 with {"error":"invalid postcode"}.

PUT
/private/recipients
JWT

Update recipients on existing parcels

Overwrites the recipient details (name, email, phone, drop-off instruction) on parcels you own, pairing recipients[i] with parcel_ids[i].

When to use it

Fixing a typo in a phone number or adding an email after booking. Note the snake_case parcel_ids key, and keep both arrays the same length.

Request body (JSON)

recipients

Recipient[]

required

{fullName, email, phoneNumber, dropOffInstruction} per parcel.

parcel_ids

integer[]

required

Parcel ids in the same order. Must be at least as long as recipients.

Request

curl -X PUT -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  https://owleh.com/api/private/recipients \
  -d '{"recipients":[{"fullName":"Sam Lee","email":"sam@example.com","phoneNumber":"+447700900111","dropOffInstruction":"Ring twice"}],"parcel_ids":[555]}'

Response

{
  "success": true
}

Errors

400

"Parameters cannot be parsed".

GET
/private/recipients/saved
JWT

Recently used recipients

Up to 50 distinct recipients from your past bookings, most recently used first — the address-book suggestions in the web app.

When to use it

Pre-fill a booking form for repeat customers.

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/recipients/saved

Response

{
  "success": true,
  "data": [
    {
      "fullName": "Sam Lee",
      "email": "sam@example.com",
      "phoneNumber": "+447700900111",
      "dropOffInstruction": "Leave with concierge",
      "lastUsed": "2026-10-20T14:00:00Z"
    }
  ]
}
GET
/private/promo
JWT

Validate a promo code

Checks a code is active, unexpired, not used up by you and valid for your account, and returns what it is worth. The discount is applied again server-side at creation, so this is for display.

When to use it

Before showing a discounted total. Create silently ignores an invalid code, so validate here first or the customer sees a different charge.

Query parameters

code

string

required

The code; case-insensitive.

Response fields

discountPercent

number | null

If set and > 0, a percent of (discounted price + insurance).

discountAmount

number

Otherwise a fixed amount in pounds. The charge never drops below £0.30.

Request

curl -H "Authorization: Bearer <jwt>" "https://owleh.com/api/private/promo?code=WELCOME10"

Response

{
  "success": true,
  "code": "WELCOME10",
  "discountAmount": 0,
  "discountPercent": 10
}

Errors

400

"code is required".

403

"This promo code is not valid for your account".

404

"Invalid promo code".

422

"This promo code is no longer active" / "has expired" / "You have already used this promo code".

Reference

Account and sessions

Sign in, keep a session alive, and read or update the business profile. Permission flags are granted by Owleh admins and gate the optional features below.

FlagWhat it unlocks
canUseApiTokensMint API tokens; GET /v1/quote and POST /v1/auth/token. Revoking it disables every token at once.
canCreateCourierGroupsCourier groups, the payouts ledger, and courierGroupID on a delivery.
canPayCourierDirectlyOff-platform bookings (offPlatformPayment): you pay the courier, Owleh charges nothing.
canSetOffPlatformPriceSend your own price on off-platform bookings instead of the suggestion.
canNotifyRecipientsRecipients get the booking and delivered messages with a tracking link.
canEditNotificationTemplatesRewrite those messages via /private/settings/notifications.
mustUseCourierGroupsA restriction: public bookings (courierGroupID 0) are refused.
canSeeListPriceDisplay-only in the web app (list price + VAT). No API effect.
POST
/user/register
Public

Create a business account

Registers a sender account from an email and password and signs it in straight away — businesses are approved immediately. Name, business name, phone and address are set afterwards with PUT /private/user.

When to use it

Programmatic onboarding of your own sub-accounts. For a normal integration, create the account on the website once and use API tokens.

Request body (JSON)

email

string

required

Lowercased. Must not already be registered.

password

string

required

Stored hashed. No server-side length rule at registration (the reset flow enforces 8+).

Request

curl -X POST -H "Content-Type: application/json" https://owleh.com/api/user/register \
  -d '{"email":"ops@acme.co.uk","password":"correct horse battery staple"}'

Response

{
  "token": "eyJhbGciOiJIUzI1NiIs…",
  "refreshToken": "3f9c1a…"
}

Notes

Flat response, no success wrapper.

Errors

400

Malformed JSON.

500

The email is already registered (there is no dedicated "exists" response) or the account could not be created.

POST
/user/login/jwt
Public

Sign in with email and password

Exchanges credentials for an access JWT and a refresh token.

When to use it

Interactive sign-in. Server-to-server integrations should prefer an API token and POST /v1/auth/token so the password never leaves your vault.

Request body (JSON)

email

string

required

Case-insensitive.

password

string

required

Request

curl -X POST -H "Content-Type: application/json" https://owleh.com/api/user/login/jwt \
  -d '{"email":"ops@acme.co.uk","password":"…"}'

Response

{
  "token": "eyJhbGciOiJIUzI1NiIs…",
  "refreshToken": "3f9c1a…"
}

Errors

400

Malformed JSON, or a login type other than jwt in the path.

500

Unknown email or wrong password — the two are not distinguished.

GET
/user/refresh
Public

Refresh the access token

Presents the refresh token in the Authorization header and returns a fresh access JWT. The refresh token itself is not rotated: you get the same value back with its expiry pushed 90 days out.

When to use it

Whenever a /private call returns 401. If this returns 401 too, the session is gone: sign in again or re-exchange your API token.

Request

curl -H "Authorization: Bearer 3f9c1a…" https://owleh.com/api/user/refresh

Response

{
  "token": "eyJhbGciOiJIUzI1NiIs…(new)",
  "refreshToken": "3f9c1a…(same)"
}

Notes

A still-valid access JWT is also accepted here and yields a brand-new pair (legacy clients). Prefer the refresh token.

Errors

401

Empty header, unknown or expired refresh token, or the account no longer exists.

POST
/private/logout
JWT

Revoke a session

Revokes the given refresh token server-side. Always returns success; the access JWT stays valid until it expires on its own.

When to use it

When an integration is decommissioned or a session is suspected leaked. Deleting an API token does the same for every session it minted.

Request body (JSON)

refreshToken

string

The refresh token to revoke. Optional; without it nothing is revoked.

Request

curl -X POST -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  https://owleh.com/api/private/logout -d '{"refreshToken":"3f9c1a…"}'

Response

{
  "success": true
}
POST
/user/reset/token
Public

Request a password reset email

Emails a reset link valid for 15 minutes. Always answers success so the endpoint cannot be used to discover which emails have accounts.

When to use it

Your own "forgot password" screen. The link in the email lands on owleh.com; the token at its end can be used with the endpoint below.

Request body (JSON)

email

string

required

Request

curl -X POST -H "Content-Type: application/json" https://owleh.com/api/user/reset/token -d '{"email":"ops@acme.co.uk"}'

Response

{
  "success": true
}

Errors

400

{"error":"Invalid request"} — malformed JSON.

POST
/user/reset/password
Public

Set a new password with a reset token

Sets the password, consumes the token and revokes every refresh token on the account. Existing access JWTs run to their expiry.

When to use it

Completing the flow started above.

Request body (JSON)

token

string

required

From the reset email.

new_password

string

required

At least 8 characters. Note the snake_case key.

Request

curl -X POST -H "Content-Type: application/json" https://owleh.com/api/user/reset/password \
  -d '{"token":"…","new_password":"a much better password"}'

Response

{
  "success": true
}

Errors

400

"Reset token is required", "Password must be at least 8 characters", or "This reset link is invalid or has expired. Please request a new one."

500

"Failed to reset password".

GET
/private/user
JWT

Your profile

The account record, returned flat (no success wrapper). Bank fields exist for couriers and are empty on a business.

When to use it

Read it before every PUT — the update overwrites the whole profile.

Response fields

registrationStatus

string

approved for businesses; disabled after DELETE /private/user.

profilePicture

string

Object filename only; the public image is /avatars/{id}/{profilePicture}.

numberOfDeliveries

integer

Server-owned counter.

created

string

Not populated on this endpoint (always the zero date).

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/user

Response

{
  "id": 42,
  "email": "ops@acme.co.uk",
  "name": "Jane",
  "surname": "Doe",
  "businessName": "Acme Pharmacy",
  "phoneNumber": "07123456789",
  "address": "1 High Street, London",
  "postcode": "N1 9GU",
  "registrationStatus": "approved",
  "userType": "business",
  "sortCode": "",
  "bankName": "",
  "accountHolderName": "",
  "accountNumber": "",
  "numberOfDeliveries": 0,
  "profilePicture": "",
  "created": "0001-01-01T00:00:00Z"
}

Notes

The active payment method is not on this object: read GET /private/payment/paypal and /gocardless for their active flags.

PUT
/private/user
JWT
full overwrite

Update your profile

Writes name, surname, businessName, phoneNumber, address, postcode and the bank fields from the body. It is a plain overwrite: any of those keys you leave out is saved as an empty string.

When to use it

Setting the business name and contact details after registration. Always GET, modify, PUT the whole object.

Request body (JSON)

id

integer

required

Must be your own user id, otherwise 401.

name, surname, businessName, phoneNumber, address, postcode

string

Written as given. A 10-digit phone number without a leading 0 gets one added.

sortCode, bankName, accountNumber, accountHolderName

string

Courier payee fields; send them back unchanged.

Request

curl -X PUT -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  https://owleh.com/api/private/user -d '{"id":42,"email":"ops@acme.co.uk","name":"Jane","surname":"Doe","businessName":"Acme Pharmacy Ltd","phoneNumber":"07123456789","address":"1 High Street, London","postcode":"N1 9GU","registrationStatus":"approved","userType":"business","sortCode":"","bankName":"","accountHolderName":"","accountNumber":"","numberOfDeliveries":0,"profilePicture":"","created":"0001-01-01T00:00:00Z"}'

Response

{
  "success": true
}

Notes

email, userType, registrationStatus, numberOfDeliveries and profilePicture in the body are ignored.

Errors

400

Malformed JSON.

401

id is not yours.

500

"User cannot be updated".

DELETE
/private/user
JWT
irreversible from the API

Disable your account

Marks the account disabled. Nothing is deleted and existing sessions are not revoked; contact support to restore it.

Request

curl -X DELETE -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/user

Response

{
  "success": true
}
POST
/private/user/avatar
JWT

Upload a profile photo

Multipart upload of any common image up to 15 MB. It is rotated upright, square-cropped, resized to 512px and stored as a JPEG under a new random filename. DELETE /private/user/avatar removes it.

When to use it

Branding the account. The photo is public at the returned avatarUrl — the random filename is the only access control.

Request body (JSON)

photo

file (multipart/form-data)

required

JPEG, PNG, WebP, GIF, BMP or TIFF; > 0 and ≤ 15 MB.

Request

curl -X POST -H "Authorization: Bearer <jwt>" -F "photo=@logo.png" https://owleh.com/api/private/user/avatar

Response

{
  "success": true,
  "data": {
    "profilePicture": "3f9c2e1b7d4a.jpg",
    "avatarUrl": "/avatars/42/3f9c2e1b7d4a.jpg"
  }
}

Errors

400

Missing/oversized file, or "That file isn't an image we can read…".

502

Storage failed.

GET
/private/user/permissions
JWT

Your permission flags

The admin-granted capabilities on the account. Only granted flags appear, each true; an account with none gets an empty object.

When to use it

Decide which features to expose in your integration: courier groups, off-platform pricing, notification templates, API tokens. See the table above for what each unlocks.

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/user/permissions

Response

{
  "success": true,
  "data": {
    "canCreateCourierGroups": true,
    "canNotifyRecipients": true,
    "canUseApiTokens": true
  }
}
GET
/private/driver/:deliveryID
JWT

The courier on a delivery

Name, phone number and photo of the courier assigned to one of your deliveries. Email and bank details are never exposed.

When to use it

Show "your courier is Sam" and a call button once deliveryState is accepted or later.

Path parameters

deliveryID

integer

required

One of your deliveries with a courier assigned.

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/driver/1234

Response

{
  "success": true,
  "data": {
    "id": 77,
    "name": "Sam",
    "surname": "Patel",
    "phoneNumber": "07700900123",
    "avatarUrl": "/avatars/77/ab12cd34.jpg"
  }
}

Errors

404

Not your delivery, or no courier assigned yet.

Reference

Payment methods

Owleh collects from the business with one of three methods, chosen per booking with paymentMethod (or your stored default when it is omitted). Direct Debit via GoCardless is a set-once mandate collected when a delivery completes. Card via Stripe is a saved card held at booking and captured at completion. PayPal is approved by the buyer for each booking and captured immediately. Couriers are paid by Owleh; none of this touches them. Off-platform bookings skip collection entirely.

An explicit paymentMethod that is unavailable or not set up makes the booking fail rather than silently falling back. Cancelling a waiting delivery releases the hold (card), refunds the capture (PayPal) or does nothing (Direct Debit, not yet collected).

paymentMethodNeedsCollectedCancel while waiting
gocardlessAn active mandate (POST …/gocardless/mandate, then poll GET)At completionNothing to release
stripeA saved card (POST …/setup → Stripe.js → POST …/card)Held at booking, captured at completionHold voided
paypalpaypalOrderID from POST /private/deliveries/paypal/order, approved by the buyerImmediatelyRefunded
GET
/private/payment/providers
JWT · business account

Which payment methods are offered

One flag per collection method: true when it is configured on the platform and not hidden by an admin.

When to use it

Call it first and only offer set-up flows for methods that are true. A hidden provider returns 503 on its set-up endpoints and cannot be used on a booking.

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/payment/providers

Response

{
  "success": true,
  "stripe": true,
  "paypal": true,
  "gocardless": true
}
POST
/private/payment/gocardless/mandate
JWT · business account

Set up a Direct Debit mandate (GoCardless)

POST opens a hosted GoCardless Billing Request flow and returns the page to send the user to. GET reports whether a mandate is active, completing a pending flow on the way, so poll it after the user returns. DELETE removes the stored mandate (and resets the default method to card if it was Direct Debit).

When to use it

The recommended way to pay. Set up once; every later booking with paymentMethod "gocardless" needs no approval — the Direct Debit is created when Owleh completes the delivery.

Request

curl -X POST -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/payment/gocardless/mandate
# → send the user to authorisation_url, then poll:
curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/payment/gocardless/mandate
# {"success":true,"active":true}

Response

{
  "success": true,
  "authorisation_url": "https://pay.gocardless.com/billing/static/flow?id=BRF…"
}

Notes

The hosted flow returns the user to https://owleh.com; if you run the flow from your own site, keep a tab on your side and poll GET until active is true.

Errors

503

"GoCardless is not available".

GET
/private/payment/gocardless
JWT · business account

Direct Debit availability and default

GET reports availability, whether Direct Debit is your default method, and whether a mandate is active. POST makes it the default (needs an active mandate); DELETE reverts the default to card and keeps the mandate.

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/payment/gocardless
curl -X POST -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/payment/gocardless

Response

{
  "success": true,
  "available": true,
  "active": true,
  "mandateActive": true
}

Errors

400

POST: "set up a GoCardless Direct Debit before selecting it".

503

"GoCardless is not available".

POST
/private/payment/setup
JWT · business account

Start saving a card (Stripe)

Creates a Stripe customer for the account if needed and a SetupIntent, returning its client secret for Stripe.js.

When to use it

Step 1 of saving a card: confirm the SetupIntent in the browser with Stripe.js, then send the resulting payment method id to POST /private/payment/card.

Request

curl -X POST -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/payment/setup

Response

{
  "success": true,
  "client_secret": "seti_1Px…_secret_…"
}

Errors

503

{"error":"Card payments are not available"} — hidden by an admin.

POST
/private/payment/card
JWT · business account

Save the confirmed card

Attaches the payment method from the confirmed SetupIntent as the account's card. One card per account; saving again replaces it. GET returns the saved card ({"card": null} when none), DELETE detaches it.

When to use it

Step 2 of saving a card. Required before any booking with paymentMethod "stripe": a hold is placed at creation and captured when Owleh completes the delivery.

Request body (JSON)

payment_method_id

string

required

The pm_… id from Stripe.js.

Request

curl -X POST -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  https://owleh.com/api/private/payment/card -d '{"payment_method_id":"pm_1Px…"}'

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/payment/card
curl -X DELETE -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/payment/card

Response

{
  "success": true,
  "last4": "4242",
  "brand": "visa"
}

Notes

GET /private/payment/card → {"success":true,"card":{"last4":"4242","brand":"visa"}}.

Errors

400

"payment_method_id required".

503

"Card payments are not available".

GET
/private/payment/paypal
JWT · business account

PayPal availability and default

GET reports whether PayPal is offered, whether it is your default method, and the PayPal client id to load the JS SDK with. POST makes PayPal your default method; DELETE reverts the default to card. There is nothing to "set up" — every PayPal payment is approved by the buyer at booking time.

When to use it

Load the SDK with clientID, create an order with POST /private/deliveries/paypal/order, let the buyer approve it, then create the delivery with paypalOrderID. The default only matters when a booking omits paymentMethod.

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/payment/paypal
curl -X POST -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/payment/paypal   # make it the default
curl -X DELETE -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/payment/paypal # back to card

Response

{
  "success": true,
  "available": true,
  "active": false,
  "clientID": "AYSq3RDGsmBLJE-otTkBtM-jBRd1TCQwFf9RGfwddNXWz0uFU9ztymylOhRS"
}

Errors

503

POST: "PayPal is not available".

Reference

Courier groups and direct payouts

By default a booking goes to every Owleh courier. With courier groups enabled you can define named groups and send a booking only to one of them (courierGroupID). With direct payment enabled as well, a booking to a group can be off-platform: Owleh charges you nothing, the courier is paid by you, and the ledger below tells you what you owe. Only members of a group see its jobs on the board; a delivery can still be handed to another courier by the one who accepted it.

GET
/private/courier-groups
JWT · business account
needs canCreateCourierGroups

List your courier groups

Your groups, newest first, each with its members (id, name, email).

When to use it

Populate a "send to" picker: the group ids go into courierGroupID on a booking.

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/courier-groups

Response

{
  "success": true,
  "data": [
    {
      "id": 12,
      "businessID": 42,
      "name": "Night couriers",
      "created": "2026-09-14T10:02:11Z",
      "members": [
        {
          "id": 77,
          "name": "Sam Patel",
          "email": "sam@example.com"
        }
      ]
    }
  ]
}

Errors

403

{"error":"not permitted to manage courier groups"}.

POST
/private/courier-groups
JWT · business account
needs canCreateCourierGroups

Create, rename or delete a group

POST creates a named group (members empty). PUT /private/courier-groups/:id renames it. DELETE /private/courier-groups/:id removes it — its members are dropped and any deliveries still pointing at it become public.

When to use it

Segment couriers by area, shift or client. Names are not required to be unique.

Request body (JSON)

name

string

required

Trimmed; must not be blank.

Request

curl -X POST -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  https://owleh.com/api/private/courier-groups -d '{"name":"Night couriers"}'

curl -X PUT -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  https://owleh.com/api/private/courier-groups/12 -d '{"name":"Evening couriers"}'
curl -X DELETE -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/courier-groups/12

Response

{
  "success": true,
  "data": {
    "id": 12,
    "businessID": 42,
    "name": "Night couriers",
    "created": "2026-09-14T10:02:11Z",
    "members": []
  }
}

Errors

400

"name is required", "invalid body" or "invalid group id".

404

"courier group not found or not owned by this business".

POST
/private/courier-groups/:id/members
JWT · business account
needs canCreateCourierGroups

Add or remove a courier

Adds an approved Owleh courier to the group by their email (case-insensitive). DELETE /private/courier-groups/:id/members/:courierId removes them. Both are idempotent.

When to use it

There is deliberately no courier directory: you need the courier's email, or they can send you an invite link (below) which adds them for you.

Path parameters

id

integer

required

A group you own.

Request body (JSON)

email

string

required

The courier's Owleh email. Must be an approved courier.

Request

curl -X POST -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  https://owleh.com/api/private/courier-groups/12/members -d '{"email":"sam@example.com"}'
curl -X DELETE -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/courier-groups/12/members/77

Response

{
  "success": true,
  "data": {
    "id": 77,
    "name": "Sam Patel",
    "email": "sam@example.com"
  }
}

Errors

400

"email is required", "invalid group id" or "invalid courier id".

404

"no approved courier found with that email" or the group is not yours.

GET
/private/courier-payouts
JWT · business account
needs canCreateCourierGroups

What you owe each courier (off-platform)

For bookings you pay the courier directly, one row per courier with unpaid deliveries: count, total and their payee details so you can make the bank transfer. GET /private/courier-payouts/:driverID lists that courier's deliveries, paid and unpaid.

When to use it

Run it on your payment day, pay each courier, then mark them paid with the endpoint below. Filter by booking date with from/to.

Query parameters

from

YYYY-MM-DD

Inclusive start, London calendar day of the booking.

to

YYYY-MM-DD

Inclusive end.

Request

curl -H "Authorization: Bearer <jwt>" "https://owleh.com/api/private/courier-payouts?from=2026-10-01&to=2026-10-31"
curl -H "Authorization: Bearer <jwt>" "https://owleh.com/api/private/courier-payouts/77?from=2026-10-01&to=2026-10-31"

Response

{
  "success": true,
  "data": [
    {
      "driverID": 77,
      "name": "Sam Patel",
      "email": "sam@example.com",
      "deliveryCount": 4,
      "totalPending": 86.4,
      "accountHolderName": "S Patel",
      "sortCode": "12-34-56",
      "accountNumber": "12345678",
      "bankName": "Monzo"
    }
  ]
}

Notes

The per-courier detail rows are {deliveryID, paymentDetailsID, deliveryState, amount, courierAmount, status: "direct", payoutMade, createdAt}, newest first. Only delivered, approved or complete deliveries count.

Errors

400

"from/to must be YYYY-MM-DD" or "invalid driver id".

POST
/private/courier-payouts/paid/:driverID
JWT · business account
needs canCreateCourierGroups

Mark a courier as paid

Flags every outstanding off-platform delivery with that courier as settled and returns how many. It ignores from/to — it always settles everything outstanding.

When to use it

Right after you have sent the bank transfer.

Path parameters

driverID

integer

required

From the ledger.

Request

curl -X POST -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/courier-payouts/paid/77

Response

{
  "success": true,
  "count": 4
}
POST
/private/courier-invite/:token/accept
JWT · business account

Accept a courier's invite

In one step: enables courier groups on your account, creates your default "Couriers" group if needed, and adds the inviting courier to it. Accepting again is a no-op. No permission needed — this is how the permission is granted.

When to use it

A courier you already work with wants to receive your bookings through Owleh.

Path parameters

token

string

required

From the link.

Request

curl -X POST -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/courier-invite/k8Jf2…/accept

Response

{
  "success": true,
  "data": {
    "group": {
      "id": 12,
      "businessID": 42,
      "name": "Couriers",
      "created": "2026-09-14T10:02:11Z",
      "members": [
        {
          "id": 77,
          "name": "Sam Patel",
          "email": "sam@example.com"
        }
      ]
    },
    "courier": {
      "id": 77,
      "name": "Sam Patel",
      "email": "sam@example.com"
    }
  }
}

Errors

400

The courier is no longer approved.

404

"invite link not found".

Reference

Recipient notifications

With recipient notifications enabled, each parcel's recipient is emailed and texted when you book (with the live tracking link) and again when their parcel is dropped off (after the first proof photo). The copy is templated per business.

GET
/private/settings/notifications
JWT · business account
needs canEditNotificationTemplates

Read your recipient message templates

Your six templates (blank = the default copy), the defaults themselves, and the placeholder list with meanings.

When to use it

Build a template editor, or just read the defaults to know what your recipients receive.

Response fields

data

object

Your overrides; "" means the default is used.

defaults

object

The built-in copy for each key.

placeholders[]

{token, meaning, deliveredOnly}

{name} {business} {window} {location} {link}, plus {time} for the delivered pair.

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/settings/notifications

Response

{
  "success": true,
  "data": {
    "smsTemplate": "",
    "emailSubject": "",
    "emailTemplate": "",
    "deliveredSms": "",
    "deliveredEmailSubject": "",
    "deliveredEmailBody": ""
  },
  "defaults": {
    "smsTemplate": "Hi {name}, your delivery from {business} will be arriving {window} to {location} through Owleh. Track your delivery live: {link}. Owleh Same Day Delivery",
    "emailSubject": "Your delivery from {business} is on its way — track it live",
    "emailTemplate": "Hi {name}, your delivery from {business} will be arriving {window} to {location} through Owleh. Track your delivery live: {link}. Owleh Same Day Delivery",
    "deliveredSms": "Hi {name}, your delivery from {business} has been delivered to {location} at {time}. See it here: {link}. Owleh Same Day Delivery",
    "deliveredEmailSubject": "Your delivery from {business} has been delivered",
    "deliveredEmailBody": "Hi {name}, your delivery from {business} has been delivered to {location} at {time}. See it here: {link}. Owleh Same Day Delivery"
  },
  "placeholders": [
    {
      "token": "{name}",
      "meaning": "recipient's first name (or \"there\")",
      "deliveredOnly": false
    },
    {
      "token": "{time}",
      "meaning": "time the parcel was delivered, e.g. \"14:32\" (after-delivery messages)",
      "deliveredOnly": true
    }
  ]
}

Errors

403

"notification settings are not enabled for this account".

PUT
/private/settings/notifications
JWT · business account
needs canEditNotificationTemplates
replaces all six

Save your templates

Validates and stores all six templates at once. A key you omit or leave blank reverts to the default. Returns the same payload as GET.

When to use it

Brand the messages your recipients get. Sending at all is governed by a separate flag, canNotifyRecipients, so saving templates on an account without it changes nothing visible.

Request body (JSON)

smsTemplate

string ≤ 500

Booking SMS. Must keep {link}. No {time}.

emailSubject

string ≤ 150

Booking email subject, single line. No {time}.

emailTemplate

string ≤ 2000

Booking email body. No {time}.

deliveredSms

string ≤ 500

After-delivery SMS. {time} allowed, {link} optional.

deliveredEmailSubject

string ≤ 150

After-delivery subject, single line.

deliveredEmailBody

string ≤ 2000

After-delivery body.

Request

curl -X PUT -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  https://owleh.com/api/private/settings/notifications \
  -d '{"smsTemplate":"Hi {name}, Acme is bringing your order {window}. Track it: {link}","emailSubject":"","emailTemplate":"","deliveredSms":"","deliveredEmailSubject":"","deliveredEmailBody":""}'

Response

{
  "success": true,
  "data": {
    "smsTemplate": "Hi {name}, Acme is bringing your order {window}. Track it: {link}",
    "emailSubject": "",
    "emailTemplate": "",
    "deliveredSms": "",
    "deliveredEmailSubject": "",
    "deliveredEmailBody": ""
  },
  "defaults": {
    "smsTemplate": "Hi {name}, your delivery from {business} will be arriving {window} to {location} through Owleh. Track your delivery live: {link}. Owleh Same Day Delivery",
    "emailSubject": "Your delivery from {business} is on its way — track it live",
    "emailTemplate": "Hi {name}, your delivery from {business} will be arriving {window} to {location} through Owleh. Track your delivery live: {link}. Owleh Same Day Delivery",
    "deliveredSms": "Hi {name}, your delivery from {business} has been delivered to {location} at {time}. See it here: {link}. Owleh Same Day Delivery",
    "deliveredEmailSubject": "Your delivery from {business} has been delivered",
    "deliveredEmailBody": "Hi {name}, your delivery from {business} has been delivered to {location} at {time}. See it here: {link}. Owleh Same Day Delivery"
  },
  "placeholders": []
}

Errors

400

A validation message such as "SMS template must include {link} so recipients can track the delivery", "…is too long (612 characters, max 500)", "email subject must be a single line", "…uses an unknown placeholder {foo}".

403

"notification settings are not enabled for this account".

POST
/private/settings/notifications/test
JWT · business account
needs canEditNotificationTemplates

Send yourself a test message

Renders the saved templates with sample data and sends them to the email and phone on your own profile, reporting what each provider said.

When to use it

After saving templates, to see the real SMS and email.

Request body (JSON)

kind

string

"delivered" to test the after-delivery pair; anything else tests the booking pair.

Request

curl -X POST -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  https://owleh.com/api/private/settings/notifications/test -d '{"kind":"delivered"}'

Response

{
  "success": true,
  "data": {
    "sms": "accepted by Twilio for +447700900123",
    "email": "accepted by Resend",
    "smsText": "[Owleh test] Hi Sam, your delivery from Acme Pharmacy has been delivered…",
    "emailSubject": "[Test] Your delivery from Acme Pharmacy has been delivered",
    "emailBody": "Hi Sam, …"
  }
}

Notes

sms or email is null when that contact detail is missing from your profile; otherwise it is the provider's acceptance or error text.

Errors

400

"add an email or mobile number to your profile first".

Reference

Managing API tokens

Tokens can also be managed from the API tokens card on your profile page. Either way you need API access enabled on the account by Owleh — ask us via the contact form.

GET
/private/api-tokens
JWT · business account
needs canUseApiTokens

List your API tokens

Name, display prefix, creation and last-use time of each token. Never the token itself.

Request

curl -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/api-tokens

Response

{
  "success": true,
  "data": [
    {
      "id": 3,
      "name": "ERP sync",
      "prefix": "owl_a1b2c3d4",
      "createdAt": "2026-10-01T09:00:00Z",
      "lastUsedAt": "2026-10-23T08:15:00Z"
    }
  ],
  "limit": 10
}

Errors

403

"API access is not enabled for this account".

POST
/private/api-tokens
JWT · business account
needs canUseApiTokens
shown once

Mint a token

Creates a named token and returns its clear text this one time. Up to 10 per account; they do not expire. DELETE /private/api-tokens/:id revokes one and signs out every session it minted.

When to use it

One token per integration, named after it, so you can revoke one without touching the others.

Request body (JSON)

name

string

required

1–60 characters.

Request

curl -X POST -H "Authorization: Bearer <jwt>" -H "Content-Type: application/json" \
  https://owleh.com/api/private/api-tokens -d '{"name":"ERP sync"}'
curl -X DELETE -H "Authorization: Bearer <jwt>" https://owleh.com/api/private/api-tokens/3

Response

{
  "success": true,
  "data": {
    "id": 3,
    "name": "ERP sync",
    "prefix": "owl_a1b2c3d4",
    "createdAt": "2026-10-01T09:00:00Z",
    "lastUsedAt": null
  },
  "token": "owl_a1b2c3d4e5f6…"
}

Errors

400

"give the token a name (up to 60 characters)" or "you already have the maximum of 10 tokens — delete one first".

404

DELETE: not your token.

Ready to integrate?

Create an account, ask us to enable API tokens, and send your first booking from your own system.