Returns and Failed Deliveries
Failed Deliveries and Returns
Sometimes a parcel cannot be delivered, the receiver refuses it, nobody is available, the address turns out to be wrong, or the order is cancelled while the courier is already en route. When that happens the parcel is already in the courier's hands and has to go somewhere.
Gophr handles this by creating a return delivery: a second, linked delivery that takes the parcel back to the original pickup address. This page explains when that happens, what the return delivery looks like, and how your integration is told about it.
The key concept
A return is not a status on the original delivery. It is a new delivery in its own right, linked to the one that failed.
This means:
- The original delivery reaches a terminal status (
CANCELED) and never moves again. - A new delivery is created with
leg_typeset toRETURN. Its pickup address is the failed delivery's dropoff address, and its dropoff address is the original pickup address. - The return delivery has its own
delivery_id, its own status lifecycle, its own ETAs, and its own price.
When a return is created
A return delivery is created when a delivery is cancelled after the parcel has been picked up (that is, at or after EN_ROUTE) for a reason that means the goods still exist and need to go back.
The reasons that trigger a return are:
| Reason | Meaning |
|---|---|
REFUSED | The receiver refused the parcel |
PERSON_NOT_AVAILABLE | Nobody was available to take the parcel |
ADDRESS_NOT_FOUND | The dropoff address could not be found |
CHALLENGE_25 | The receiver failed an age verification check |
DAMAGED | The item was damaged |
ORDER_CANCELLED | The order was cancelled after collection |
Returns are not created when:
- The delivery is cancelled before the parcel is picked up — there is nothing to return.
- The delivery is cancelled for a reason outside the list above.
- Returns are disabled on your account.
Returns are automatic. You never need to book the return leg yourself, and you should not create your own replacement delivery in response to a failed one — you would be charged for a collection that Gophr has already scheduled.
What the return delivery looks like
| Property | Value on the return delivery |
|---|---|
leg_type | RETURN |
delivery_id | New — different from the original delivery |
job_id | New — the return is its own job |
external_id | Copied from the original delivery |
| Pickup address | The failed delivery's dropoff address |
| Dropoff address | The original delivery's pickup address |
| Courier | The same courier who is already holding the parcel |
| Starting status | EN_ROUTE — the parcel is already collected |
| Price | Calculated and charged as a separate delivery |
Two consequences worth planning for:
external_idis not unique across deliveries. The original and its return share it. Always key your records ondelivery_id, and useleg_typeto tell the two apart.- The return starts mid-lifecycle. You will not receive
ACCEPTED_BY_COURIERorAT_PICKUPfor it. The first status webhook you receive for a return delivery isEN_ROUTE, followed by the normalAT_DELIVERY→TAKING_SIGNATURE→DELIVEREDprogression as the parcel is handed back.
A return delivery can itself be cancelled, in exactly the same way as any other delivery.
How you find out about a return
There are three channels. Which are available to you depends on your account configuration — talk to your account manager if you need one enabled.
1. Status webhooks for the return delivery (default)
The return delivery sends Status Update Webhooks just like any other delivery. Its leg_type is RETURN:
{
"delivery_id": "0dd41eb9-564d-4b5d-8acc-05b4cd8e0ae2",
"external_id": "gophr-64a53d967f54d",
"pickup_eta": "2023-07-03T10:57:16+01:00",
"delivery_eta": "2023-07-03T11:09:28+01:00",
"courier_location_lat": "51.50700000",
"courier_location_lng": "-0.10500000",
"status": "EN_ROUTE",
"leg_type": "RETURN",
"price_net": {
"amount": 11.91,
"currency": "GBP"
},
"price_gross": {
"amount": 14.29,
"currency": "GBP"
},
"meta_data": [
],
"courier_name": "dCourier",
"courier_notes": null,
"courier_mobile_number": "+447777777777",
"cancellation_comment": null,
"proof_of_pickup": [],
"proof_of_delivery": [],
"events": [
{
"timestamp": 1688547447,
"status": "EN_ROUTE"
}
]
}This is the simplest thing to integrate against: a return is just another delivery whose leg_type tells you what it is.
2. The linked_delivery block on the original delivery's webhook
linked_delivery block on the original delivery's webhookThe status webhook for the original delivery carries the return delivery nested under linked_delivery. This lets you see the failure and the return in a single message.
The field is null when no return exists.
linked_delivery reference
linked_delivery referencePresent on the status webhook of the original delivery. null when the delivery has no return.
| Field | Type | Description |
|---|---|---|
job_id | string | Job identifier of the return |
delivery_id | string | Delivery identifier of the return — use this as your key |
external_id | string | Copied from the original delivery |
status | string | Current status of the return delivery |
leg_type | string | RETURN |
earliest_pickup_time | ISO 8601 | Earliest the return can be collected from the failed dropoff address |
pickup_deadline | ISO 8601 | Latest the return should be collected |
earliest_dropoff_time | ISO 8601 | Earliest the return can be handed back |
dropoff_deadline | ISO 8601 | Latest the return should be handed back |
pickup_eta | ISO 8601 | Live estimate for collection |
delivery_eta | ISO 8601 | Live estimate for handing the parcel back |
courier_name | string | Courier carrying the return |
courier_mobile_number | string | Courier contact number |
courier_notes | string | Notes left by the courier |
courier_location_lat | number | Courier's last known latitude |
courier_location_lng | number | Courier's last known longitude |
price_net | object | { "amount": 8.50, "currency": "GBP" } |
price_gross | object | { "amount": 10.20, "currency": "GBP" } |
cancellation_comment | string | Set only if the return itself is cancelled |
proof_of_pickup | array | Proof captured at the failed delivery attempt, which evidences the parcel going back onto the vehicle |
proof_of_delivery | array | Proof captured when the parcel is handed back |
events | array | Status history |
meta_data | object | Reserved for future use |
Time fields are ISO 8601 with offset, for example 2026-07-30T15:42:00+01:00. Any of them may be null if the corresponding time has not been set.
For the return delivery's own status history, prefer the status webhooks it sends directly.
Example
{
"job_id": "b81d20f4c7",
"delivery_id": "3e77a5091d",
"external_id": "ORDER-10432",
"status": "CANCELED",
"leg_type": null,
"courier_name": "Alex",
"price_net": { "amount": 12.00, "currency": "GBP" },
"price_gross": { "amount": 14.40, "currency": "GBP" },
"proof_of_delivery": [],
"linked_delivery": {
"job_id": "0f4c1b2e9a",
"delivery_id": "7d3a91cc42",
"external_id": "ORDER-10432",
"status": "EN_ROUTE",
"leg_type": "RETURN",
"earliest_pickup_time": "2026-07-30T15:42:00+01:00",
"pickup_deadline": "2026-07-30T16:12:00+01:00",
"earliest_dropoff_time": "2026-07-30T15:42:00+01:00",
"dropoff_deadline": "2026-07-30T18:00:00+01:00",
"pickup_eta": "2026-07-30T15:45:00+01:00",
"delivery_eta": "2026-07-30T16:20:00+01:00",
"courier_name": "Alex",
"courier_mobile_number": "+447700900123",
"courier_location_lat": 51.5142,
"courier_location_lng": -0.0931,
"price_net": { "amount": 8.50, "currency": "GBP" },
"price_gross": { "amount": 10.20, "currency": "GBP" },
"cancellation_comment": null,
"proof_of_pickup": [],
"proof_of_delivery": [],
"events": [],
"meta_data": {}
}
}A worked example
An order is collected from a warehouse and the receiver refuses it at the door.
| # | What happens | What you receive |
|---|---|---|
| 1 | Courier collects the parcel | AT_PICKUP, then EN_ROUTE for delivery 3e77a5091d |
| 2 | Courier arrives, receiver refuses the parcel | AT_DELIVERY for 3e77a5091d |
| 3 | Delivery is cancelled with reason REFUSED; Gophr creates the return | CANCELED for 3e77a5091d, carrying linked_delivery for the new delivery 7d3a91cc42 |
| 4 | Courier sets off back to the warehouse | EN_ROUTE for 7d3a91cc42, leg_type: "RETURN" |
| 5 | Courier arrives at the warehouse | AT_DELIVERY for 7d3a91cc42 |
| 6 | Parcel is handed back and signed for | DELIVERED for 7d3a91cc42 — the goods are back with the sender |
The order is only fully resolved at step 6. Between steps 3 and 6 the parcel is in transit back to you.
Integration checklist
- Key your delivery records on
delivery_id, never onexternal_idalone - the original and its return share anexternal_id. - Read
leg_typeon every status webhook.RETURNmeans these updates describe goods coming back to you, not going out. - Do not treat
CANCELEDas the end of the story. Check for a return before writing the order off, refunding, or restocking. - Expect a return delivery's first status to be
EN_ROUTE, notACCEPTED_BY_COURIER. - Only mark stock as returned when the return delivery reaches
DELIVERED. - Never book your own collection in response to a failed delivery - the return is created automatically.
- Handle
linked_delivery: null, which is the normal case for the vast majority of deliveries.
Account configuration
Returns and the way they are reported are configured per account:
- Returns - whether a failed delivery produces a return leg at all.
- Return delivery webhooks - whether the return delivery sends its own status webhooks. Enabled by default.
linked_deliveryin webhooks - whether the original delivery's webhook embeds the return. Available on request.return_jobin the job resource - whetherGET /jobs/{job_id}includes the return. Available on request.
Contact your account manager to change any of these.
See also
Updated about 11 hours ago
