# DHL & Hermes Parcel Tracking (Germany) (`nerdrx/de-parcel-tracking`) Actor

Track many DHL Paket / Deutsche Post and Hermes parcels in one run and get a unified status timeline (delivered, out for delivery, exception…). Carrier auto-detection, residential proxy built in. DPD/GLS/UPS experimental. Pay per tracked parcel.

- **URL**: https://apify.com/nerdrx/de-parcel-tracking.md
- **Developed by:** [Nerdrx](https://apify.com/nerdrx) (community)
- **Categories:**
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $10.00 / 1,000 tracked parcels

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## DHL & Hermes Parcel Tracking (Germany)

### What is DHL & Hermes Parcel Tracking?

This actor takes a list of German parcel tracking numbers, figures out which carrier each
one belongs to, queries that carrier's public tracking endpoint and returns **one unified
status timeline per parcel** — the same fields for DHL, Deutsche Post and Hermes.

It is built for e-commerce operations teams, customer-service automations ("where is my
order?" bots) and AI agents that need a machine-readable delivery status instead of a
carrier website.

> **Supported today: DHL / Deutsche Post and Hermes.** DPD, GLS and UPS are shipped as
> **experimental and currently failing** — DPD moved tracking behind a postal-code privacy
> gate, GLS moved behind Cloudflare, and UPS bot-protects its endpoint. Their tracking
> numbers are reported with `status: "unsupported_carrier"` and are never charged unless
> you switch on the `experimentalCarriers` input. See the carrier table below.

> **This actor uses unofficial, public tracking endpoints** — the same JSON endpoints the
> carriers' own tracking web pages call from your browser. There is no API key and no
> contract with the carriers. The endpoints are undocumented and can change or start
> blocking without notice. See "FAQ & support" and `NOTES.md` for the details and the
> current verification status of each carrier.

### What can it do?

- Track **many parcels in one run** — one dataset row per tracking number.
- **Auto-detect the carrier** from the number format, or force one carrier for the whole run.
- Fall back to the next plausible carrier when a number format is ambiguous
  (14 digits can be Hermes *or* DPD; 12 digits can be DHL *or* GLS).
- Normalize German/English status wording into a **stable status enum**
  (`pre_transit`, `in_transit`, `out_for_delivery`, `delivered`, `exception`, `unknown`,
  plus `unsupported_carrier` for disabled carriers).
- Convert all carrier timestamps (German local time, usually without a timezone) into
  **real UTC ISO 8601 strings**, DST-correct.
- Return the **full event timeline**, the estimated delivery window, the delivery time and
  a recipient hint (neighbour / parcel shop / signature) when the carrier exposes it.
- Keep going when one parcel or one carrier fails — failures are written to the dataset
  with an `error` message and are **not charged**.

### What data does it extract?

| Field | Type | Description |
| --- | --- | --- |
| `trackingNumber` | string | The normalized tracking number (spaces/dashes removed, upper-cased). |
| `carrier` | string | `dhl`, `hermes`, `dpd`, `gls`, `ups` or `unknown`. |
| `status` | string | Unified enum: `pre_transit`, `in_transit`, `out_for_delivery`, `delivered`, `exception`, `unknown`, `unsupported_carrier`. |
| `statusText` | string | null | The carrier's own status wording, in the requested language. |
| `lastEventAt` | string | null | ISO 8601 UTC timestamp of the newest event. |
| `estimatedDelivery` | string | null | ISO 8601 UTC estimated delivery / delivery window start, when the carrier publishes one. |
| `deliveredAt` | string | null | ISO 8601 UTC delivery timestamp (only when delivered). |
| `recipientHint` | string | null | Who/where it was handed over: neighbour name, parcel shop, signature, destination city. |
| `events` | array | Full timeline, oldest → newest. Each item: `{ at, status, description, location }`. |
| `trackingUrl` | string | null | Human-readable carrier tracking page for this number. |
| `sourceUrl` | string | null | The exact endpoint the data came from. |
| `scrapedAt` | string | ISO 8601 UTC timestamp of the run. |
| `error` | string | null | `null` on success; otherwise why this parcel could not be tracked. |

#### Carriers, number formats and endpoints

| Carrier (`carrier` value) | Status | Typical number formats | Endpoint used | Reliability caveats |
| --- | --- | --- | --- | --- |
| DHL Paket / Deutsche Post (`dhl`) | ✅ **supported** (needs residential proxy) | 20 digits starting `003…` (GS1/NVE), 12 or 16 digits, `JJD…` + 15–20 digits, UPU S10 `RR123456785DE` | `GET https://www.dhl.de/int-verfolgen/data/search?piececode=<nr>&language=de&inputSearch=true&noRedirect=true&cid=app` | **Needs cookies:** a "cold" client gets an HTML consent/bot page instead of JSON, so the actor always loads the public tracking page first (same cookie jar, browser headers) and retries once if it is still blocked. From Apify datacenter IPs it stays blocked even after the warm-up, so **the Apify RESIDENTIAL proxy (country `DE`) is required and is the input default** — verified working with it on 2026-09-02. Unknown numbers come back as a 200 stub with `sendungNichtGefunden.keineDatenVerfuegbar`, which is reported as "no data", not as a failure. |
| Hermes Germany (`hermes`) | ✅ **supported** | 14 digits (also `H…`, 12/16/20-digit label variants) | `GET https://api.my-deliveries.de/tnt/parcelservice/parceldetails/<nr>` (fallback: `…/tnt/v2/shipments/search/<nr>`) | Answers `404` for numbers that exist but have not been scanned yet. Both response shapes (`parcelHistory` and `parcelProgress`) are supported. |
| DPD Germany (`dpd`) | ⚠️ **experimental — currently failing** | 14 digits (often leading `0`), 28-digit long label numbers | `GET https://tracking.dpd.de/rest/plc/de_DE/<nr>` (`en_DE` for English) | **Broken as of 2026-09-02.** The endpoint now hangs up the socket (also via proxy) — DPD redirects tracking to a postal-code privacy gate (`my.dpd.de/myparcels/dataprotection-tracking.aspx`), which needs the recipient PLZ, so the old REST endpoint is effectively dead. Kept behind `experimentalCarriers` in case it returns. |
| GLS Germany (`gls`) | ⚠️ **experimental — currently failing** | 11 or 12 digits, `ZB…` | `GET https://gls-group.eu/app/service/open/rest/DE/de/rstt001?match=<nr>` | **Broken as of 2026-09-02.** `gls-group.eu` now redirects to `www.gls-pakete.de/reach-sendungsverfolgung?match=<nr>` behind Cloudflare, so the old open REST endpoint is unverified and likely dead. Kept behind `experimentalCarriers`. |
| UPS Germany (`ups`) | ⚠️ **experimental — currently failing** | `1Z` + 16 alphanumeric characters | `POST https://www.ups.com/track/api/Track/GetStatus?loc=de_DE` with an `X-XSRF-TOKEN` taken from the cookie set by the tracking page | **Never verified working.** UPS bot-protects this endpoint aggressively and usually returns an HTML challenge instead of JSON. Kept behind `experimentalCarriers`; use the official UPS API for production UPS volume. |

Carrier auto-detection is a format heuristic, not a lookup. When two carriers match the
same format, the actor queries the most likely one first and falls back to the next.
Set the `carrier` input explicitly when you already know the carrier — it is faster and
avoids the extra request.

### How to use

1. Paste your tracking numbers into **Tracking numbers** (one per line). Spaces and dashes
   are fine — they are stripped automatically.
2. Leave **Carrier** on `auto` for mixed lists, or pick a carrier when the whole list is
   from one carrier.
3. Pick the **Language** for the carrier's original status texts (`de` or `en`). The
   `status` enum is language independent, so downstream logic never has to parse German.
4. Leave **Proxy configuration** on the default (Apify RESIDENTIAL, country `DE`) — DHL
   needs it.
5. Optionally set **Maximum items** as a cost cap, then run.
6. Read the results from the dataset (`Overview` view) or via the API:
   `https://api.apify.com/v2/datasets/<DATASET_ID>/items?clean=true&format=json`.

To keep an order dashboard in sync, schedule the actor (for example hourly) with the open
tracking numbers and upsert on `trackingNumber`; stop sending a number once its `status`
is `delivered`.

**Proxy — keep the default.** `proxyConfiguration` defaults to the Apify **RESIDENTIAL**
proxy, country `DE`, because **DHL requires it**: from datacenter IPs dhl.de serves a
bot/consent HTML page instead of JSON even after the cookie warm-up (verified on the
platform). With the residential proxy DHL returns JSON normally. Hermes works either way.
If you turn the proxy off, expect DHL rows with `blocked (use a residential proxy)` in
`error`.

### How much does it cost?

This actor is **pay per event**:

| Event | When it fires | Planned price |
| --- | --- | --- |
| `parcel-tracked` | A tracking number returned a usable result (status and/or events). Charged once per tracking number, after the row is stored. | **$0.01** |
| `parcel-failed` | The carrier returned nothing usable (unknown number, blocked request, endpoint change), or the number belongs to a carrier that is not supported yet (`status: "unsupported_carrier"`). The row is still written with an `error` so you can see what happened. | **free — not charged** |

So 1,000 successfully tracked parcels cost about **$10**, plus Apify platform compute
(the actor is HTTP-only and runs at 256 MB, so compute is a rounding error). Duplicate
tracking numbers inside one run are collapsed and charged once. The run stops gracefully
as soon as your pay-per-event charge limit is reached.

### Input

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `trackingNumbers` | array of strings | — (**required**) | Tracking numbers, one per line. |
| `carrier` | enum | `auto` | `auto`, `dhl`, `hermes`, and (only with `experimentalCarriers`) `dpd`, `gls`, `ups`. |
| `language` | enum | `de` | Language of the carrier status texts: `de` or `en`. |
| `maxItems` | integer | `100` | Maximum tracking numbers processed per run. |
| `experimentalCarriers` | boolean | `false` | Also attempt DPD, GLS and UPS. They currently fail; leave off. |
| `proxyConfiguration` | object | Apify RESIDENTIAL, country `DE` | **Keep the default** — DHL needs it. |

```json
{
  "trackingNumbers": ["00340434161094015902", "12345678901234"],
  "carrier": "auto",
  "language": "de",
  "maxItems": 100,
  "experimentalCarriers": false,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": ["RESIDENTIAL"],
    "apifyProxyCountry": "DE"
  }
}
```

### Output

```json
{
  "trackingNumber": "00340434161094015902",
  "carrier": "dhl",
  "status": "delivered",
  "statusText": "Die Sendung wurde erfolgreich zugestellt.",
  "lastEventAt": "2026-08-31T09:42:00.000Z",
  "estimatedDelivery": "2026-08-31T08:00:00.000Z",
  "deliveredAt": "2026-08-31T09:42:00.000Z",
  "recipientHint": "Nachbar Mustermann",
  "events": [
    {
      "at": "2026-08-28T15:03:00.000Z",
      "status": "pre_transit",
      "description": "Die Auftragsdaten zu dieser Sendung wurden vom Absender elektronisch an DHL übermittelt.",
      "location": null
    },
    {
      "at": "2026-08-29T02:12:00.000Z",
      "status": "in_transit",
      "description": "Die Sendung wurde im Start-Paketzentrum bearbeitet.",
      "location": "Neuwied"
    },
    {
      "at": "2026-08-31T05:20:00.000Z",
      "status": "out_for_delivery",
      "description": "Die Sendung wurde für die Zustellung verladen.",
      "location": "Hamburg"
    },
    {
      "at": "2026-08-31T09:42:00.000Z",
      "status": "delivered",
      "description": "Die Sendung wurde erfolgreich zugestellt.",
      "location": "Hamburg"
    }
  ],
  "trackingUrl": "https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=00340434161094015902&language=de",
  "sourceUrl": "https://www.dhl.de/int-verfolgen/data/search?piececode=00340434161094015902&language=de&inputSearch=true&noRedirect=true&cid=app",
  "scrapedAt": "2026-09-02T08:00:00.000Z",
  "error": null
}
```

A failed parcel has the same shape with `status: "unknown"`, `events: []` and a filled
`error`, for example:

```json
{
  "trackingNumber": "12345678901234",
  "carrier": "dpd",
  "status": "unknown",
  "events": [],
  "error": "dpd: DPD returned no parcel life cycle data for this tracking number | hermes: Hermes: no shipment found (HTTP 404)",
  "scrapedAt": "2026-09-02T08:00:00.000Z"
}
```

### FAQ & support

**Is this an official carrier API?**
No. It calls the same undocumented public JSON endpoints the carriers' own tracking pages
use in your browser. No API key or carrier contract is needed, but the endpoints can change
their URL, headers or response shape at any time. `NOTES.md` lists every assumption and the
`curl` command to re-verify it.

**Why is my DPD / GLS / UPS parcel `unsupported_carrier`?**
Those three carriers closed their public tracking endpoints (DPD now demands the recipient
postal code on `my.dpd.de`, GLS moved behind Cloudflare on `gls-pakete.de`, UPS serves a bot
challenge). Rather than pretend, the actor reports those numbers with
`status: "unsupported_carrier"`, `error: "carrier not supported yet"` and **charges
nothing**. Set `experimentalCarriers: true` to attempt them anyway — expect failures.

**Why did a parcel come back with an `error`?**
The usual reasons: the number is not (yet) in the carrier's system, the number belongs to a
different carrier than detected, the shipment is too old, or the endpoint answered
`403`/`429` (for DHL: run with the residential proxy). Failed parcels are never charged.

**The carrier was detected wrong.**
Number formats overlap between German carriers, so detection is a heuristic. Set the
`carrier` input to the correct carrier and re-run — that also skips the fallback request.

**Why are the timestamps different from the carrier website?**
Carriers publish German local time without a timezone. This actor converts them to UTC ISO
8601 (DST-aware), so `2026-08-31 11:42` in Berlin becomes `2026-08-31T09:42:00.000Z`.

**Can I get the recipient's name / signature?**
Only what the carrier itself shows publicly — a neighbour name, a parcel-shop name or a
surname on a signature line. This actor never returns personal data the carrier does not
publish for the tracking number.

**Does it work for international parcels?**
Partly. DHL handles `JJD…` and UPU (`RR…DE`) numbers and Hermes covers its German network.
For parcels handled entirely outside Germany, use the destination carrier's own tracker.

**Support:** open an issue on the actor page. Include the tracking number's *format*
(not necessarily the number), the carrier and the run ID.

# Actor input Schema

## `trackingNumbers` (type: `array`):

One tracking number per line. The carrier is detected from the number format unless you set the 'Carrier' field below. Whitespace and dashes are stripped automatically.

## `carrier` (type: `string`):

Force a carrier for every tracking number. 'auto' detects the carrier from the number format (recommended when you mix carriers in one run). DPD, GLS and UPS also require 'Try experimental carriers' to be enabled.

## `experimentalCarriers` (type: `boolean`):

Off by default. DPD moved tracking behind a postal-code privacy gate, GLS moved behind Cloudflare and UPS bot-protects its endpoint, so these three currently fail. When off, their tracking numbers are reported with status 'unsupported\_carrier' and are never charged. Turn on only to test whether they came back.

## `language` (type: `string`):

Language for the carrier's original status texts (statusText, event descriptions). The normalized 'status' enum is language independent.

## `maxItems` (type: `integer`):

Maximum number of tracking numbers to process in this run. Use it as a safety cap on cost.

## `proxyConfiguration` (type: `object`):

REQUIRED FOR DHL. dhl.de serves a bot/consent page instead of JSON to datacenter IPs, so keep the default Apify RESIDENTIAL proxy with country DE. Hermes also works without a proxy.

## Actor input object example

```json
{
  "trackingNumbers": [
    "00340434161094015902",
    "12345678901234"
  ],
  "carrier": "auto",
  "experimentalCarriers": false,
  "language": "de",
  "maxItems": 100,
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "DE"
  }
}
```

# Actor output Schema

## `results` (type: `string`):

No description

## `resultsCsv` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "trackingNumbers": [
        "00340434161094015902",
        "12345678901234"
    ],
    "proxyConfiguration": {
        "useApifyProxy": true,
        "apifyProxyGroups": [
            "RESIDENTIAL"
        ],
        "apifyProxyCountry": "DE"
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("nerdrx/de-parcel-tracking").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "trackingNumbers": [
        "00340434161094015902",
        "12345678901234",
    ],
    "proxyConfiguration": {
        "useApifyProxy": True,
        "apifyProxyGroups": ["RESIDENTIAL"],
        "apifyProxyCountry": "DE",
    },
}

# Run the Actor and wait for it to finish
run = client.actor("nerdrx/de-parcel-tracking").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "trackingNumbers": [
    "00340434161094015902",
    "12345678901234"
  ],
  "proxyConfiguration": {
    "useApifyProxy": true,
    "apifyProxyGroups": [
      "RESIDENTIAL"
    ],
    "apifyProxyCountry": "DE"
  }
}' |
apify call nerdrx/de-parcel-tracking --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,nerdrx/de-parcel-tracking"
        }
    }
}

```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/suQkisaTeGEEMuTwW/builds/nLWo5zNxcL9oMDXVP/openapi.json
