DHL & Hermes Parcel Tracking (Germany)
Pricing
from $10.00 / 1,000 tracked parcels
DHL & Hermes Parcel Tracking (Germany)
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.
Pricing
from $10.00 / 1,000 tracked parcels
Rating
0.0
(0)
Developer
Nerdrx
Maintained by CommunityActor stats
0
Bookmarked
3
Total users
2
Monthly active users
2 days ago
Last modified
Categories
Share
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 theexperimentalCarriersinput. 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.mdfor 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, plusunsupported_carrierfor 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
errormessage 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
- Paste your tracking numbers into Tracking numbers (one per line). Spaces and dashes are fine — they are stripped automatically.
- Leave Carrier on
autofor mixed lists, or pick a carrier when the whole list is from one carrier. - Pick the Language for the carrier's original status texts (
deoren). Thestatusenum is language independent, so downstream logic never has to parse German. - Leave Proxy configuration on the default (Apify RESIDENTIAL, country
DE) — DHL needs it. - Optionally set Maximum items as a cost cap, then run.
- Read the results from the dataset (
Overviewview) 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. |
{"trackingNumbers": ["00340434161094015902", "12345678901234"],"carrier": "auto","language": "de","maxItems": 100,"experimentalCarriers": false,"proxyConfiguration": {"useApifyProxy": true,"apifyProxyGroups": ["RESIDENTIAL"],"apifyProxyCountry": "DE"}}
Output
{"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:
{"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.