For developers
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
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
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": "…"}).
| Credential | Where it works | How you get it | Lifetime |
|---|---|---|---|
| JWT | Every /private endpoint | POST /user/login/jwt, or POST /v1/auth/token | 30 days; refreshable |
| Refresh token | GET /user/refresh only | Returned next to every JWT | Sliding 90 days; revoked by POST /private/logout |
| API token (owl_…) | GET /v1/quote, POST /v1/auth/token | Profile 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/refreshReading the responses
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.
| Status | Body | Meaning |
|---|---|---|
| 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
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.
| deliveryState | isActive | Who moves it on | What it means |
|---|---|---|---|
| waiting | true | A courier accepts, or an admin assigns | On the board. The only state you can cancel from. |
| accepted | true | The courier | A courier has the job; driverID is set and live location starts. |
| arrived | true | The courier | At the pickup address. |
| picked | true | The courier | Parcels collected, en route. Recipients see live tracking. |
| delivered | true | You (approve) within the web app or via increment | Every parcel has a proof photo. Recipients have been told. |
| approvedByBusiness | false | An admin (complete), or you (unapprove ≤ 2 h) | You have signed the job off. |
| complete | false | — | Payment captured or collected. Terminal. |
| canceled | false | — | Cancelled by you while waiting, or by an admin. Terminal. |
Money
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.
| Step | Standard example | Where you see it |
|---|---|---|
| Route 5 km in London + 1 parcel | 5 × £1.40 + £3 = £10.00 | — |
| +10% commission, truncated to 1 dp | £11.00 | quote data / API price |
| Account discount 10%, then floor £8 | £9.90 | discountedPrice |
| + insurance (£150 declared) | + £2.00 | computed client-side from the parcels |
| − promo code | e.g. − £1.19 | promoDiscountAmount on the delivery |
| − platform discount | e.g. 0% | globalDiscountAmount on the delivery |
Reference
No credentials needed. Quote a route, follow a shared tracking link, check the API is up.
/quotePrices 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
pickupstring required | Full UK postcode of the pickup. |
dropoffstring required | Full UK postcode of the drop-off. |
parcelsinteger | 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. |
/track/:tokenThe 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
tokenstring required | The share token from POST /private/deliveries/tracking-link/:deliveryID (also the one in recipient notifications). |
Response fields
delivery.statestring | waiting · accepted · arrived · picked · delivered · approvedByBusiness · complete · canceled. |
delivery.courierNamestring | First name only; empty until a courier accepts. |
delivery.courierAvatarUrlstring | Public photo path (prefix with the base URL); empty if none. |
delivery.distanceMetersinteger | Planned route length. |
locationobject | 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. |
/invite/:tokenWho 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
tokenstring 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". |
/contactThe 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)
namestring | |
emailstring | Either email or phone is required. |
phonestring | |
messagestring |
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". |
/aliveReturns 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
trueReference
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.
/v1/quotePrices 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
pickupstring required | Full UK postcode. |
dropoffstring required | Full UK postcode. |
parcelsinteger | 1–50, default 1. Each parcel adds the per-parcel fee. |
servicestring | standard (default) or urgent (×1.25). Case-insensitive. |
pickupAtinteger | Unix seconds of the planned pickup. Only chooses the weekday or weekend discount set (London day). Default: now. |
Response fields
pricenumber | Full rate after your floor — no discount. |
discountedPricenumber | With your account discount taken off, then floored. |
discountPercentnumber | The effective account percent for this route (weighted by drop-off zone). |
platformDiscountPercentnumber | A platform-wide percent an Owleh admin may have set. |
totalnumber | discountedPrice less the platform percent: what a plain public booking with no insurance or promo is charged. |
distanceKmnumber | 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". |
/v1/auth/tokenTurns 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
tokenstring | Access JWT, 30 days. |
refreshTokenstring | Opaque refresh token for GET /user/refresh (sliding 90 days). |
expiresAtinteger | 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
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.
/private/deliveries/quotePrices 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.postcodestring required | Pickup postcode. |
parcels[].dropOffLocation.postcodestring required | One per parcel; the route is optimised across them. |
serviceTypestring | standard or urgent. |
pickUpTimeBeforeinteger | Picks the weekday or weekend discount set. 0 = today. |
Response fields
datanumber | Full price after your floor. |
discountedPricenumber | With your account percent, then floored. What you are charged before insurance/promo/platform discount. |
groupPrice / groupDiscountedPricenumber | The same pair with the courier-group percent folded in — use when sending to a group. |
offPlatformPrice / offPlatformDiscountedPricenumber | Suggested courier price when paying the courier directly (commission-free, own floors). |
userDiscountPercentnumber | Effective account percent for this route. |
groupDiscountPercent / globalDiscountPercentnumber | The two platform-set percents. The global one is applied last at charge time and is not included in the figures above. |
minPrice / minPriceUrgentnumber | 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). |
/private/deliveriesBooks 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)
serviceTypestring required | "standard" or "urgent". Anything else is priced as standard. |
pickUpTimeBeforeinteger required | Unix seconds: collect by. Also picks the weekday/weekend discount set. |
deliverTimeBeforeinteger required | Unix seconds: deliver by. Must not be before pickUpTimeBefore. |
pickUpTimeAfterinteger | Unix seconds: earliest collection (0 = not set). Only needed for a custom window. |
deliverTimeAfterinteger | 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. |
pickUpInstructionsstring | Shown to the courier at pickup. |
pickUpContact{name, phone, email} | Who the courier calls at pickup. |
parcelsParcel[] required | At least one. See the parcel fields below. |
courierGroupIDinteger | 0 or absent = every courier. A group id sends the job only to that group (needs canCreateCourierGroups and the group must be yours). |
offPlatformPaymentboolean | Pay the courier directly; Owleh charges nothing. Needs canPayCourierDirectly and a courierGroupID. |
pricenumber | Off-platform only: the courier price you set (needs canSetOffPlatformPrice; ≤ £10,000). 0 = use the suggestion. Ignored on-platform. |
paymentMethodstring | "stripe", "paypal" or "gocardless". Empty = your account default. Each must be set up first (see Payments). |
paypalOrderIDstring | Required when paying with PayPal: the id from POST /private/deliveries/paypal/order after buyer approval. |
promoCodestring | 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 / depthinteger | Centimetres. Shown to the courier; not validated. |
parcels[].weightnumber | Kilograms. The web app offers Small ≤1, Medium ≤5, Large ≤10. |
parcels[].insuranceDeclaredValuenumber | 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). |
/private/deliveries/paypal/orderPrices 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. |
/private/deliveries/business/:isActiveYour 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
isActiveboolean required | true for in-flight deliveries, false for finished ones. Accepts true/false/1/0. |
Response fields
data[].deliveryStatestring | See the lifecycle table. |
data[].driverIDinteger | 0 until a courier accepts. Resolve the courier with GET /private/driver/:deliveryID. |
data[].pricenumber | Full price. Your charge = price − userDiscountAmount + insurance − promoDiscountAmount − globalDiscountAmount. |
data[].courierPricenumber | What the courier earns (90% of the full price; the whole amount off-platform). |
data[].distanceinteger | Metres. |
data[].parcels[].stagestring | waiting → onDelivery (first proof photo) → delivered. |
data[].parcels[].numberOfImagesinteger | Proof photos uploaded so far; fetch them with GET /private/parcels/images/:parcelID. |
data[].createdstring | 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. |
/private/deliveries/statsAll-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.
/private/deliveries/cancel/:deliveryIDCancels 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
deliveryIDinteger 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. |
/private/deliveries/increment/:deliveryIDMoves 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
deliveryIDinteger 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. |
/private/deliveries/unapprove/:deliveryIDPuts 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
deliveryIDinteger 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". |
/private/deliveries/tracking-link/:deliveryIDReturns 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
deliveryIDinteger required | One of your deliveries. |
Query parameters
regenerate1 | 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. |
/private/location/:deliveryIDThe 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
deliveryIDinteger 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.
/private/parcels/images/:parcelIDLists 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
parcelIDinteger 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. |
/private/suggestions/:postcodeValidates 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
postcodestring 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"}.
/private/recipientsOverwrites 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)
recipientsRecipient[] required | {fullName, email, phoneNumber, dropOffInstruction} per parcel. |
parcel_idsinteger[] 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". |
/private/recipients/savedUp 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"
}
]
}/private/promoChecks 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
codestring required | The code; case-insensitive. |
Response fields
discountPercentnumber | null | If set and > 0, a percent of (discounted price + insurance). |
discountAmountnumber | 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
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.
| Flag | What it unlocks |
|---|---|
| canUseApiTokens | Mint API tokens; GET /v1/quote and POST /v1/auth/token. Revoking it disables every token at once. |
| canCreateCourierGroups | Courier groups, the payouts ledger, and courierGroupID on a delivery. |
| canPayCourierDirectly | Off-platform bookings (offPlatformPayment): you pay the courier, Owleh charges nothing. |
| canSetOffPlatformPrice | Send your own price on off-platform bookings instead of the suggestion. |
| canNotifyRecipients | Recipients get the booking and delivered messages with a tracking link. |
| canEditNotificationTemplates | Rewrite those messages via /private/settings/notifications. |
| mustUseCourierGroups | A restriction: public bookings (courierGroupID 0) are refused. |
| canSeeListPrice | Display-only in the web app (list price + VAT). No API effect. |
/user/registerRegisters 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)
emailstring required | Lowercased. Must not already be registered. |
passwordstring 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. |
/user/login/jwtExchanges 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)
emailstring required | Case-insensitive. |
passwordstring 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. |
/user/refreshPresents 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. |
/private/logoutRevokes 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)
refreshTokenstring | 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
}/user/reset/tokenEmails 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)
emailstring 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. |
/user/reset/passwordSets 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)
tokenstring required | From the reset email. |
new_passwordstring 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". |
/private/userThe 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
registrationStatusstring | approved for businesses; disabled after DELETE /private/user. |
profilePicturestring | Object filename only; the public image is /avatars/{id}/{profilePicture}. |
numberOfDeliveriesinteger | Server-owned counter. |
createdstring | 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.
/private/userWrites 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)
idinteger required | Must be your own user id, otherwise 401. |
name, surname, businessName, phoneNumber, address, postcodestring | Written as given. A 10-digit phone number without a leading 0 gets one added. |
sortCode, bankName, accountNumber, accountHolderNamestring | 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". |
/private/userMarks 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
}/private/user/avatarMultipart 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)
photofile (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. |
/private/user/permissionsThe 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
}
}/private/driver/:deliveryIDName, 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
deliveryIDinteger 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
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).
| paymentMethod | Needs | Collected | Cancel while waiting |
|---|---|---|---|
| gocardless | An active mandate (POST …/gocardless/mandate, then poll GET) | At completion | Nothing to release |
| stripe | A saved card (POST …/setup → Stripe.js → POST …/card) | Held at booking, captured at completion | Hold voided |
| paypal | paypalOrderID from POST /private/deliveries/paypal/order, approved by the buyer | Immediately | Refunded |
/private/payment/providersOne 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
}/private/payment/gocardless/mandatePOST 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". |
/private/payment/gocardlessGET 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". |
/private/payment/setupCreates 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. |
/private/payment/cardAttaches 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_idstring 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/cardResponse
{
"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". |
/private/payment/paypalGET 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
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.
/private/courier-groupsYour 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"}. |
/private/courier-groupsPOST 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)
namestring 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/12Response
{
"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". |
/private/courier-groups/:id/membersAdds 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
idinteger required | A group you own. |
Request body (JSON)
emailstring 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/77Response
{
"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. |
/private/courier-payoutsFor 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
fromYYYY-MM-DD | Inclusive start, London calendar day of the booking. |
toYYYY-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". |
/private/courier-payouts/paid/:driverIDFlags 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
driverIDinteger 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
}/private/courier-invite/:token/acceptIn 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
tokenstring 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
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.
/private/settings/notificationsYour 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
dataobject | Your overrides; "" means the default is used. |
defaultsobject | 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". |
/private/settings/notificationsValidates 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)
smsTemplatestring ≤ 500 | Booking SMS. Must keep {link}. No {time}. |
emailSubjectstring ≤ 150 | Booking email subject, single line. No {time}. |
emailTemplatestring ≤ 2000 | Booking email body. No {time}. |
deliveredSmsstring ≤ 500 | After-delivery SMS. {time} allowed, {link} optional. |
deliveredEmailSubjectstring ≤ 150 | After-delivery subject, single line. |
deliveredEmailBodystring ≤ 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". |
/private/settings/notifications/testRenders 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)
kindstring | "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
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.
/private/api-tokensName, 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". |
/private/api-tokensCreates 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)
namestring 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/3Response
{
"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. |
Create an account, ask us to enable API tokens, and send your first booking from your own system.