# brafa booking-sync API

Version 1 · October 2026

This API is for booking systems whose bookings open doors on locks managed by brafa.
Examples are golf simulators, sports halls and booking engines. Your system does two
things with it:

* **Push bookings.** Send a booking whenever it is created, changed, moved or
  cancelled. brafa puts the booking's door code on the right locks at the right time.
* **Read lock status.** For each booking, and each lock it opens, ask whether the code
  is on the lock, why not if it isn't, and when the guest first used it.

## What you get from brafa

| | |
|---|---|
| **Base URL** | `https://app.brafasystems.com/api/booking-sync/{provider}/{tenant}` |
| **Provider** | Your system's name at brafa (lowercase, e.g. `examplegolf`). |
| **Secret** | One signing secret for your system, used for all your venues. brafa sends it over a secure channel. Keep it server-side. |

**Tenant** is your own id for a venue or customer account. Use letters, digits, `-`
and `_` (up to 64 characters); letter case doesn't matter. Keep it stable, because
it identifies the venue at brafa.

All requests are `POST` with a JSON body, over HTTPS, and signed (see
[Signing](#signing)). All times in responses are UTC (`Z`), which is also Iceland
time.

## New venues

A venue is set up at brafa by its **first push**. That push creates the venue and
its units (from your `units`) in brafa. Add `tenantName` to it to name the
venue; otherwise brafa uses your tenant id. You don't need to tell brafa beforehand.

brafa then connects the venue's locks and assigns them to its units. Until that's
done, your bookings are stored, lock status shows `NO_LOCK_ASSIGNED`, and no code
reaches a lock. Once the locks are connected, codes flow without anything more from
you, including codes for bookings you've already pushed.

## Venues moving from another booking system

brafa may already open doors at a venue from another booking system. Before such a
venue moves to you:

1. **Give brafa your tenant id for it.** brafa links that tenant to the venue it
   already has, so it keeps its lock connection and lock assignments. Give brafa your
   unit names too, so they can be checked against the venue's.
2. **Your first push for the venue is the switch-over.** Until then, the old system
   keeps putting codes on the locks. From your first push on, only bookings you push
   get codes: bookings left over from the old system no longer reach the locks.
3. **In your first pushes, send every booking of the venue from today on.** Use your
   own ids, and pick ids that can't clash with the old system's (a prefix like `SG-`
   does this). Codes the old system already put on a lock are reused, not added
   twice.
4. **Unit names matter on the first push.** The first time brafa sees one of your unit
   ids, it matches the unit to the venue's existing one by name. If no name matches,
   brafa gives that unit the doors the venue's units had before (usually one shared
   entrance) until brafa checks it. After that the unit id decides, and renaming is
   fine.

## How codes reach the locks

* **On the day:** a booking's code is put on the locks **on the day the booking
  starts**. Bookings on later days are stored and sent to the locks on their day.
  This keeps few codes on each lock at any time.
* **Changes today:** a booking that starts today, or whose code is already on a lock,
  is updated on the locks within seconds of your push.
* **Opening hours:** the code opens the door from **15 minutes before** `startTime`
  until **15 minutes after** `endTime`.
* **Several locks:** a unit can have more than one lock, for example its own door and
  a shared front door. The code goes on all of them. For a booking with several units,
  a lock they share gets the code once.
* **One code per booking:** give each booking its own code. If two bookings on the
  same unit and day share a code, the lock holds one entry for it. That entry opens
  from the first booking's start to the last one's end, including the gap between.

## Typical flow

1. When a booking is created, changed, moved or cancelled, push its **current**
   state to `/bookings`.
2. A `202` reply means at least one of the bookings is being sent to its locks now.
   Query `/lock-status` for those bookings a few seconds later. Repeat until no lock
   is `pending` or `updating`.
3. If you like, query `/lock-status` with today's `date` now and then, to show lock
   and arrival status for the whole day.

## Push bookings

```
POST {base}/bookings
Content-Type: application/json
X-Brafa-Signature: t=1791370800,v1=…
```

```json
{
  "tenantName": "Golfhöllin",
  "bookings": [
    {
      "bookId": "SG-48213",
      "units": [
        { "id": "17", "name": "Hermir 1" },
        { "id": "18", "name": "Hermir 2" }
      ],
      "accessKey": "4817",
      "startTime": "2026-10-12T18:00:00Z",
      "endTime": "2026-10-12T19:00:00Z",
      "guestName": "Jón Jónsson",
      "modifiedAt": "2026-10-12T09:14:03Z"
    },
    {
      "bookId": "SG-48214",
      "units": [{ "id": "19", "name": "Hermir 3" }],
      "accessKey": "0592",
      "startTime": "2026-10-13T10:00:00Z",
      "endTime": "2026-10-13T11:00:00Z",
      "modifiedAt": "2026-10-12T09:20:41Z"
    }
  ]
}
```

| Field | Required | Rules |
|---|---|---|
| `tenantName` | no | The venue's name, up to 100 characters. Used when this push sets the venue up (see [New venues](#new-venues)); ignored after that. |
| `bookings` | yes | 1–100 bookings, applied in the order given. Send each booking's full current state, not just what changed. |
| `bookId` | yes | Your id for the booking. Letters, digits, `-` and `_`, up to 64 characters. It must stay the same for the whole life of the booking. |
| `units` | yes | The units booked (room, bay, simulator): 1–20, so a booking can cover several, such as two simulators. Each is `{ "id": …, "name": … }`. |
| `units[].id` | yes | Your **stable** id for the unit. Letters, digits, `-` and `_`, up to 64 characters. It decides which locks open, so it must never change for a unit. If you have no unit ids, use a name you'll never change. |
| `units[].name` | yes | The unit's current name, shown in brafa. You may rename a unit freely; its locks stay the same. |
| `accessKey` | yes, unless cancelled | The door code: a **JSON string** of 4–9 digits, such as `"0592"`. A JSON number is rejected, because a leading zero would be lost. `""` cancels the booking. If the field is missing, the request is rejected; a missing code is never read as a cancellation. |
| `status` | no | `confirmed` or `cancelled`. `cancelled` cancels the booking, whatever `accessKey` says. |
| `startTime`, `endTime` | yes | ISO 8601. A time with an offset (`Z`, `+00:00`, `+02:00`) is converted. A time without one is read as Iceland time (UTC). `endTime` must be after `startTime`. |
| `guestName` | no | Shown to the venue's staff in brafa. |
| `modifiedAt` | no, but recommended | When your system last changed the booking (ISO 8601). If brafa already has a newer version, it ignores this one (`lockSync: "stale"`). Then retries and parallel sends can't overwrite a newer change. If you send it, it must change every time the booking changes. |

* **Cancel:** send the booking with `"status": "cancelled"` or with `"accessKey": ""`.
  The code is taken off the locks.
* **Move or change:** send the same `bookId` with the new times, units or code. The
  code is changed in place on the lock, or moved to the new units' locks. A booking
  moved to a later day is taken off today's locks at once and comes back on its day.
* **New unit:** a unit id brafa hasn't seen before is added to the venue, but it
  opens no door until brafa assigns it a lock. Lock status reports this as
  `NO_LOCK_ASSIGNED`.
* **Changing booking system:** see
  [Venues moving from another booking system](#venues-moving-from-another-booking-system).

### Push response

```json
{
  "bookings": [
    { "bookId": "SG-48213", "status": "confirmed", "lockSync": "in_progress" },
    { "bookId": "SG-48214", "status": "confirmed", "lockSync": "on_booking_day" }
  ]
}
```

The response is `202` if any booking has `lockSync` `in_progress`, and `200`
otherwise. Every booking in it has been stored.

`lockSync`:

| Value | Meaning |
|---|---|
| `in_progress` | The booking starts today, or its code was already on a lock. Its locks are being updated now; query [lock status](#lock-status) for the result. |
| `on_booking_day` | The code goes on the locks on the booking's day. |
| `none` | Cancelled, and nothing was on a lock to remove. |
| `stale` | Ignored: brafa already has a newer version of this booking (by `modifiedAt`). |
| `skipped` | The booking couldn't be stored. This shouldn't happen for a valid request; tell brafa if you see it. |

`"warning": "ACCESS_KEY_TOO_SIMPLE"` on a booking means the locks will refuse its
code. Locks refuse a run of consecutive digits like `1234` or `9876`, and the same
digit repeated, like `1111`. The booking is stored, but no lock gets a code until you
send the booking again with a different code.

**Replayed push:** sending exactly the same push again (same `t`, same body) gets
`200 {"status": "duplicate"}`, and the push isn't applied a second time.

## Lock status

```
POST {base}/lock-status
Content-Type: application/json
X-Brafa-Signature: t=1791370800,v1=…
```

Ask for up to 100 bookings by id:

```json
{ "bookIds": ["SG-48213", "SG-48214"] }
```

Or ask for every booking that starts on a date (up to 500, in start order):

```json
{ "date": "2026-10-12" }
```

Send `bookIds` or `date`, not both. A venue that hasn't pushed anything yet answers
`404 TENANT_NOT_FOUND`. Reading changes nothing, so you can repeat a query
as often as you like, within the [rate limit](#limits). `checkedAt` in the answer is
when brafa produced it.

```json
{
  "checkedAt": "2026-10-12T18:05:00Z",
  "bookings": [
    {
      "bookId": "SG-48213",
      "status": "confirmed",
      "units": [
        { "id": "17", "name": "Hermir 1" },
        { "id": "18", "name": "Hermir 2" }
      ],
      "startTime": "2026-10-12T18:00:00Z",
      "endTime": "2026-10-12T19:00:00Z",
      "state": "failed",
      "arrivedAt": "2026-10-12T17:52:00Z",
      "sendsOn": null,
      "problems": [],
      "locks": [
        {
          "lockId": "5001",
          "lockName": "Hermir 1",
          "state": "on_lock",
          "permissionId": "88123",
          "validFrom": "2026-10-12T17:45:00Z",
          "validTo": "2026-10-12T19:15:00Z",
          "lastAttemptAt": "2026-10-12T09:00:02Z",
          "firstUsedAt": null,
          "error": null
        },
        {
          "lockId": "5009",
          "lockName": "Front door",
          "state": "on_lock",
          "permissionId": "88124",
          "validFrom": "2026-10-12T17:45:00Z",
          "validTo": "2026-10-12T19:15:00Z",
          "lastAttemptAt": "2026-10-12T09:00:05Z",
          "firstUsedAt": "2026-10-12T17:52:00Z",
          "error": null
        },
        {
          "lockId": "5002",
          "lockName": "Hermir 2",
          "state": "failed",
          "permissionId": null,
          "validFrom": null,
          "validTo": null,
          "lastAttemptAt": "2026-10-12T09:00:08Z",
          "firstUsedAt": null,
          "error": { "code": "-3002", "message": "gateway offline" }
        }
      ]
    },
    { "bookId": "SG-48214", "status": "not_found" }
  ]
}
```

### Booking fields

| Field | Meaning |
|---|---|
| `bookId` | As you sent it. |
| `status` | `confirmed`, `cancelled`, or `not_found` (brafa has no booking with this id for your tenant). A `not_found` booking has no other fields. |
| `units` | The units the booking covers, as `{ "id", "name" }` with your ids and their latest names. |
| `startTime`, `endTime` | The booking's times, in UTC. |
| `state` | The worst state among its locks (see [Lock states](#lock-states)). It is `no_lock_assigned` if the booking's units have no lock yet, and `none` for a cancelled booking that never reached a lock. |
| `arrivedAt` | The first time the guest opened **any** of the booking's locks with the code, during the booking's opening hours. `null` until then. |
| `sendsOn` | For a `scheduled` booking, the date its code goes on the locks. Otherwise `null`. |
| `problems` | `["NO_LOCK_ASSIGNED"]` when brafa hasn't yet assigned a lock to at least one of the booking's units. Otherwise empty. |
| `locks` | One entry for each lock the code goes on, or was taken off. brafa keeps this detail until 2 days after the booking's day; for an older booking `locks` is empty and `state` is `expired`. |

### Lock fields

| Field | Meaning |
|---|---|
| `lockId`, `lockName` | brafa's id and name for the lock. `lockName` can be `null`. |
| `state` | See [Lock states](#lock-states). |
| `permissionId` | The lock's own id for the code while it is on the lock, otherwise `null`. |
| `validFrom`, `validTo` | The hours the lock actually holds for the code, while it is on the lock. |
| `lastAttemptAt` | When brafa last added, changed or removed the code on this lock. |
| `firstUsedAt` | The first time the guest opened this lock with the code, during the booking's opening hours. |
| `error` | Why the last attempt failed: `{ "code": …, "message": … }`, or `null`. |

Only a successful unlock with the booking's code counts as use. A wrong code doesn't
count, and neither does brafa changing the code on the lock. brafa usually sees an
unlock within seconds, and within about 10 minutes at most.

### Lock states

From worst to best, which is also the order used for the booking's `state`:

| State | Meaning |
|---|---|
| `rejected` | The lock will never accept this code (`ACCESS_KEY_TOO_SIMPLE`). Send the booking again with a different code. |
| `failed` | The last attempt failed (see `error`). brafa retries automatically. If an older version of the code is still on the lock, `validFrom`/`validTo` show it. |
| `removal_pending` | The code must come off this lock (the booking was cancelled, or moved to another unit or day), but the removal isn't confirmed yet. brafa retries automatically. |
| `not_sent` | The booking is over, and the code never reached this lock. |
| `updating` | The code is on the lock, but your latest change to its code or hours isn't on the lock yet. `validFrom`/`validTo` still show the old hours. |
| `pending` | Should be on the lock now, and is being sent. |
| `scheduled` | Goes on the lock on the booking's day (`sendsOn`). |
| `on_lock` | The lock has the code, and its hours cover the booking. |
| `expired` | The code was on the lock, and the booking is over. |
| `removed` | The code was taken off this lock. |

`error.code`:

* **Negative numbers** are error codes from the lock manufacturer's cloud (TTLock), with
  its own `message`. For example, `-3002` means the lock's internet gateway is offline.
* `CONNECTION_ERROR`: the lock cloud couldn't be reached.
* `NO_LOCK_ACCOUNT`: the venue's lock account isn't connected at brafa yet.
* `HTTP_<status>` or `UNEXPECTED_RESPONSE`: the lock cloud answered with an error or
  an unreadable reply.
* `ACCESS_KEY_TOO_SIMPLE`: see `rejected`.

Gateway, lock-account and lock-assignment problems are on brafa's side; contact brafa
if one doesn't clear. The only state you fix yourself is `rejected`: send a different
code.

## Bookings feed (optional; brafa reads it)

If your system can serve one, brafa also reads each venue's bookings from a feed
of yours every 10 minutes. It catches up on any push that never arrived, for
example during a network problem.

```
GET {your feed address}?from=2026-10-12&to=2026-10-13
Authorization: Bearer <token>
Accept: application/json
```

* **Address:** you choose it and give it to brafa, with `{tenant}` where the venue
  id goes, e.g. `https://<your-api>/brafa/{tenant}/bookings`. brafa fills in each
  venue's tenant id.
* **Token:** you issue brafa a token for the feed and send it over a secure channel.
  brafa sends it in the `Authorization` header; refuse requests without it. To change
  it, give brafa the new token, and accept both until brafa has switched.
* **`from`, `to`:** dates (`YYYY-MM-DD`, Iceland time), both included. brafa asks for
  today and tomorrow.
* **When:** every 10 minutes, for each of your venues that has pushed at least once.
  Answer within 10 seconds.

Answer `200` with every booking of that venue that **starts** on a day from `from`
to `to`. Use exactly the format of a [push](#push-bookings):

```json
{
  "bookings": [
    {
      "bookId": "SG-48213",
      "units": [{ "id": "17", "name": "Hermir 1" }],
      "accessKey": "4817",
      "startTime": "2026-10-12T18:00:00Z",
      "endTime": "2026-10-12T19:00:00Z",
      "guestName": "Jón Jónsson",
      "modifiedAt": "2026-10-12T09:14:03Z"
    },
    {
      "bookId": "SG-48190",
      "units": [{ "id": "18", "name": "Hermir 2" }],
      "status": "cancelled",
      "startTime": "2026-10-12T20:00:00Z",
      "endTime": "2026-10-12T21:00:00Z",
      "modifiedAt": "2026-10-12T07:02:11Z"
    }
  ]
}
```

* **Include cancelled bookings**, with `"status": "cancelled"` or `"accessKey": ""`.
  brafa never cancels a booking because it's missing from the feed, so a short or
  failed answer can't take codes off the locks. A booking you leave out keeps whatever
  brafa last had.
* **Include `modifiedAt`.** It decides between your feed and your pushes: a version
  older than the one brafa already has is ignored.
* Return up to 1,000 bookings per answer.
* **When brafa changes nothing:** another status code, no answer within the time
  limit, or an answer without a `bookings` list. brafa tries again on the next run.
* **Invalid bookings:** one invalid booking in an otherwise good answer is skipped,
  and the rest are applied.

## Signing

Sign every request with your system's secret:

```
signed_payload = "{t}.{raw request body}"
v1             = hex( HMAC_SHA256(secret, signed_payload) )
header         = X-Brafa-Signature: t={t},v1={v1}
```

* `t` is the current Unix time in seconds. A request whose `t` is more than 5 minutes
  away from brafa's clock is rejected, so keep your server clock in sync.
* Sign the **exact bytes** you send as the body. If the JSON is serialised again after
  signing, even with the same content, the signature won't match.
* `v1` is lowercase hexadecimal.
* **When brafa changes your secret,** both the old and the new one work for a while.
  You may send both, as `t=…,v1=<old>,v1=<new>`.

**Test vector.** Check your code against this before going live:

| | |
|---|---|
| secret | `test-secret-do-not-use` |
| t | `1791370800` |
| body | `{"bookIds":["1001"]}` |
| v1 | `64b64af70c022d6e127c4e5f6ffdc621bd0419ed761a7fbba12cc66f378521b7` |

C#:

```csharp
var body = JsonSerializer.Serialize(payload);
var t = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var v1 = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{body}"))).ToLowerInvariant();

var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/bookings")
{
    Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
request.Headers.Add("X-Brafa-Signature", $"t={t},v1={v1}");
```

Node.js:

```js
const crypto = require("crypto");

const body = JSON.stringify(payload);
const t = Math.floor(Date.now() / 1000);
const v1 = crypto.createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");

await fetch(`${baseUrl}/bookings`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-Brafa-Signature": `t=${t},v1=${v1}` },
  body,
});
```

Shell:

```bash
BODY='{"bookIds":["1001"]}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST "$BASE_URL/lock-status" \
  -H 'Content-Type: application/json' \
  -H "X-Brafa-Signature: t=$T,v1=$SIG" \
  --data-raw "$BODY"
```

## Errors and retries

Errors come back as `{"error": "<CODE>"}`:

| HTTP | `error` | Meaning | What to do |
|---|---|---|---|
| `400` | `BAD_SIGNATURE_HEADER` | The `X-Brafa-Signature` header is missing or malformed. | Fix the request. |
| `401` | `BAD_SIGNATURE` | The signature doesn't match your system's secret. | Check the secret and that you sign the exact body. |
| `403` | `TIMESTAMP_EXPIRED` | `t` is more than 5 minutes from brafa's clock. | Sync your clock and sign again. |
| `404` | `PROVIDER_NOT_FOUND` | Unknown provider in the URL, or brafa has switched your system off. | Check the URL, or contact brafa. |
| `404` | `TENANT_NOT_FOUND` | A status query for a venue that hasn't pushed yet, or a venue brafa has switched off. | Push a booking first, or contact brafa. |
| `422` | `VALIDATION_FAILED` | The body is invalid. Nothing was stored. | Fix the fields listed in `errors`. |
| `429` | — | Too many requests. | Wait and retry. |
| `503` | `PROVIDER_NOT_CONFIGURED` | brafa can't verify your requests right now. | Contact brafa. |
| `5xx` | — | Temporary problem at brafa. | Retry with backoff. |

A `422` lists each problem field:

```json
{
  "error": "VALIDATION_FAILED",
  "errors": { "bookings.0.accessKey": ["The bookings.0.accessKey must be a string."] }
}
```

* **Retrying:** retry `429` and `5xx` with increasing waits, signing again with a new
  `t`. Don't retry other `4xx` errors unchanged.
* **Timeouts:** if a push times out, send it again. Pushes are idempotent: they set
  each booking to the state you send.
* **Order:** with `modifiedAt`, pushes can arrive in any order, because an older
  version never overwrites a newer one. Without it, send the pushes for one booking
  one after another, so they can't overtake each other.

## Limits

| | |
|---|---|
| Bookings per push | 100 |
| Units per booking | 20 |
| `bookIds` per status query | 100 |
| Bookings returned for a `date` | 500 |
| Requests | 300 per minute per IP address, pushes and status queries together |
| Signature age | 5 minutes |
| Bookings feed answer | 10 seconds, up to 1,000 bookings |
