# Guide to parcelLab Webhooks: Features and Best Practices

parcelLab is a post-purchase platform for retailers. It tracks shipments across carriers, turns raw carrier scans into a common status model, and powers the order status pages and delivery notifications shoppers see. Its webhooks feed the same tracking events back into your shop systems, so your order management, customer service and analytics tools know when a parcel ships, is delayed, goes out for delivery or arrives.

This guide covers the events parcelLab sends and their payload, how to authenticate requests with HTTP Basic credentials, parcelLab's retry and deduplication behaviour, and the practices that keep a receiver reliable in production.

## What are parcelLab webhooks?

parcelLab sends one webhook per event for each consignment, meaning each tracking record in parcelLab. A split order with several trackings produces several webhooks: each tracking gets its own `onDispatch`, even when they ship together.

You don't register webhooks yourself. You give parcelLab an HTTPS URL and credentials, and parcelLab sets the webhook up. Plain HTTP is not supported.

parcelLab does not sign requests. It supports three ways to authenticate them: HTTP Basic authentication, access token headers and an OAuth 2.0 access token. This guide uses HTTP Basic, which is what Hookdeck's parcelLab source checks.

## parcelLab webhook features

| Feature | Details |
| --- | --- |
| Configuration | Provide parcelLab with an HTTPS URL and credentials. parcelLab configures the webhook |
| Scope | One webhook per event per consignment |
| Method and format | `POST`, `Content-Type: application/json` |
| Discriminator | The body's `event` field, such as `onDelivered` |
| Formats | parcelLab JSON (default) or CloudEvent |
| Authentication | HTTP Basic, access token headers or OAuth 2.0 access token. No signature |
| Deduplication | None on parcelLab's side. Use `eventid` |
| Retries | 15 retries at 30 minute intervals after an error, then the event is logged and dropped |
| Source IPs | `35.156.240.70`, `35.157.215.87`, `35.157.195.174` |

## Events

| Event | Fires when |
| --- | --- |
| `onInternalDelay` | The consignment was not shipped in time, against the `send_date` set on record creation |
| `onCreated` | A record is created through data transfer from the retailer |
| `onUpdated` | A record is updated through data transfer from the retailer. The updated fields are not included |
| `onDispatch` | The first carrier scan. Sent only once |
| `onScheduled` | The carrier announces a delivery day, and again for every change to it |
| `onDelay` | A delay or delivery issue, with an `exception_code` |
| `onOutForDelivery` | The consignment is out for delivery |
| `onFailedAttempt` | A delivery attempt failed, with `next_action` (`NewAttemptToday`, `NewAttemptNextDay` or `PickUpReady`) |
| `onPickupReady` | The parcel is ready for collection at a courier location, with pickup location fields |
| `onDelivered` | Final event: the consignment was delivered |
| `onReturn` | Final event: delivery failed and the parcel is going back to the sender, with a `return_reason` |

parcelLab does not deduplicate events, so several carrier scans of the same type send several webhooks. Expect more than one `onOutForDelivery` for one parcel.

Every event has the same top-level structure. Here is parcelLab's `onDelivered` example, with the tracking link shortened as in the docs:

```json
{
  "event": "onDelivered",
  "eventid": "abc123-def456",
  "timestamp": "2000-12-16T16:12:00.000Z",
  "references": {
    "courier": "dhl-germany",
    "tracking_number": "00340000000001",
    "orderNo": "ORD0012345",
    "client": "parcelFashion",
    "warehouse": "EU",
    "destination_country_iso3": "DEU",
    "language": "de"
  },
  "data": {
    "tracking_link": "https://www.dhl.de/...?piececode=00340000000001",
    "reroute_link": "",
    "raw_scan": "DLVRD-ACCPT-ZU | parcelLab",
    "raw_scan_location": "Munich",
    "delivery_status": "Delivered",
    "delivery_status_locale_short": "Zugestellt",
    "delivery_status_locale_long": "Die Lieferung wurde der Empfängerin oder dem Empfänger (parcelLab) zugestellt.",
    "expected_delivery_date_raw": "",
    "expected_delivery_date_formatted": "",
    "delivery_location": "Doorstep",
    "delivery_information": "parcelLab"
  },
  "reporting": {
    "lead_time_door_to_door": 2,
    "lead_time_qualified_attempt": 2,
    "lead_time_end_to_end": 3
  }
}

```

