# brafa booking-sync API for Entrify

Version 1 · October 2026

This is how Entrify sends bookings to brafa, and reads back what happened at the door.

brafa already reads your bookings feed (`/api/tenants/{tenant}/bookings/codes`) on a
schedule, and keeps doing so as a safety net. This API adds two things:

* **Push bookings.** Send a booking the moment it is created, changed, moved or
  cancelled. A booking for today then reaches the lock within seconds, instead of at
  brafa's next read of your feed.
* **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.

## Your details

| | |
|---|---|
| **Provider** | `entrify` |
| **Base URL** | `https://app.brafasystems.com/api/booking-sync/entrify/{tenant}`, e.g. `https://app.brafasystems.com/api/booking-sync/entrify/golfsvitan` |
| **Secret** | One signing secret for Entrify, used for all your tenants. brafa sends it to you separately, over a secure channel. Keep it server-side. |

**Tenant** is your tenant slug, the same as in your feed URL, e.g. `golfsvitan`.
Letter case doesn't matter.

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.

## Tenants

Tenants brafa already reads through your feed, such as `golfsvitan`, are already set
up. There is nothing to do for them.

A **new** tenant is set up at brafa by its **first push**. That push creates the
venue and its locations (from your `units`) in brafa. Add `tenantName` to it to name
the venue; otherwise brafa uses your tenant slug. 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.

## 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`. Bookings for later days can be pushed at any time; brafa
   puts them on the lock on their day.
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.
4. If a push fails, nothing is lost: brafa's reads of your feed pick the booking up
   before its day.

## Push bookings

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

```json
{
  "bookings": [
    {
      "bookId": "3801",
      "units": [{ "id": "100000", "name": "Hafnarfjörður" }],
      "accessKey": "1725",
      "startTime": "2026-10-12T16:00:00",
      "endTime": "2026-10-12T18:00:00",
      "guestName": "Jón Jónsson"
    },
    {
      "bookId": "3802",
      "units": [{ "id": "100001", "name": "Egilshöll" }],
      "accessKey": "",
      "startTime": "2026-10-13T10:00:00",
      "endTime": "2026-10-13T11:00:00"
    }
  ]
}
```

The second booking is a cancellation: an empty `accessKey`, as in your feed.

### From your feed

Each pushed booking carries the same data as your feed:

| Your feed | Push field | Notes |
|---|---|---|
| `bookId` | `bookId` | Unchanged. |
| `propId` | `units[].id` | Your location id, as a string. |
| `unitName` | `units[].name` | The location's name. |
| `accessKey` | `accessKey` | A JSON **string**. `""` means cancelled, as in your feed. |
| `startTime`, `endTime` | `startTime`, `endTime` | Unchanged. Without an offset, they're read as Iceland time, as now. |
| `guestName` | `guestName` | Unchanged. |
| `modifiedDate` | `modifiedAt` | Send it **only** if it changes every time that booking changes. Leave it out if it can stay the same, or be the same for many bookings. |
| `tenantName` | `tenantName` | Top level, optional. Names a new tenant's venue. |

### All fields

| Field | Required | Rules |
|---|---|---|
| `tenantName` | no | The venue's name, up to 100 characters. Used when this push sets the venue up (see [Tenants](#tenants)); 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, location or code.
  A move keeps its `bookId` and `accessKey`, as in your feed. The code is changed in
  place on the lock, or moved to the new location's lock. A booking moved to a later
  day is taken off today's locks at once and comes back on its day.
* **New location:** a location 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`.

### Push response

```json
{
  "bookings": [
    { "bookId": "3801", "status": "confirmed", "lockSync": "in_progress" },
    { "bookId": "3802", "status": "cancelled", "lockSync": "none" }
  ]
}
```

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": ["3801", "3803"] }
```

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": "3801",
      "status": "confirmed",
      "units": [{ "id": "100000", "name": "Hafnarfjörður" }],
      "startTime": "2026-10-12T16:00:00Z",
      "endTime": "2026-10-12T18:00:00Z",
      "state": "failed",
      "arrivedAt": "2026-10-12T15:52:00Z",
      "sendsOn": null,
      "problems": [],
      "locks": [
        {
          "lockId": "5009",
          "lockName": "Front door",
          "state": "on_lock",
          "permissionId": "88124",
          "validFrom": "2026-10-12T15:45:00Z",
          "validTo": "2026-10-12T18:15:00Z",
          "lastAttemptAt": "2026-10-12T09:00:05Z",
          "firstUsedAt": "2026-10-12T15:52:00Z",
          "error": null
        },
        {
          "lockId": "5001",
          "lockName": "Hafnarfjörður",
          "state": "failed",
          "permissionId": null,
          "validFrom": null,
          "validTo": null,
          "lastAttemptAt": "2026-10-12T09:00:08Z",
          "firstUsedAt": null,
          "error": { "code": "-3002", "message": "gateway offline" }
        }
      ]
    },
    { "bookId": "3803", "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 booking's location, as `{ "id", "name" }`: your `propId` and its latest name. |
| `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.

## Signing

Sign every request with Entrify'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 Entrify'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` | The URL doesn't say `entrify`, or brafa has switched Entrify's access off. | Check the URL, or contact brafa. |
| `404` | `TENANT_NOT_FOUND` | A status query for a new tenant that hasn't pushed yet, or a tenant 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 |
