Partner API
Unwrapped sends you property scan bookings. This API lets your system see every booking, confirm it, put a shooter on it, move it, cancel it, and tell us when you're short on capacity. Webhooks let you know the moment we book something new.
Overview
All requests go to:
https://unwrapped-partners.pages.dev/api/partner/v1
Requests and responses are JSON. Send Content-Type: application/json with any body. Every endpoint below is relative to that base URL.
Both sides write to the same diary. When we have a lead, we book it into a free slot and it arrives with you as requested. You confirm it, adding the time and shooter that suit your rounds. You can also book work straight in yourself. Either way the slot is held, and nobody else can take a place that's already full.
Authentication
We'll give you an API key that starts with uwp_. Send it as a bearer token on every request:
curl https://unwrapped-partners.pages.dev/api/partner/v1/me \
-H "Authorization: Bearer uwp_your_key_here"
Keep the key on your server. Don't put it in a web page or mobile app. If one leaks, tell us and we'll revoke it and issue a new one. You can have more than one key live at a time, so swapping keys doesn't need any downtime.
How a booking moves
| Status | Meaning |
|---|---|
requested | We've booked a lead into this slot and it's waiting for you to confirm. The slot is already held. |
confirmed | You've accepted it. Bookings you create yourself start here unless you say otherwise. |
completed | The scan has been done. |
no_show | Your shooter went and couldn't get in. |
cancelled | Cancelled by either side. The place is free again. |
We aim to have requested bookings confirmed within two to three days. If the time we picked doesn't work for your route, move it while you confirm (see updating a booking).
Dates and times
Everything is on your local clock, in your account's timezone (Europe/London). We send times three ways so you can use whichever is easiest:
dateasYYYY-MM-DDandtimeas 24 hourHH:MM, both localstarts_atas ISO 8601 with the offset applied, e.g.2026-10-20T09:00:00+01:00in summer and+00:00in winter
When you send a time, send date and time on your local clock. Record timestamps like created_at and updated_at are UTC and end in Z.
Capacity
Your diary is set up with working days, a first and last start time, a slot length, how many shoots can run in the same slot and how many you can do in a day. Call GET /me to see the current settings. We change them whenever you ask. For a single day, use blackouts.
Errors
Errors use normal HTTP status codes and always have the same shape, with a code you can switch on and a message you can show a person:
{
"error": {
"code": "slot_taken",
"message": "That slot is full. Check availability and pick another time."
}
}
| HTTP | code | When |
|---|---|---|
| 400 | invalid_json | The body isn't valid JSON |
| 400 | missing_field | A required field is missing. The message names it |
| 400 | invalid_date, invalid_time, invalid_range | A date or time we can't read, or a range over 62 days |
| 400 | invalid_status | A status that can't be set this way |
| 400 | invalid_url | The webhook URL isn't a valid https URL |
| 401 | unauthorized | Missing, wrong or revoked API key |
| 403 | partner_paused | Your account is paused. You can still read, but not write |
| 404 | not_found | No such booking, or no such endpoint |
| 409 | slot_taken | The slot or the day is already full |
| 409 | day_closed | That day has a blackout with no capacity |
| 409 | booking_cancelled, booking_completed | The booking can't be changed in its current state |
| 422 | in_the_past | The time has already gone |
| 422 | not_a_working_day, outside_hours | Outside your working days or start times |
| 422 | too_soon, beyond_horizon | Inside the notice period, or further ahead than the diary is open |
| 422 | no_webhook | You asked for a test webhook with no URL set |
| 500 | server_error | Something went wrong on our side. Safe to retry |
Apart from in_the_past, the 422 checks, slot_taken and day_closed are skipped when you send "override_capacity": true. See creating a booking.
Your account
Who the key belongs to and how your diary is set up.
{
"partner": { "id": "ptn_0mv6sq5zj0cx88mn4", "name": "Example Scans Ltd", "status": "active" },
"diary": {
"timezone": "Europe/London",
"working_days": [1, 2, 3, 4, 5, 6],
"day_start": "09:00",
"day_end": "18:00",
"slot_minutes": 90,
"slot_times": ["09:00", "10:30", "12:00", "13:30", "15:00", "16:30"],
"slot_capacity": 2,
"daily_capacity": 6,
"min_notice_hours": 24,
"horizon_days": 60
},
"webhook": { "url": "https://example.com/hooks/unwrapped" }
}
working_days uses 0 for Sunday through 6 for Saturday. slot_capacity is how many shoots can start in the same slot. daily_capacity is the most in one day.
Availability
Free places per day and per slot. from and to are local dates, inclusive, up to 62 days apart. If you leave them out you get today.
{
"timezone": "Europe/London",
"slot_minutes": 90,
"days": [
{
"date": "2026-10-26",
"open": true,
"closed_reason": null,
"capacity": 6,
"booked": 4,
"remaining": 2,
"note": null,
"slots": [
{ "time": "09:00", "starts_at": "2026-10-26T09:00:00+00:00", "available": false, "remaining": 0 },
{ "time": "10:30", "starts_at": "2026-10-26T10:30:00+00:00", "available": true, "remaining": 1 }
]
},
{
"date": "2026-11-01", "open": false, "closed_reason": "not_a_working_day",
"capacity": 6, "booked": 0, "remaining": 0, "note": null, "slots": []
}
]
}
closed_reason is one of not_a_working_day, closed (a blackout), full, beyond_horizon or paused. A slot's remaining takes the day's limit into account, so it never offers more than the day has left.
List bookings
| Query | Description |
|---|---|
from, to | Local dates, inclusive, filtering on the booking date |
status | One or more statuses, comma separated, e.g. requested,confirmed |
updated_since | ISO 8601 timestamp. Returns bookings changed at or after it, oldest change first. See keeping in sync |
limit | 1 to 200, default 50 |
offset | For the next page, pass the next_offset you were given |
{
"data": [ { ...booking }, { ...booking } ],
"has_more": true,
"next_offset": 50
}
Without updated_since, results are in date and time order.
Get a booking
{ "booking": { ...booking } }
Create a booking
For work you've taken directly. The place is held the moment this returns 201.
| Field | Description |
|---|---|
daterequired | YYYY-MM-DD, local |
timerequired | HH:MM, local. Doesn't have to be one of the slot times, but must fall inside your hours |
customer.namerequired | Who your shooter should ask for |
customer.phone / customer.emailone required | How to reach them |
property.addressrequired | Street and town |
property.postcode | Stored in capitals |
property.type | e.g. House, Flat, Commercial |
property.bedrooms, property.size_sqft | Numbers |
property.access_notes | Keys, parking, who'll be there |
service | What's being shot. Left empty, it's treated as a Matterport scan |
notes | Anything else |
assignee | Your shooter's name |
partner_ref | Your own job number. We store it and send it back on every booking and webhook |
status | confirmed (default) or requested |
override_capacity | true to book in even if the slot or day is full, or outside your hours. Use it when you know your own diary better than the settings do |
curl -X POST https://unwrapped-partners.pages.dev/api/partner/v1/bookings \
-H "Authorization: Bearer uwp_your_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: JOB-7781" \
-d '{
"date": "2026-10-26",
"time": "10:30",
"customer": { "name": "Sarah Hughes", "phone": "07700 900123", "email": "sarah@example.co.uk" },
"property": { "address": "14 Elm Road, Leeds", "postcode": "LS6 2AB", "type": "House", "bedrooms": 3 },
"assignee": "Tom Reid",
"partner_ref": "JOB-7781"
}'
Returns 201 with { "booking": {...}, "replayed": false }. If the slot filled up in the meantime you'll get 409 slot_taken. Check availability again and offer another time.
Idempotency-Key header (any string up to 120 characters, your job number works well) and a retried request can't create a second booking. If we've already seen the key we return the original booking with 200 and "replayed": true.Update a booking
Send only what's changing. This is how you confirm a booking, put a shooter on it, move it, or mark it done.
| Field | Description |
|---|---|
status | confirmed, completed or no_show. Use cancel to cancel |
date, time | Move it. Send one or both. The same capacity checks apply as when booking |
assignee | Your shooter's name |
partner_ref | Your own job number |
customer, property, service, notes | Same fields as create. Inside customer and property, only the keys you send are changed |
override_capacity | true to move it into a slot that's full |
Confirming a lead and moving it in one go
curl -X PATCH https://unwrapped-partners.pages.dev/api/partner/v1/bookings/bk_0mv6squxqf9w3o2jq \
-H "Authorization: Bearer uwp_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "status": "confirmed", "time": "13:30", "assignee": "Tom Reid", "partner_ref": "JOB-7781" }'
{
"booking": { ...booking, "status": "confirmed", "time": "13:30", "assignee": "Tom Reid" },
"changed": ["starts_at", "assignee", "partner_ref", "status"]
}
If the move would overbook the slot, nothing is changed (not even the status) and you get 409 slot_taken.
Cancel a booking
{ "reason": "Vendor took the property off the market" }
The reason is optional. Returns the cancelled booking. Cancelling twice is harmless. A completed booking can't be cancelled.
Blackouts
Close a day, or take fewer bookings than usual, for example when a shooter is off or a van's in for repair.
{ "capacity": 3, "reason": "Two shooters on leave" }
capacity is the most bookings that day. Use 0 to close it. Bookings already in the diary stay put, so check for any you need to move.
Removes the blackout and the day goes back to normal. Returns 204.
Webhook settings
{ "url": "https://example.com/hooks/unwrapped" }
Must be https. The response includes a signing secret that starts with whsec_. Store it, because you'll need it to check signatures. Setting the URL again gives you a new secret. Send { "url": null } to stop webhooks.
Sends a ping event to your URL.
The last 50 deliveries with attempts, last HTTP status and any error. Handy while you're building.
The booking object
{
"id": "bk_0mv6squxqf9w3o2jq",
"ref": "UM-BK-5081",
"status": "confirmed",
"starts_at": "2026-10-26T09:00:00+00:00",
"date": "2026-10-26",
"time": "09:00",
"timezone": "Europe/London",
"duration_minutes": 90,
"service": null,
"customer": { "name": "Sarah Hughes", "email": "sarah@example.co.uk", "phone": "07700 900123" },
"property": {
"address": "14 Elm Road, Leeds",
"postcode": "LS6 2AB",
"type": "House",
"bedrooms": 3,
"size_sqft": null,
"access_notes": "Keys with neighbour at 16"
},
"notes": null,
"assignee": "Tom Reid",
"partner_ref": "JOB-7781",
"source": "unwrapped",
"confirmed_at": "2026-10-13T14:49:21Z",
"cancelled_at": null,
"cancel_reason": null,
"created_at": "2026-10-13T14:49:18Z",
"updated_at": "2026-10-13T14:49:21Z"
}
| Field | Notes |
|---|---|
id | Use this in URLs. It never changes |
ref | Our human reference, handy on the phone |
source | unwrapped if we booked it, partner if you did |
duration_minutes | How long the slot is held for |
We may add new fields over time. Please ignore any you don't recognise rather than rejecting the response.
Webhooks
When a booking changes, we POST the event to your webhook URL straight away.
| Event | When |
|---|---|
booking.created | A new booking. When triggered_by is unwrapped this is a new lead for you to confirm |
booking.confirmed | A booking moved to confirmed |
booking.updated | Any other change: moved, reassigned, details edited, completed |
booking.cancelled | Cancelled by either side |
ping | Sent by POST /webhook/test |
You'll also get events for changes you made yourself through the API, so your system sees one consistent feed. triggered_by tells you which side made the change.
POST /hooks/unwrapped HTTP/1.1
Content-Type: application/json
Unwrapped-Event: booking.created
Unwrapped-Delivery: whk_0mv6sqv2x1k9a8bce
Unwrapped-Signature: t=1791902958,v1=5f2c0d4b...e81a
{
"id": "whk_0mv6sqv2x1k9a8bce",
"event": "booking.created",
"created_at": "2026-10-13T14:49:18.204Z",
"triggered_by": "unwrapped",
"data": { "booking": { ...booking } }
}
Responding and retries
Reply with any 2xx within 10 seconds. Anything else counts as a failure and we try again after 1, 5, 15 and 60 minutes, then 3, 6 and 12 hours. After 8 failed attempts we stop and flag it on our side. If you'd rather not depend on webhooks alone, see keeping in sync.
The same event can occasionally arrive twice, and retries can arrive out of order. Use the id to skip ones you've already handled, and compare the booking's updated_at with what you hold before overwriting.
Checking signatures
Every webhook is signed with your whsec_ secret. To check one, take t and v1 from the Unwrapped-Signature header, work out HMAC-SHA256 of {t}.{raw body} with your secret, and compare the hex result with v1. Use the raw request body exactly as received, before any JSON parsing. Rejecting anything where t is more than five minutes old stops replays.
import crypto from 'node:crypto';
export function isFromUnwrapped(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex'));
const given = Buffer.from(parts.v1 ?? '');
return given.length === expected.length && crypto.timingSafeEqual(expected, given);
}
function is_from_unwrapped(string $rawBody, string $header, string $secret): bool {
parse_str(str_replace(',', '&', $header), $parts);
if (abs(time() - (int) $parts['t']) > 300) return false;
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1'] ?? '');
}
// $rawBody = file_get_contents('php://input');
// $header = $_SERVER['HTTP_UNWRAPPED_SIGNATURE'];
import hashlib, hmac, time
def is_from_unwrapped(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(secret.encode(), f'{parts["t"]}.'.encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Keeping in sync
Webhooks are the fast path. As a safety net, we'd suggest polling every few minutes for anything changed since the last change you saw:
GET /bookings?updated_since=2026-10-13T14:49:21Z&limit=200
Store the updated_at of the last booking in each response and send it as updated_since next time. Because the match is "at or after", you'll see that last booking again. Skip anything with an id and updated_at you already hold. Keep paging with offset while has_more is true.
Please keep to a few requests a second. Polling every few minutes is plenty.
Testing
We'll give you a sandbox key first. It works exactly like the live one, against a separate test diary, so you can book, move, cancel and fire webhooks without touching real jobs. When you're ready we'll issue a live key. The base URL is the same.
A sensible first pass:
GET /meto check your key worksPUT /webhookwith your endpoint, thenPOST /webhook/testGET /availabilityfor next weekPOST /bookings, thenPATCHit to move it and add a shooter, then cancel it- Check each step arrived as a webhook and the signature checks out
Questions or something not behaving: get in touch with your contact at Unwrapped Media.