# Webhooks

Instead of polling the Get reservations endpoint, you can have Bob Box push a `POST` to your own URL whenever a reservation's status changes or its PINs are reissued.

## How it works

1. You configure a URL for each event you care about, in the dashboard.
2. When that event happens on one of your reservations, Bob Box sends a `POST` with a JSON body to that URL.
3. Your endpoint returns `200 OK` to acknowledge it. Anything else is retried.

## The events

Each one's exact trigger and payload is in section 5 below.

| Event | Fires when | Setting name |
| --- | --- | --- |
| **Parcel dropped** | The parcel is put into the locker | `webhook_reservation_dropoff_url` |
| **Parcel collected** | The recipient collects it | `webhook_reservation_pickup_url` |
| **Reservation returned** | The parcel is removed to go back to the sender | `webhook_reservation_return_url` |
| **Reservation cleared** | Bob Box empties the compartment | `webhook_reservation_cleared_url` |
| **Pin reissued** | Any PIN changes | `webhook_reservation_pins_reissued_url` |

**There is no `event_type` field in the body.** You know which event it is from which of your URLs received the request, so give each event a distinct URL.

Every payload shares these four fields, plus one timestamp specific to the event:

| Field | Type | Description |
| --- | --- | --- |
| `reservation_id` | integer | The reservation's ID. |
| `custom_reference` | string | Your own reference for this reservation, if you set one. |
| `custom_data` | object | Any custom data you attached to the reservation. |
| `reservation_status` | string | The reservation's status at the time of the event. |

A "manual update" — a status changed through the API rather than by locker hardware — fires the same webhook as the hardware path. Handle both identically.

## 1. Set up webhooks in the dashboard

This is the only way to generate your signing secret.

**You must be signed in as the Bob Box account owner.** An account user cannot do this. If you are a super admin, you cannot do it from the internal Accounts pages either — impersonate the account owner first.

### Step 1 — Create and save the webhook secret

1. In the left menu, click **Settings** (cog icon).
2. Open the **Webhook auth credentials** tab.
3. Click **Create webhook secret**. (If one already exists, the button says **Regenerate webhook secret**.)
4. A popup shows your new secret. Click the copy icon.
5. **Save it somewhere safe now — you cannot get it back.** If you lose it you must regenerate, and the old one stops working immediately.
6. Close the popup. From now on you only see a masked version.

This secret is the key for the `X-Webhook-Signature` HMAC-SHA256 signature on every webhook we send you.

### Step 2 — Add your webhook URLs

1. Still in **Settings**, open the **General** tab.
2. Under **Webhooks**, fill in the URLs you want. Leave a field blank to switch that event off.
3. Click **Save**. Each URL field saves independently, so one bad URL will not block the others.

Example URL format: `https://example.com/reservation-dropoff`

A blank URL means the event is simply not sent — nothing fails or errors on the reservation side.

### Step 3 — Check it works

Do a real dropoff on a test reservation. We `POST` to your "Parcel dropped off" URL about 2 seconds later. Verify the signature using the secret from Step 1.

**If webhooks stop right after you regenerate a secret,** check that your endpoint was updated — it is still checking the old one.

## 2. The webhook request

**Method:** `POST`
**Content-Type:** `application/json`

Every request carries these headers:

```http
Content-Type: application/json
X-BobGroup-Type: webhook
X-Webhook-Timestamp: 1756288800000
X-Webhook-Signature: v1=5d41402abc4b2a76b9719d911017c592...
X-Auth-Hash: c592a76b9719d911017...
```

`X-Webhook-Timestamp` is a Unix timestamp in **milliseconds**.

`X-Webhook-Signature` and `X-Auth-Hash` are only sent if you have created a webhook secret. Without one, the request arrives unsigned and you cannot verify it — so set one up.

## 3. Verifying the request came from Bob Box

### Recommended: verify `X-Webhook-Signature`

An HMAC-SHA256 signature, sent as `v1=<hex signature>`, computed over the timestamp and the raw request body:

```text
signature = hex( HMAC-SHA256( secret, timestamp + "." + raw_request_body ) )
```

**Example — Node.js verification:**

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

