# Webhooks

Bob Go pushes events to a URL you own, so you do not have to poll. Subscribe
per topic, per delivery URL.

## Topics

| Topic | Fires when |
| --- | --- |
| `tracking/updated` | A tracking event is received for a shipment. |
| `fulfillment/created` | A fulfilment on an order has been created successfully. Not sent for returns. |
| `shipment_submission_status/updated` | A shipment's submission status changes. |
| `shipment_charged_amount/updated` | The amount charged for a shipment changes. |
| `shipment_charged_weight/updated` | The weight charged for a shipment changes. |
| `shipment_health_status/updated` | A shipment's health status changes. |
| `order/updated` | An existing order changes. Creating an order does not fire it. |

## Endpoints

| | |
| --- | --- |
| [`POST /v2/webhooks`](webhooks_post.md) | Subscribe, or change a subscription. |
| [`GET /v2/webhooks`](webhooks_get.md) | List your subscriptions. |
| [`DELETE /v2/webhooks`](webhooks_delete.md) | Remove subscriptions. |

## How delivery works

When the event happens, Bob Go sends a `POST` to your delivery URL with the
payload as JSON. Every delivery carries these headers:

| Header | Value |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-BobGroup-Topic` | The topic, e.g. `tracking/updated`. |
| `X-BobGroup-Type` | `webhook` |
| `bobgo-webhook-signature` | The signature, once your account has a webhook secret. See below. |

Deliveries stop while the account is inactive or closed.

**Answer with a `2xx`.** Anything else counts as a failure. Bob Go retries a
`429` or a `5xx` every 15 minutes. It does not retry any other `4xx`, or a
delivery it could not complete at all, such as a refused connection or a
timeout.

**Answer within 20 seconds.** A delivery still waiting after that is abandoned
and not retried. Acknowledge first, then do the work: queue the payload and
return, rather than processing inline.

**Failing deliveries switch the subscription off.** The first failure sets
`first_failed_time` on the subscription, and a delivery that succeeds clears it.
If deliveries are still failing three days later, the next failure sets the
subscription `inactive` and the account gets an alert in the dashboard and by
email. Set it back to
`active` with [`POST /v2/webhooks`](webhooks_post.md) once your endpoint is
fixed.

**Expect repeats.** A webhook may arrive more than once. Key your processing on
something stable, either `tracking_reference` plus the checkpoint time or the
fulfilment `id`, so a repeat is a no-op rather than a duplicate shipment in
your system.

Keep a log of what you received and how you answered. When something is missing
weeks later, that log is the only way to tell a webhook that never arrived from
one that arrived and was dropped.

## Verifying the signature

Generate a webhook secret in the dashboard under **Settings → Webhook
subscriptions**. From then on every delivery carries `bobgo-webhook-signature`:
the HMAC-SHA256 of the raw request body, keyed with the secret, and
base64-encoded.

To check it, compute the same HMAC over the body bytes exactly as you received
them, before any JSON parsing, using the secret string as the key as it is (do
not hex-decode it). Compare the base64 result with the header, and reject a
delivery that does not match.

Regenerating the secret takes effect immediately, with no overlap, so deploy the
new secret as soon as you create it. Deleting it stops the signature being
sent.

Even with a signature, treat the URL as public, and do not act on a payload
without confirming it against your own records.

## Payloads

### `tracking/updated`

Sent whenever a tracking event is received. The payload is the tracking object
that [`GET /v2/tracking`](../10_Tracking%20events/tracking.md) returns, as a
single object rather than an array, plus a `shipment` object with the contact
details. It carries the **full** history for the shipment, not just the new
checkpoint, so you can replace your copy rather than merging.

```json
{
  "shipment_tracking_reference": "UAEG4Z55",
  "status": "delivered",
  "status_friendly": "Delivered",
  "checkpoints": [{ "...": "..." }],
  "shipment_movement_events": { "...": "..." },
  "shipment": {
    "collection_contact_name": "John",
    "collection_contact_surname": "Doe",
    "collection_contact_email": "john@doe.com",
    "collection_contact_mobile_number": "+27123456789",
    "delivery_contact_name": "Jane",
    "delivery_contact_surname": "Doe",
    "delivery_contact_email": "jane@doe.com",
    "delivery_contact_mobile_number": "+27123456789",
    "tracking_url": "https://..."
  }
}
```

The fields are described on [Get tracking events](../10_Tracking%20events/tracking.md).

### `fulfillment/created`

Sent once a fulfilment on an order has been created successfully: a shipment
or a buyer collection. An order can produce several. A return is a fulfilment
too, but it does not send this webhook. Follow a return through its shipment
instead, with `shipment_submission_status/updated` and `tracking/updated`.

```json
{
  "id": 6432,
  "account_id": 2,
  "channel_ref_id": "",
  "order_id": 22837,
  "channel_order_number": "22837",
  "type": "original",
  "method": "shipment",
  "method_id": 14273,
  "method_reference": "UADPWTTF",
  "method_status": "pending-collection",
  "status": "success",
  "failed_reason": "",
  "time_created": "2022-10-20T19:42:00.642739Z",
  "time_modified": "2022-10-20T19:42:02.397912Z",
  "order_items": [
    {
      "id": 31119,
      "description": "Product 1",
      "fulfilled_qty": 1,
      "qty": 1,
      "sku": "",
      "total_price": 599,
      "total_weight_kg": 0.1,
      "unit_price": 599,
      "unit_weight_kg": 0.1,
      "vendor": "GoPugMe",
      "channel_ref_id": 12116233191527
    }
  ]
}
```

`type` is `original`, or `replacement` when the fulfilment replaces an earlier
one. `qty` and `fulfilled_qty` on each line are the order line's totals so far,
not the quantity in this fulfilment.

### `order/updated`

Sent when an existing order changes: an update, a status or payment change, a
tag added or removed, archiving or unarchiving. The payload is the order as it
now stands.

```json
{
  "id": 22837,
  "account_id": 1234,
  "channel_order_number": "ABC123",
  "channel_ref_id": "",
  "currency": "ZAR",
  "customer_name": "John",
  "customer_surname": "Doe",
  "customer_email": "john@doe.com",
  "customer_phone": "0123456789",
  "date_placed_on_channel": "2026-09-05T08:30:00Z",
  "delivery_address": { "...": "..." },
  "fulfillment_status": "unfulfilled",
  "order_items": [{ "...": "..." }],
  "note": "",
  "payment_status": "paid",
  "status": "processing",
  "tags": [{ "name": "VIP", "color": "#ffcc00" }],
  "total_discount": 0,
  "total_price": 1500,
  "total_qty": 15,
  "total_tax": 195.65,
  "total_weight_kg": 1.5,
  "total_fulfilled_qty": 0,
  "buyer_selected_shipping_cost": 49.99,
  "buyer_selected_shipping_method": "Standard Shipping",
  "pickup_point_location_id": null,
  "pickup_point_provider_slug": null,
  "time_created": "2026-09-05T08:30:12Z",
  "time_modified": "2026-09-05T09:12:40Z"
}
```

`order_items` has the same fields as on `fulfillment/created`. The status and
payment values are listed on [Get orders](../6_Orders/order_list.md).

### Shipment topics

`shipment_submission_status/updated`, `shipment_charged_amount/updated`,
`shipment_charged_weight/updated` and `shipment_health_status/updated` all send
**the whole shipment**, in the same shape. Which topic fired tells you what
changed; the payload tells you what the shipment now looks like. The shape is
close to, but not the same as, a shipment from `GET /v2/shipments`.

```json
{
  "id": 644,
  "account_id": 68,
  "tracking_reference": "UASDX44J",
  "custom_tracking_reference": "631",
  "channel_order_number": "12345",
  "provider_slug": "demo",
  "provider_shipment_id": "123456789",
  "service_level_code": "ECO",
  "service_level_cut_off_time": "16:30:00",
  "status": "pending-collection",
  "submission_status": "success",
  "health_status": "healthy",
  "health_status_reason": "",
  "failed_reason": null,
  "rate": 117.3,
  "charged_amount": 117.3,
  "charged_weight_kg": 1,
  "submitted_weight_kg": 1,
  "submitted_vol_weight_kg": 0.2,
  "declared_value": 0,
  "parcel_count": 1,
  "parcels": [
    {
      "description": "Watch box",
      "submitted_length_cm": 1,
      "submitted_width_cm": 1,
      "submitted_height_cm": 1,
      "submitted_weight_kg": 1,
      "actual_length_cm": null,
      "actual_width_cm": null,
      "actual_height_cm": null,
      "actual_weight_kg": null,
      "custom_parcel_reference": null,
      "tracking_reference": "UASDX44J/1"
    }
  ],
  "collection_address": {
    "id": 410,
    "entered_address": "22 Ealing Crescent, Bryanston, Johannesburg, 2191, Gauteng, South Africa",
    "type": "business",
    "company": "Bob Go",
    "street_address": "22 Ealing Crescent",
    "local_area": "Bryanston",
    "city": "Johannesburg",
    "zone": "GP",
    "code": "2191",
    "country": "South Africa",
    "lat": -26.044926,
    "lng": 28.024245
  },
  "delivery_address": { "...": "..." },
  "collection_contact_name": "John",
  "collection_contact_surname": "Doe",
  "collection_contact_email": "john@doe.com",
  "collection_contact_mobile_number": "+27123456789",
  "delivery_contact_name": "Jane",
  "delivery_contact_surname": "Doe",
  "delivery_contact_email": "jane@doe.com",
  "delivery_contact_mobile_number": "+27123456789",
  "collection_after": "09:00:00",
  "collection_before": "15:00:00",
  "collection_min_date": "2022-10-13T11:22:17.521043Z",
  "collection_reference": "UASDX44J",
  "collected_date": null,
  "delivered_date": null,
  "instructions_collection": "",
  "instructions_delivery": "",
  "meta": {
    "collection_reference": "UASDX44J",
    "estimated_collection_date": "2022-10-13T10:52:18.190469Z",
    "estimated_delivery_date": "2022-10-18T06:00:00Z"
  },
  "manifest_id": 0,
  "order_fulfillment": null,
  "tags": null,
  "tracking_expiry_date": null,
  "tracking_last_event_time": null,
  "tracking_url": "https://...",
  "time_created": "2022-10-13T10:52:17.533949Z",
  "time_modified": "2022-10-13T10:52:18.72622Z"
}
```

What each topic is for:

- **`shipment_submission_status/updated`**: the shipment reached, or failed to
  reach, the courier. Submission time varies, so this is the reliable signal
  that a booking is real. Read `submission_status` and `failed_reason`.
- **`shipment_charged_amount/updated`**: the courier re-priced the shipment
  after measuring it. `charged_amount` is what you actually pay, and it can
  differ from what you quoted the buyer.
- **`shipment_charged_weight/updated`**: the courier's measured weight
  changed. Usually arrives with, or just before, a charged-amount change.
- **`shipment_health_status/updated`**: Bob Go's own assessment of whether the
  shipment is moving as it should, against its collection and delivery
  timelines. Read `health_status` and `health_status_reason` to find shipments
  that are stalled, at risk, or have hit a collection or delivery problem,
  without waiting for a customer to complain.