`eventid` is a unique ID for the event, made of `a-f`, `0-9` and dashes, and parcelLab says to use it for deduplication. `timestamp` is when the event happened, not when the webhook was sent. `references` identifies the consignment and your order, and the same values can optionally be appended to the URL as query parameters. `reporting` holds lead times, which are `-1` until the scan that ends them has happened. `raw_scan` is whatever the carrier sent, with no common pattern.

In the CloudEvent format, the event becomes `type`, such as `com.parcellab.tracking.event.ondelivered`, the `eventid` becomes `id`, and `references`, `data` and `reporting` move inside the CloudEvent's `data` object. Which format you receive is part of the setup parcelLab does for you.

> **Inspect parcelLab webhooks as they arrive.** Give parcelLab a [Hookdeck Inspect](https://inspect.hookdeck.com) URL during setup to see the exact headers and body it sends, with no account or setup required.

## Setting up parcelLab webhooks

Pick a username and generate a long random password, for example with `openssl rand -hex 32`, and store both on your receiver.

1. Prepare an HTTPS endpoint with a valid certificate.
2. Give parcelLab the URL and the Basic credentials, and say which format you want (parcelLab JSON or CloudEvent) and whether you want the `references` values as query parameters.
3. If you allowlist at the firewall, allow `35.156.240.70`, `35.157.215.87` and `35.157.195.174`.
4. Once parcelLab has set the webhook up, create a test consignment and watch for `onCreated`.

The credentials must arrive on the first request. parcelLab's documentation doesn't say whether it sends Basic credentials straight away or only after a `401` challenge with a `WWW-Authenticate` header. A sender that waits for a challenge will never authenticate against a receiver that doesn't issue one, so confirm with parcelLab that the `Authorization` header is sent on every request.

To put Hookdeck in front of your service, create a Hookdeck source with the **parcelLab** type and give parcelLab its URL. Enter the same **Username** and **Password** on the source. Hookdeck does not send a `401` challenge, so it needs the credentials on the first request. The source accepts `POST`, which is the method parcelLab uses. If parcelLab sends access token headers or an OAuth token instead of Basic credentials, this source won't verify them.

## Securing parcelLab webhooks

With Basic authentication, each request carries `Authorization: Basic base64(username:password)`. Decode it, split on the first colon, and compare the username and password with your stored values in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function safeEqual(a, b) {
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

function verifyBasicAuth(header, username, password) {
  if (!username || !password) return false; // never accept when unconfigured
  const match = /^basic\s+([A-Za-z0-9+/]+={0,2})\s*$/i.exec(header || '');
  if (!match) return false;
  const decoded = Buffer.from(match[1], 'base64');
  const colon = decoded.indexOf(0x3a); // split on the first colon: passwords may contain ':'
  if (colon === -1) return false;
  const userOk = safeEqual(decoded.subarray(0, colon), Buffer.from(username, 'utf8'));
  const passOk = safeEqual(decoded.subarray(colon + 1), Buffer.from(password, 'utf8'));
  return userOk && passOk; // evaluate both before deciding
}

const app = express();

app.post('/webhooks/parcellab', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verifyBasicAuth(
    req.get('authorization'),
    process.env.PARCELLAB_WEBHOOK_USERNAME,
    process.env.PARCELLAB_WEBHOOK_PASSWORD
  );
  if (!ok) return res.sendStatus(401);

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Skip event.eventid if you have seen it, then route on event.event.
  switch (event.event) {
    case 'onDispatch':
    case 'onOutForDelivery':
    case 'onDelivered':
      break;
    default:
      // Log and acknowledge anything else.
  }

  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import binascii
import hmac
import os
import re

BASIC_PATTERN = re.compile(r"^basic\s+([A-Za-z0-9+/]+={0,2})\s*$", re.IGNORECASE)

def verify_basic_auth(header, username, password) -> bool:
    if not username or not password:
        return False  # never accept when unconfigured
    match = BASIC_PATTERN.match(header or "")
    if not match:
        return False
    try:
        decoded = base64.b64decode(match.group(1), validate=True)
    except (binascii.Error, ValueError):
        return False
    received_user, sep, received_password = decoded.partition(b":")  # first colon only
    if not sep:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    user_ok = hmac.compare_digest(received_user, username.encode("utf-8"))
    password_ok = hmac.compare_digest(received_password, password.encode("utf-8"))
    return user_ok and password_ok

# verify_basic_auth(request.headers.get("authorization"),
#                   os.environ.get("PARCELLAB_WEBHOOK_USERNAME"),
#                   os.environ.get("PARCELLAB_WEBHOOK_PASSWORD"))

```

Basic credentials prove the sender knows the password. They do not cover the body and carry no timestamp of the request, so a modified or replayed request still passes. parcelLab already requires HTTPS. Use a long random password, rotate it with parcelLab if it might have leaked, and add the three source IPs at your firewall as a second layer.

> **Make parcelLab webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the Basic credentials, drops repeat scans with deduplication, and stores every tracking event until your service has processed it.

## parcelLab webhook limitations and pain points

### Credentials have to arrive without a challenge

**The Problem:** Every request fails authentication even though the credentials are right.

**Why It Happens:** Some HTTP clients only send Basic credentials after a server answers `401` with a `WWW-Authenticate` challenge. parcelLab's docs don't say which way its sender works, and a receiver that verifies the request as it arrives, as Hookdeck does, never issues that challenge.

**Workarounds:**

* Ask parcelLab to confirm the `Authorization` header is sent on the first request.
* Point the webhook at a Hookdeck Inspect URL first and check the header is there.

**How Hookdeck Can Help:** Hookdeck keeps every request, including those that fail verification, with full headers, so you can see straight away whether `Authorization` arrived.

### Repeated scans, repeated webhooks

**The Problem:** One parcel produces several `onOutForDelivery` or `onScheduled` webhooks, and a handler that sends a customer message on each one sends it several times.

**Why It Happens:** parcelLab doesn't deduplicate events. Every carrier scan of a type sends a webhook, and `onScheduled` fires on every change to the delivery date.

**Workarounds:**

* Deduplicate on `eventid` for retries of the same event.
* Decide per event type whether a repeat is news. A second `onScheduled` with a new date is, a second `onOutForDelivery` usually isn't.

**How Hookdeck Can Help:** Hookdeck [deduplication](/docs/deduplication) can drop repeated deliveries based on fields you choose, such as `eventid`, before they reach your service.

### Retries stop after about 7.5 hours

**The Problem:** An outage longer than the retry window loses tracking events, including final ones like `onDelivered`.

**Why It Happens:** After an error, parcelLab retries 15 times at 30 minute intervals, then logs an error and deletes the event from its queue.

**Workarounds:**

* Alert on errors from your endpoint well within that window.
* Reconcile open orders against parcelLab's API after an outage.

**How Hookdeck Can Help:** Hookdeck accepts and stores each event as it arrives, then delivers to your service with [retries](/docs/retries) you configure, and you can replay any event once your service is back.

### Events out of order

**The Problem:** A late retry can deliver an older status after a newer one, so an order flips back from delivered to out for delivery.

**Why It Happens:** Retries run 30 minutes apart, and each consignment's events are independent requests.

**Workarounds:**

* Compare `timestamp`, which is when the event happened, with the latest you have stored before updating.
* Treat `onDelivered` and `onReturn` as final and ignore earlier statuses after them.

## Best practices

### Key everything on `eventid` and the tracking

Use `eventid` for idempotency and `references.courier` plus `references.tracking_number` for the consignment. Map to your order through `references.orderNo`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Treat optional fields as optional

`exception_code`, `next_action`, the pickup location fields, `delivery_information` and `return_reason` only appear on their events, and some pickup details aren't available for every location. Read them with optional access.

### Use the localised strings for customers

`delivery_status_locale_short` and `delivery_status_locale_long` are already in the recipient's language from `references.language`. They're safer to show customers than `raw_scan`, which is whatever the carrier sent.

### Acknowledge fast

Return `200` once the request is authenticated and process it from a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

parcelLab sends a JSON webhook for each tracking event of each consignment, with no deduplication of carrier scans and a retry window of about 7.5 hours. Authenticate with Basic credentials that arrive on the first request, compare them in constant time, deduplicate on `eventid`, order by `timestamp`, and treat `onDelivered` and `onReturn` as final.

[Hookdeck Event Gateway](https://hookdeck.com) checks the Basic credentials, drops repeats, and holds each tracking event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle parcelLab webhooks reliably in minutes.