Developers
Trust Your Move API Documentation
The Trust Your Move API lets your website or other software read live storage availability, units, bookings and invoices, read removals leads and jobs, create storage holds and enquiries, and receive signed webhooks when things change. It is a plain REST API over HTTPS with JSON bodies, versioned at /api/v1.
Version 1 · Last updated 20 October 2026 · Base URL https://trustyourmove.co.uk/api/v1
Overview
Every Trust Your Move firm on the Pro or Enterprise plan can switch on the partner API under Settings → API keys and create keys with the scopes an integration needs. Requests are always scoped to the firm that owns the key: there is no way to read or write another firm’s data, and responses never include client names or email addresses unless the key carries the bookings:read:pii scope.
- Read-only by default. The default scope is
inventory:read. Grantbookings:writeonly to integrations that need to create bookings. The removals endpoints (leads and jobs) are read-only: nothing can create or change a lead or job through the API. - Live data. Availability comes from the same unit state machine the dashboard uses, so what you show on a partner site matches what staff see.
- Money is integer pence (
9500= £95.00) and dates are ISO-8601 in UTC. The firm’s own timezone is reported infirm.timezone. - Removals data.
leads:readandjobs:readreturn enquiries and jobs for firms that have the Leads or Removals module switched on. Jobs carry postcodes, dates and volume but no street addresses, notes, crew or money. - Webhooks push booking, invoice, unit, access-code, appointment and removals (
lead.created,job.stage_changed,job.completed) events to your server, signed with HMAC-SHA256.
A quick first call once you have a key:
curl https://trustyourmove.co.uk/api/v1/inventory \
-H "Authorization: Bearer sf_live_YOUR_KEY"Authentication
Send your key as a bearer token in the Authorization header on every request. Keys look like sf_live_ followed by 43 URL-safe characters. Trust Your Move stores only a SHA-256 hash of the key, so it is shown exactly once when created — if you lose it, revoke it and create another.
Authorization: Bearer sf_live_78NqGf_OEs6LbpsyaQ8jHg9vy8EwZgS…Scopes
| Scope | Grants |
|---|---|
inventory:read | Storage types, rates, availability, warehouses and units (no client data). |
bookings:read | Booking references, status, dates and storage type — no client names or emails. |
bookings:read:pii | Adds client name and email to booking responses. Grant only when needed. |
bookings:write | Create holds and enquiries through POST /api/v1/bookings. |
invoices:read | Invoice numbers, status, totals and due dates. |
leads:read | Removals enquiries with the contact details the enquirer gave (name, email, phone), move postcodes and date, source and status. Never the raw provider payload, IP address or notes. |
jobs:read | Removals jobs: reference, stage, type, move postcodes and dates, volume and the client id. No street addresses, notes, crew or money. |
Authentication responses
401 unauthorised— no key, a malformed key, an unknown key or a revoked key. The message does not say which.403 forbidden— the key is valid but the firm has switched the API off, is on a plan without API access, is suspended, or the key lacks the scope the endpoint needs (code: "missing_scope").
Keep keys on your server. Never embed one in a web page, mobile app or public repository — use the public availability endpoint for anything that runs in a browser.
Rate Limits
Each key has its own limit, set when the key is created (default 60 requests per minute, configurable between 10 and 6000). The window is a fixed calendar minute. Every response carries the current state:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per minute for this key |
X-RateLimit-Remaining | Requests left in the current minute |
X-RateLimit-Reset | Unix timestamp (seconds) when the window resets |
Retry-After | On a 429 only: seconds to wait before retrying |
When you exceed the limit you get 429 rate_limited. Back off until Retry-After has passed; do not retry in a tight loop. The unauthenticated public availability endpoint is limited to 60 requests per minute per IP address instead.
Conventions & Pagination
- JSON everywhere. Send
Content-Type: application/jsonon POST requests. - Field names are
snake_case. Enumerations (statuses, billing models, VAT treatments) are upper-case strings exactly as shown in the dashboard. - All responses include
Cache-Control: no-storeandX-API-Version: v1; CORS is allowed for GET requests so a server-rendered partner page can call the API, but keys must still stay server-side. - Send
OPTIONSto any endpoint for a CORS preflight.
Pagination
List endpoints accept page (from 1) and per (10, 25, 50, 100; default 25) and wrap results in a data array with a pagination object:
{
"data": [
"…"
],
"pagination": {
"page": 2,
"per": 25,
"total": 131,
"pages": 6
}
}Results are ordered newest first (bookings, invoices, leads, jobs) or by code (units). Pages are stable within a minute of normal activity; iterate until page === pages.
Errors
Errors always have the same envelope. Validation failures (422) include an issues array with the field path of each problem.
{
"error": {
"message": "Validation failed",
"code": "invalid",
"issues": [
{
"path": "client.email",
"message": "Invalid email address"
}
]
}
}| Status | Code | When |
|---|---|---|
| 400 | bad_request | Body is not valid JSON |
| 401 | unauthorised | Missing, malformed, unknown or revoked key |
| 403 | forbidden / missing_scope | API off, plan without API, firm suspended, or scope not granted |
| 404 | not_found | Storage type, warehouse, booking, lead, job or firm not found (within your firm). Leads and jobs also return this when that module is switched off for the firm |
| 409 | no_availability | A hold was requested but no unit of that type is free |
| 422 | invalid / insurance_required | Validation failed (or an invalid filter on a list); see the message |
| 429 | rate_limited | Over the key’s per-minute limit |
| 500 | server_error | Something failed on our side — safe to retry with the same body |
Endpoints
All paths below are relative to https://trustyourmove.co.uk/api/v1.
GET/inventory
Scope required: inventory:read
The firm’s public profile, every active storage type with rates, billing model and live availability, and the list of warehouses. This is the one call most partner sites need.
curl https://trustyourmove.co.uk/api/v1/inventory -H "Authorization: Bearer sf_live_…"{
"firm": {
"slug": "harbourside-storage",
"name": "Harbourside Storage",
"tagline": "Secure container storage by the quay",
"phone": "01752 000111",
"email": "hello@harbourside.example",
"website": "https://harbourside.example",
"address": {
"line1": "12 Quay Road",
"line2": null,
"town": "Plymouth",
"postcode": "PL4 0AA",
"country": "GB"
},
"booking_url": "https://trustyourmove.co.uk/harbourside-storage",
"currency": "GBP",
"timezone": "Europe/London",
"hold_minutes": 15,
"insurance_disclosure": {
"text": "Goods are stored at the owner's risk unless separate insurance is arranged…",
"version": 1,
"terms_url": null
}
},
"storage_types": [
{
"id": "st_01J…",
"slug": "35-sq-ft-container",
"name": "35 sq ft container",
"description": "Fits the contents of a one-bedroom flat.",
"size_label": "35 sq ft",
"dimensions": {
"width_m": 2.4,
"length_m": 1.5,
"height_m": 2.2,
"volume_m3": 7.9
},
"rates": {
"daily_pence": 400,
"weekly_pence": 2500,
"monthly_pence": 9500
},
"billing": {
"model": "RECURRING",
"payment_mode": "SUBSCRIPTION",
"deposit_percent": 0,
"interval": "MONTH",
"interval_count": 1,
"interval_label": "monthly",
"collection_method": "STRIPE_SUBSCRIPTION",
"vat_treatment": "STANDARD",
"min_term_days": 0
},
"image_url": null,
"is_active": true,
"availability": {
"available": 3,
"total": 12
},
"updated_at": "2026-10-01T08:00:00.000Z"
}
],
"warehouses": [
{
"id": "wh_01J…",
"code": "QUAY",
"name": "Quay Road",
"town": "Plymouth",
"postcode": "PL4 0AA",
"country": "GB",
"is_active": true
}
]
}insurance_disclosure.text is the wording a client must see before paying; show it and send insurance_acknowledged: true when you create a hold.
GET/inventory/storage-types/{slug}
Scope required: inventory:read
One storage type by its slug (as returned in /inventory), with availability. Returns 404 for inactive or unknown slugs.
curl https://trustyourmove.co.uk/api/v1/inventory/storage-types/35-sq-ft-container -H "Authorization: Bearer sf_live_…"{
"storage_type": {
"id": "st_01J…",
"slug": "35-sq-ft-container",
"name": "35 sq ft container",
"description": "Fits the contents of a one-bedroom flat.",
"size_label": "35 sq ft",
"dimensions": {
"width_m": 2.4,
"length_m": 1.5,
"height_m": 2.2,
"volume_m3": 7.9
},
"rates": {
"daily_pence": 400,
"weekly_pence": 2500,
"monthly_pence": 9500
},
"billing": {
"model": "RECURRING",
"payment_mode": "SUBSCRIPTION",
"deposit_percent": 0,
"interval": "MONTH",
"interval_count": 1,
"interval_label": "monthly",
"collection_method": "STRIPE_SUBSCRIPTION",
"vat_treatment": "STANDARD",
"min_term_days": 0
},
"image_url": null,
"is_active": true,
"availability": {
"available": 3,
"total": 12
},
"updated_at": "2026-10-01T08:00:00.000Z"
}
}GET/inventory/availability
Scope required: inventory:read
Counts only — lighter than /inventory for polling.
| Parameter | Type | Description |
|---|---|---|
storage_type | string | Optional slug to restrict to one type |
from | date | Optional ISO date. Adds freeing_by_date: active bookings of that type ending on or before the date. |
curl "https://trustyourmove.co.uk/api/v1/inventory/availability?storage_type=locker&from=2026-12-01" -H "Authorization: Bearer sf_live_…"{
"as_of": "2026-10-18T09:30:00.000Z",
"from": "2026-12-01T00:00:00.000Z",
"storage_types": [
{
"id": "st_01J…",
"slug": "locker",
"name": "Locker",
"available": 5,
"total": 20,
"by_status": {
"AVAILABLE": 5,
"OCCUPIED": 14,
"UNDER_MAINTENANCE": 1
},
"freeing_by_date": 2,
"rates": {
"daily_pence": 200,
"weekly_pence": 1000,
"monthly_pence": 3000
}
}
]
}GET/inventory/units
Scope required: inventory:read
Paginated units with code, status, warehouse and storage type. Units never carry client data, even with the PII scope.
| Parameter | Type | Description |
|---|---|---|
status | enum | AVAILABLE, RESERVED, OCCUPIED, UNDER_MAINTENANCE, FLAGGED_FOR_DISPOSAL or WRITTEN_OFF |
warehouse | string | Warehouse id or code |
storage_type | string | Storage type slug |
q | string | Unit code contains |
page, per | int | Pagination |
curl "https://trustyourmove.co.uk/api/v1/inventory/units?status=AVAILABLE&warehouse=QUAY&per=50" -H "Authorization: Bearer sf_live_…"{
"data": [
{
"id": "un_01J…",
"code": "A-01",
"status": "AVAILABLE",
"warehouse": {
"id": "wh_01J…",
"code": "QUAY",
"name": "Quay Road"
},
"storage_type": {
"id": "st_01J…",
"slug": "35-sq-ft-container",
"name": "35 sq ft container"
},
"last_status_change_at": "2026-10-12T10:15:00.000Z",
"created_at": "2026-01-04T09:00:00.000Z"
}
],
"pagination": {
"page": 1,
"per": 25,
"total": 48,
"pages": 2
}
}GET/bookings
Scope required: bookings:read
Paginated bookings, newest first. The client object contains only the id unless the key also has bookings:read:pii, in which case name and email are included.
| Parameter | Type | Description |
|---|---|---|
status | enum | DRAFT, PENDING_PAYMENT, PENDING_REVIEW, CONFIRMED, ACTIVE, PAYMENT_OVERDUE, COMPLETED, CANCELLED, EXPIRED |
storage_type | string | Storage type slug |
reference | string | Reference contains (e.g. HS-0001) |
client_id | string | Only this client |
from, to | date | Start date range |
page, per | int | Pagination |
curl "https://trustyourmove.co.uk/api/v1/bookings?status=ACTIVE&from=2026-01-01" -H "Authorization: Bearer sf_live_…"{
"data": [
{
"id": "bk_01J…",
"reference": "HS-000123",
"status": "PENDING_PAYMENT",
"source": "API",
"storage_type": {
"id": "st_01J…",
"slug": "35-sq-ft-container",
"name": "35 sq ft container"
},
"unit": {
"id": "un_01J…",
"code": "A-07"
},
"client": {
"id": "cl_01J…"
},
"start_date": "2026-11-01T00:00:00.000Z",
"end_date": null,
"term_days": null,
"rate_pence": 9500,
"rate_basis": "MONTH",
"billing": {
"model": "RECURRING",
"payment_mode": "SUBSCRIPTION",
"deposit_percent": 0,
"interval": "MONTH",
"interval_count": 1,
"collection_method": "STRIPE_SUBSCRIPTION",
"vat_treatment": "STANDARD",
"status": "NONE",
"next_billing_at": null
},
"insurance_acknowledged_at": "2026-10-18T09:30:00.000Z",
"confirmed_at": null,
"cancelled_at": null,
"completed_at": null,
"created_at": "2026-10-18T09:30:00.000Z",
"updated_at": "2026-10-18T09:30:00.000Z"
}
],
"pagination": {
"page": 1,
"per": 25,
"total": 1,
"pages": 1
}
}GET/bookings/{id}
Scope required: bookings:read
One booking by id or by reference (case-insensitive, e.g. HS-000123). Same shape and PII rule as the list.
curl https://trustyourmove.co.uk/api/v1/bookings/HS-000123 -H "Authorization: Bearer sf_live_…"POST/bookings
Scope required: bookings:write
Create a booking on behalf of a client. Two modes:
"inquiry"(default) — creates aPENDING_REVIEWbooking with no unit. The firm reviews it and contacts the client."hold"— reserves an available unit of that type for the firm’s hold window (firm.hold_minutes) and returnsPENDING_PAYMENT. The client pays on the firm’s booking page (or staff confirm it) beforehold_expires_at; otherwise the unit is released automatically. Requiresinsurance_acknowledged: true.
The client is matched by email within the firm; if none exists one is created (existing records are never overwritten). Bookings created this way are marked source: "API" and trigger booking.created webhooks.
| Parameter | Type | Description |
|---|---|---|
storage_type | string | Required. Storage type slug |
mode | enum | hold or inquiry (default) |
client.name | string | Required |
client.email | string | Required |
client.phone, company_name, is_business, vat_number, address_line1, address_line2, town, postcode, country, marketing_consent | various | Optional; used only when a new client record is created |
start_date | date | Required. ISO date, not in the past |
term_days | int | Optional fixed term (1–3650). Omit for open-ended recurring storage |
warehouse_id | string | Optional: hold a unit in this warehouse only |
insurance_acknowledged | boolean | Required true for holds; records consent to the firm’s insurance disclosure |
notes | string | Optional, shown to staff |
external_reference | string | Optional: your own id, stored in the notes and echoed back |
curl -X POST https://trustyourmove.co.uk/api/v1/bookings \
-H "Authorization: Bearer sf_live_…" \
-H "Content-Type: application/json" \
-d '{"storage_type":"35-sq-ft-container","mode":"hold","client":{"name":"Priya Nair","email":"priya@example.com","phone":"07700 900123","postcode":"PL1 2AB"},"start_date":"2026-11-01","insurance_acknowledged":true,"notes":"Moving from a one-bed flat; access needed on Saturdays.","external_reference":"CRM-48213"}'{
"booking": {
"id": "bk_01J…",
"reference": "HS-000123",
"status": "PENDING_PAYMENT",
"source": "API",
"storage_type": {
"id": "st_01J…",
"slug": "35-sq-ft-container",
"name": "35 sq ft container"
},
"unit": {
"id": "un_01J…",
"code": "A-07"
},
"client": {
"id": "cl_01J…",
"name": "Priya Nair",
"email": "priya@example.com"
},
"start_date": "2026-11-01T00:00:00.000Z",
"end_date": null,
"term_days": null,
"rate_pence": 9500,
"rate_basis": "MONTH",
"billing": {
"model": "RECURRING",
"payment_mode": "SUBSCRIPTION",
"deposit_percent": 0,
"interval": "MONTH",
"interval_count": 1,
"collection_method": "STRIPE_SUBSCRIPTION",
"vat_treatment": "STANDARD",
"status": "NONE",
"next_billing_at": null
},
"insurance_acknowledged_at": "2026-10-18T09:30:00.000Z",
"confirmed_at": null,
"cancelled_at": null,
"completed_at": null,
"created_at": "2026-10-18T09:30:00.000Z",
"updated_at": "2026-10-18T09:30:00.000Z"
},
"client_created": true,
"external_reference": "CRM-48213",
"hold_expires_at": "2026-10-18T09:45:00.000Z",
"quote": {
"rate_pence": 9500,
"rate_basis": "MONTH",
"term_days": null,
"due_now_net_pence": 9500,
"schedule": "Then £95.00 + VAT every month",
"creates_subscription": true,
"first_recurring_at": "2026-12-01T00:00:00.000Z"
},
"next_step": "Send the client to the firm’s booking page to pay before hold_expires_at, or staff can confirm it in the dashboard."
}Possible errors: 409 no_availability (hold with nothing free — fall back to an enquiry), 422 insurance_required, 404 not_found for an unknown storage type or warehouse.
GET/invoices
Scope required: invoices:read
Paginated invoice headers with totals and balances. Client and booking are returned as ids only — no names or addresses.
| Parameter | Type | Description |
|---|---|---|
status | enum | DRAFT, ISSUED, PAID, PARTIALLY_PAID, OVERDUE, VOID, REFUNDED |
kind | enum | BOOKING, DEPOSIT, RECURRING, ADJUSTMENT, CREDIT_NOTE, FINAL |
booking_id, client_id | string | Filter by booking or client |
number | string | Invoice number contains |
from, to | date | Issue date range |
page, per | int | Pagination |
curl "https://trustyourmove.co.uk/api/v1/invoices?status=OVERDUE" -H "Authorization: Bearer sf_live_…"{
"data": [
{
"id": "inv_01J…",
"number": "INV-000412",
"kind": "RECURRING",
"status": "PAID",
"booking_id": "bk_01J…",
"client_id": "cl_01J…",
"issue_date": "2026-10-01T00:00:00.000Z",
"due_date": "2026-10-08T00:00:00.000Z",
"period_start": "2026-10-01T00:00:00.000Z",
"period_end": "2026-10-31T00:00:00.000Z",
"currency": "GBP",
"subtotal_pence": 9500,
"vat_pence": 1900,
"total_pence": 11400,
"paid_pence": 11400,
"outstanding_pence": 0,
"vat_treatment": "STANDARD",
"paid_at": "2026-10-01T06:02:11.000Z",
"voided_at": null,
"created_at": "2026-10-01T06:00:00.000Z"
}
],
"pagination": {
"page": 1,
"per": 25,
"total": 1,
"pages": 1
}
}GET/leads
Scope required: leads:read
Paginated removals enquiries, newest first, with the contact details the enquirer gave, move postcodes and date, source and status. Needs the Leads module to be on for the firm. Repeat enquiries flagged as duplicates are left out unless you ask for them. Never returned: notes, street addresses, the provider’s raw payload, IP address.
| Parameter | Type | Description |
|---|---|---|
status | enum | NEW, CONTACTED, SURVEY_BOOKED, QUOTED, WON, LOST, SPAM |
source | enum | WEBSITE, PHONE, EMAIL, WALK_IN, REFERRAL, REALLYMOVING, PINLOCAL, COMPAREMYMOVE, GETAMOVER, TRIGLOBAL, OTHER_PROVIDER, API, MANUAL |
client_id | string | Only leads linked to this client |
email | string | Exact email match, case-insensitive |
created_from, created_to | date | Created date range |
updated_since | date | Only leads changed on or after this date |
include_duplicates | boolean | true to include repeat enquiries |
page, per | int | Pagination |
curl "https://trustyourmove.co.uk/api/v1/leads?status=NEW&created_from=2026-10-01" -H "Authorization: Bearer sf_live_…"{
"data": [
{
"id": "ld_01J…",
"status": "NEW",
"source": "WEBSITE",
"source_ref": null,
"client_id": null,
"first_name": "Priya",
"last_name": "Nair",
"email": "priya@example.com",
"phone": "07700 900123",
"move_from_postcode": "PL1 2AB",
"move_to_postcode": "EX4 4QJ",
"move_date": "2026-11-14",
"flexible": true,
"flexible_days": 7,
"property_type": "TERRACED",
"bedrooms": 3,
"estimated_volume_litres": 22000,
"estimated_volume_m3": 22,
"volume_basis": "bedrooms_quick_estimate",
"converted_job_id": null,
"duplicate_of_id": null,
"marketing_consent": false,
"lost_reason": null,
"created_at": "2026-10-18T09:30:00.000Z",
"updated_at": "2026-10-18T09:30:00.000Z"
}
],
"pagination": {
"page": 1,
"per": 25,
"total": 1,
"pages": 1
}
}GET/leads/{id}
Scope required: leads:read
One lead by id. Same shape as the list, wrapped in a lead object. Returns 404 if it is not in your firm or the Leads module is off.
curl https://trustyourmove.co.uk/api/v1/leads/ld_01J… -H "Authorization: Bearer sf_live_…"GET/jobs
Scope required: jobs:read
Paginated removals jobs, newest first. Needs the Removals module to be on for the firm. Archived jobs are left out unless you ask for them. booked_days lists the diary days of the move itself that are not cancelled. Never returned: street addresses, notes, crew, pay rates, prices or costing.
| Parameter | Type | Description |
|---|---|---|
stage | enum | LEAD, SURVEY_BOOKED, SURVEYED, SURVEY_SENT, ESTIMATED, ACCEPTED, DEPOSIT_PAID, CREW_ASSIGNED, JOB_DONE, REVIEWED, BALANCE_PAID, CHASING, IN_STORAGE, LOST, CANCELLED, COMPLETED |
type | enum | HOME, OFFICE, PART_LOAD, INTERNATIONAL, STORAGE_ONLY, OTHER |
client_id, lead_id | string | Filter by client or by the lead the job came from |
reference | string | Reference contains (e.g. JOB-0001) |
created_from, created_to | date | Created date range |
move_from, move_to | date | Requested move date range |
updated_since | date | Only jobs changed on or after this date |
include_archived | boolean | true to include archived jobs |
page, per | int | Pagination |
curl "https://trustyourmove.co.uk/api/v1/jobs?stage=ACCEPTED&move_from=2026-11-01" -H "Authorization: Bearer sf_live_…"{
"data": [
{
"id": "jb_01J…",
"reference": "JOB-000123",
"stage": "ACCEPTED",
"type": "HOME",
"client_id": "cl_01J…",
"lead_id": "ld_01J…",
"move_from_postcode": "PL1 2AB",
"move_to_postcode": "EX4 4QJ",
"requested_date": "2026-11-14",
"requested_date_flexible": true,
"requested_flex_days": 7,
"requested_window": "ALL_DAY",
"booked_days": [
"2026-11-14"
],
"volume_litres": 28000,
"volume_m3": 28,
"volume_source": "SURVEY",
"packing_service": false,
"dismantling": false,
"archived": false,
"satisfaction_score": null,
"satisfaction_at": null,
"created_at": "2026-10-18T09:40:00.000Z",
"updated_at": "2026-10-19T14:05:00.000Z"
}
],
"pagination": {
"page": 1,
"per": 25,
"total": 1,
"pages": 1
}
}GET/jobs/{id}
Scope required: jobs:read
One job by id or by reference (case-insensitive, e.g. JOB-000123). Same shape as the list, wrapped in a job object.
curl https://trustyourmove.co.uk/api/v1/jobs/JOB-000123 -H "Authorization: Bearer sf_live_…"GET/webhook-events
No API key required
Machine-readable version of the webhooks section: every event name with a description, an example payload, the signature header format and a Node verification snippet. Any valid key works; no scope is required.
curl https://trustyourmove.co.uk/api/v1/webhook-events -H "Authorization: Bearer sf_live_…"GET/public/availability?firm={slug}
No API key required
An unauthenticated endpoint for availability widgets on partner websites: counts and headline rates per storage type, nothing else. It is rate-limited to 60 requests per minute per IP, cached for 30 seconds and only answers for firms that have the API switched on. Because it needs no key it is safe to call from the browser.
curl "https://trustyourmove.co.uk/api/v1/public/availability?firm=harbourside-storage"{
"firm": {
"slug": "harbourside-storage",
"name": "Harbourside Storage"
},
"as_of": "2026-10-18T09:30:00.000Z",
"storage_types": [
{
"slug": "35-sq-ft-container",
"name": "35 sq ft container",
"size_label": "35 sq ft",
"available": 3,
"total": 12,
"rates": {
"daily_pence": 400,
"weekly_pence": 2500,
"monthly_pence": 9500
}
}
]
}<div id="trustyourmove-availability"></div>
<script>
fetch('https://trustyourmove.co.uk/api/v1/public/availability?firm=harbourside-storage')
.then((r) => r.json())
.then(({ storage_types }) => {
document.getElementById('trustyourmove-availability').innerHTML = storage_types
.map((t) => `<p>${t.name}: ${t.available > 0 ? t.available + ' available' : 'Fully booked — enquire'} from £${(t.rates.monthly_pence / 100).toFixed(2)}/month</p>`)
.join('')
})
</script>Webhooks
Webhooks are configured under Settings → Webhooks (Pro and Enterprise). Each endpoint has an HTTPS URL, a list of subscribed events and a signing secret (whsec_…) shown once when the endpoint is created. Trust Your Move queues a delivery for every subscribed event and a background worker POSTs it to your URL within about a minute. The settings page shows every delivery with its response code and body, and lets you retry or send a test.ping.
Events
| Event | Sent when |
|---|---|
booking.created | A booking was created (public page, staff or API). Status is PENDING_PAYMENT for a hold or PENDING_REVIEW for an enquiry. |
booking.confirmed | Payment was taken or staff confirmed the booking; the unit is reserved for the client. |
booking.cancelled | The booking was cancelled by staff or the client. |
booking.completed | The booking ended and the unit was released. |
booking.expired | A hold expired before payment and the unit was released. |
invoice.created | An invoice was issued (booking, deposit, recurring, adjustment, credit note or final). |
invoice.paid | An invoice was paid in full (card, bank transfer, cash or cheque). |
invoice.payment_failed | A card payment attempt failed; dunning may retry. |
invoice.voided | An invoice was voided. |
unit.status_changed | A storage unit changed status (available, reserved, occupied, maintenance, disposal, written off). |
unit.moved | A unit was moved IN, OUT or transferred between warehouses. |
access_code.created | An access code or key record was issued to a client. |
access_code.revoked | An access code was revoked or expired. |
appointment.created | A retrieval or delivery appointment was booked. |
appointment.cancelled | An appointment was cancelled. |
lead.created | A removals enquiry arrived (website form, lead provider, API or entered by staff). The payload carries ids and the source, not contact details: fetch the lead with the leads:read scope. |
job.stage_changed | A removals job moved between pipeline stages. The payload carries the job id and reference, the old and new stage and who or what moved it. |
job.completed | The work on a removals job is done (the stage reached Job done or Completed for the first time). |
test.ping | Sent from Settings → Webhooks → “Send test event”. Carries no business data. |
Payload
Every delivery is a JSON POST with these headers and an envelope containing the delivery id, event name, firm id, event-specific data (ids — fetch full records through the API) and the time the event happened:
| Header | Value |
|---|---|
Content-Type | application/json |
X-StoreFlow-Event | Event name, e.g. booking.confirmed |
X-StoreFlow-Delivery | Delivery id — use it to de-duplicate retries |
X-StoreFlow-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
{
"id": "whd_01J9ZKQ4Y4M3V6XQ1W2E3R4T5Y",
"event": "booking.created",
"firmId": "firm_01J…",
"data": {
"bookingId": "bk_01J9ZKQ4Y4M3V6XQ1W2E3R4T5Z",
"reference": "HS-000123",
"status": "PENDING_PAYMENT",
"clientId": "cl_01J9ZKQ4Y4M3V6XQ1W2E3R4T60",
"storageTypeId": "st_01J9ZKQ4Y4M3V6XQ1W2E3R4T61",
"unitId": "un_01J9ZKQ4Y4M3V6XQ1W2E3R4T62"
},
"createdAt": "2026-10-18T09:30:00.000Z"
}Respond with any 2xx status within 10 seconds. Do the real work after responding (queue it) so slow processing never counts as a failure. Deliveries may arrive out of order and, after a retry, more than once — treat them as idempotent using X-StoreFlow-Delivery.
Verifying signatures
The signature is an HMAC-SHA256 over the string <timestamp>.<raw body> using your endpoint’s secret. Verify it on the raw request bytes before parsing JSON, compare with a constant-time function, and reject timestamps more than five minutes old to prevent replays.
import { createHmac, timingSafeEqual } from 'node:crypto'
// Express example: use the RAW body (not the parsed JSON) when computing the HMAC.
app.post('/hooks/trustyourmove', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('x-storeflow-signature') ?? '' // "t=1760780000,v1=abcdef…"
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const t = Number(parts.t)
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.status(400).send('stale') // 5-minute tolerance
const expected = createHmac('sha256', process.env.STOREFLOW_WEBHOOK_SECRET)
.update(`${t}.${req.body.toString('utf8')}`)
.digest('hex')
const ok = expected.length === String(parts.v1).length && timingSafeEqual(Buffer.from(expected), Buffer.from(String(parts.v1)))
if (!ok) return res.status(400).send('bad signature')
const event = JSON.parse(req.body.toString('utf8')) // { id, event, firmId, data, createdAt }
// handle event.event / event.data — respond quickly, do the work asynchronously
res.status(200).end()
})Rotating a secret (Settings → Webhooks → Edit → “Generate a new signing secret”) invalidates the old one immediately, so update your receiver first.
Retries
A non-2xx response, a timeout (10 seconds) or a connection error marks the delivery failed and it is retried after 1, 5, 30, 120 and 720 minutes. After 5 failed attempts it is marked dead and no longer retried automatically; staff can re-queue it from the deliveries log at any time. Disabling an endpoint stops new events being queued without deleting its history.
Changelog
v1 — 20 October 2026
- Initial public release: inventory, storage types, availability, units, bookings (list, get, create), invoices and webhook-events endpoints.
- Read-only removals endpoints: leads (list, get) and jobs (list, get), with the
leads:readandjobs:readscopes. - Scoped API keys with per-key rate limits and
X-RateLimit-*headers. - Unauthenticated
/public/availabilitywidget endpoint. - Signed webhooks for 18 events (including
lead.created,job.stage_changedandjob.completed) plustest.ping, with a five-step retry schedule and a per-endpoint delivery log.
Breaking changes will only ever ship under a new version prefix. Questions or feature requests: use the contact page.