function verifyWebhookSignature(rawBody, timestamp, signatureHeader, secret) {
  // signatureHeader looks like "v1=<hex>"
  const receivedSignature = signatureHeader.replace(/^v1=/, "");

  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(receivedSignature, "hex"),
    Buffer.from(expectedSignature, "hex")
  );
}
```

**Use the raw, unparsed request body.** Re-serializing parsed JSON can change field order or spacing and break the comparison. This is the single most common webhook integration bug.

`X-Auth-Hash` is an older scheme kept for backwards compatibility (`SHA1(hex(MD5(body)) + secret)`). Prefer `X-Webhook-Signature` for new integrations.

## 4. Responding to webhooks

Return `200 OK` as soon as you have received and stored the webhook. Do slow processing afterwards, asynchronously.

If your endpoint does not return `200 OK`, Bob Box retries the same request **roughly once a minute, up to 9 times**. There is no exponential backoff — it is a flat one-minute gap. After that the delivery is handed back to the queue, so a small number of further attempts is possible before it stops.

**Make your endpoint idempotent.** Use `reservation_id` plus the event's timestamp field to detect a duplicate you have already handled.

## 5. The events in detail

### Parcel dropped

Sent when a parcel is put into the locker.

**Dashboard field:** Parcel dropped off
**Setting name:** `webhook_reservation_dropoff_url`

#### When it fires

The locker door closes after a `dropoff_pin` was used, and the reservation is now `waiting-for-pickup`.

It also fires when a reservation is manually updated to `waiting-for-pickup` through the API. Treat both the same way.

#### Payload

```json
{
  "reservation_id": 8931,
  "custom_reference": "ORDER-1452",
  "custom_data": {},
  "reservation_status": "waiting-for-pickup",
  "dropoff_time": "2026-08-27T09:12:00+02:00"
}
```

| Field | Type | Description |
| --- | --- | --- |
| reservation_id | integer | The reservation's ID. |
| custom_reference | string | Your own reference for this reservation, if you set one. |
| custom_data | object | Any custom data you attached to the reservation. |
| reservation_status | string | Always `waiting-for-pickup` for this event. |
| dropoff_time | string (datetime) | When the parcel was dropped off. |

#### What to do with it

- Send the recipient their `pickup_pin` and the locker address.
- On a `client-dropoff` reservation, this is your signal to dispatch a driver — the parcel is now actually in the locker.

### Parcel collected

Sent when the parcel is taken out of the locker by its recipient.

**Dashboard field:** Parcel collected
**Setting name:** `webhook_reservation_pickup_url`

#### When it fires

The locker door opens with a `pickup_pin`, and the reservation is now `complete`.

It also fires on a manual update to `complete` through the API. Treat both the same way.

#### Payload

```json
{
  "reservation_id": 8931,
  "custom_reference": "ORDER-1452",
  "custom_data": {},
  "reservation_status": "complete",
  "pickup_time": "2026-08-27T14:03:00+02:00"
}
```

| Field | Type | Description |
| --- | --- | --- |
| reservation_id | integer | The reservation's ID. |
| custom_reference | string | Your own reference for this reservation, if you set one. |
| custom_data | object | Any custom data you attached to the reservation. |
| reservation_status | string | Always `complete` for this event. |
| pickup_time | string (datetime) | When the parcel was collected. |

#### What to do with it

- Mark the delivery complete on your side and stop any collection reminders.
- On a `client-dropoff` reservation, this fires when **your driver** collects — the parcel is now on your network, not the customer's doorstep.

### Reservation returned

Sent when the parcel is taken out of the locker to go back to the sender.

**Dashboard field:** Parcel returned
**Setting name:** `webhook_reservation_return_url`

#### When it fires

The locker door opens with a `return_pin`, and the reservation is now `returned`.

It also fires on a manual update to `returned` through the API. Treat both the same way.

#### Payload

```json
{
  "reservation_id": 8931,
  "custom_reference": "ORDER-1452",
  "custom_data": {},
  "reservation_status": "returned",
  "return_time": "2026-08-27T16:40:00+02:00"
}
```

| Field | Type | Description |
| --- | --- | --- |
| reservation_id | integer | The reservation's ID. |
| custom_reference | string | Your own reference for this reservation, if you set one. |
| custom_data | object | Any custom data you attached to the reservation. |
| reservation_status | string | Always `returned` for this event. |
| return_time | string (datetime) | When the parcel was removed for return. |

#### What to do with it

- Route the parcel back to the sender on your own network.
- This is a distinct outcome from `complete` — the recipient never got the parcel. Do not close the order as delivered.

### Reservation cleared

Sent when Bob Box empties a compartment to free it up. This happens when a parcel is left in the locker long past its `intervention_time`.

**Dashboard field:** Reservation cleared
**Setting name:** `webhook_reservation_cleared_url`

#### When it fires

A compartment is cleared and the reservation's status becomes `cleared`.

It also fires on a manual update to `cleared` through the API. Treat both the same way.

A reservation only becomes eligible for clearing after its `intervention_time`, which sits after `flat_fee_end_time`. Both are on the reservation, so you can see it coming. Clearing is an operations decision, not an automatic job, so it happens some time after `intervention_time` rather than at it.

#### Payload

```json
{
  "reservation_id": 8931,
  "custom_reference": "ORDER-1452",
  "custom_data": {},
  "reservation_status": "cleared",
  "clear_time": "2026-08-27T18:00:00+02:00"
}
```

| Field | Type | Description |
| --- | --- | --- |
| reservation_id | integer | The reservation's ID. |
| custom_reference | string | Your own reference for this reservation, if you set one. |
| custom_data | object | Any custom data you attached to the reservation. |
| reservation_status | string | Always `cleared` for this event. |
| clear_time | string (datetime) | When the compartment was cleared. |

#### What to do with it

- **The parcel is no longer in the locker.** Contact Bob Box support with the `reservation_id` to find out where it went.
- Treat this as an exception, not a normal ending. If you are seeing it regularly, your collection reminders are firing too late.

### Pin reissued

Sent when a reservation's PINs change. Uncommon — it only happens when there is a problem at a locker and Bob Box has to intervene.

**Dashboard field:** Reservation pins reissued
**Setting name:** `webhook_reservation_pins_reissued_url`

#### When it fires

Any of the dropoff, pickup, or return PINs change — for example when a reservation is moved to a different compartment because the original door will not open.

#### Payload

This event carries more fields than the others, **including the live PINs**. Treat it as sensitive and keep your endpoint secure.

```json
{
  "reservation_id": 8931,
  "custom_reference": "ORDER-1452",
  "custom_data": {},
  "reservation_status": "waiting-for-pickup",
  "compartment_number": 14,
  "dropoff_pin": "482150",
  "pickup_pin": "903417",
  "return_pin": "118736",
  "time": "2026-08-27T10:15:00+02:00"
}
```

| Field | Type | Description |
| --- | --- | --- |
| reservation_id | integer | The reservation's ID. |
| custom_reference | string | Your own reference for this reservation, if you set one. |
| custom_data | object | Any custom data you attached to the reservation. |
| reservation_status | string | The reservation's status at the time of the change. |
| compartment_number | integer | The compartment the reservation now occupies. It may have moved. |
| dropoff_pin | string | The new dropoff PIN. |
| pickup_pin | string | The new pickup PIN. |
| return_pin | string | The new return PIN. |
| time | string (datetime) | When the PINs were reissued. |

#### What to do with it

- **Any PIN you have already sent a customer is now dead.** Send the new one, and say why.
- **`compartment_number` may have changed too.** If you told the customer a compartment number, correct it.
- Handle this event even though it is rare. Skipping it means a customer standing at a locker with a PIN that does not work.

## 6. Best practices

- **Secure your endpoint.** Verify the signature on every request; do not trust the body alone.
- **Respond fast.** Acknowledge, queue, then process.
- **Be resilient.** Queue and retry on your side too, so a downstream failure does not lose the event.
- **Keep an audit log.** Record every webhook received and the status you returned. It makes debugging possible.

## 7. Troubleshooting

- **Not receiving webhooks?** Confirm the URL for that event is filled in and saved under Settings → General → Webhooks. An empty URL means the event is silently skipped.
- **No signature headers?** You have not created a webhook secret yet — see Step 1.
- **Signature never matches?** You are almost certainly hashing a re-serialized body. Capture the raw bytes before your JSON parser touches them.
- **Getting the same event twice?** Make sure your endpoint returns `200 OK` reliably and quickly. A slow or failing endpoint gets retried.
