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

requested→confirmed→completed
StatusMeaning
requestedWe've booked a lead into this slot and it's waiting for you to confirm. The slot is already held.
confirmedYou've accepted it. Bookings you create yourself start here unless you say otherwise.
completedThe scan has been done.
no_showYour shooter went and couldn't get in.
cancelledCancelled 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:

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."
  }
}
HTTPcodeWhen
400invalid_jsonThe body isn't valid JSON
400missing_fieldA required field is missing. The message names it
400invalid_date, invalid_time, invalid_rangeA date or time we can't read, or a range over 62 days
400invalid_statusA status that can't be set this way
400invalid_urlThe webhook URL isn't a valid https URL
401unauthorizedMissing, wrong or revoked API key
403partner_pausedYour account is paused. You can still read, but not write
404not_foundNo such booking, or no such endpoint
409slot_takenThe slot or the day is already full
409day_closedThat day has a blackout with no capacity
409booking_cancelled, booking_completedThe booking can't be changed in its current state
422in_the_pastThe time has already gone
422not_a_working_day, outside_hoursOutside your working days or start times
422too_soon, beyond_horizonInside the notice period, or further ahead than the diary is open
422no_webhookYou asked for a test webhook with no URL set
500server_errorSomething 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

GET/me

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

GET/availability?from=2026-10-26&to=2026-11-01

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

GET/bookings
QueryDescription
from, toLocal dates, inclusive, filtering on the booking date
statusOne or more statuses, comma separated, e.g. requested,confirmed
updated_sinceISO 8601 timestamp. Returns bookings changed at or after it, oldest change first. See keeping in sync
limit1 to 200, default 50
offsetFor 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

GET/bookings/{id}
{ "booking": { ...booking } }

Create a booking

POST/bookings

For work you've taken directly. The place is held the moment this returns 201.

FieldDescription
daterequiredYYYY-MM-DD, local
timerequiredHH:MM, local. Doesn't have to be one of the slot times, but must fall inside your hours
customer.namerequiredWho your shooter should ask for
customer.phone / customer.emailone requiredHow to reach them
property.addressrequiredStreet and town
property.postcodeStored in capitals
property.typee.g. House, Flat, Commercial
property.bedrooms, property.size_sqftNumbers
property.access_notesKeys, parking, who'll be there
serviceWhat's being shot. Left empty, it's treated as a Matterport scan
notesAnything else
assigneeYour shooter's name
partner_refYour own job number. We store it and send it back on every booking and webhook
statusconfirmed (default) or requested
override_capacitytrue 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. Send an 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

PATCH/bookings/{id}

Send only what's changing. This is how you confirm a booking, put a shooter on it, move it, or mark it done.

FieldDescription
statusconfirmed, completed or no_show. Use cancel to cancel
date, timeMove it. Send one or both. The same capacity checks apply as when booking
assigneeYour shooter's name
partner_refYour own job number
customer, property, service, notesSame fields as create. Inside customer and property, only the keys you send are changed
override_capacitytrue 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

POST/bookings/{id}/cancel
{ "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.

PUT/blackouts/{date}
{ "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.

GET/blackouts?from=2026-10-01&to=2026-12-31
DELETE/blackouts/{date}

Removes the blackout and the day goes back to normal. Returns 204.

Webhook settings

PUT/webhook
{ "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.

POST/webhook/test

Sends a ping event to your URL.

GET/webhook/deliveries

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"
}
FieldNotes
idUse this in URLs. It never changes
refOur human reference, handy on the phone
sourceunwrapped if we booked it, partner if you did
duration_minutesHow 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.

EventWhen
booking.createdA new booking. When triggered_by is unwrapped this is a new lead for you to confirm
booking.confirmedA booking moved to confirmed
booking.updatedAny other change: moved, reassigned, details edited, completed
booking.cancelledCancelled by either side
pingSent 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);
}

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:

  1. GET /me to check your key works
  2. PUT /webhook with your endpoint, then POST /webhook/test
  3. GET /availability for next week
  4. POST /bookings, then PATCH it to move it and add a shooter, then cancel it
  5. 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.