DEV Community

Cover image for Don't Refund on 'Delivered': Hardening Return Workflows Against FTID Fraud
Jarrod Wright for Chargebacks911

Posted on AI-assisted

Don't Refund on 'Delivered': Hardening Return Workflows Against FTID Fraud

Many e-commerce backends ship with a return flow like this:

// Don't do this
app.post('/webhooks/carrier', async (req, res) => {
  const evt = req.body;
  if (evt.status === 'DELIVERED') {
    const rma = await db.returns.findByTracking(evt.trackingNumber);
    await payments.refund(rma.orderId, rma.amount);
  }
  res.sendStatus(200);
});
Enter fullscreen mode Exit fullscreen mode

It is fast and cheap to run, and customers like it. It also contains a clear security bug. It treats a carrier status as proof that your merchandise came back. Fraudsters exploit this with FTID (fake tracking ID) attacks. They take the prepaid return label you issued, keep the barcode intact, and edit the visible address or move part of the label onto a decoy package. The carrier scans the barcode and moves something to somewhere. Your webhook fires DELIVERED, and your system pays out.

For the wider fraud landscape (label editing, account address switching, courier impersonation), see this overview of package redirection scams and tracking red flags. This article stays narrow. It covers how to engineer the refund trigger so a single upstream event cannot release money.

What a 'delivered' event actually asserts

A delivery scan tells you very little. In practice it means:

  • A barcode encoding your tracking number was scanned.
  • The scanning party recorded a delivery event at some location and time.

It does not assert:

  • That the package contents match the RMA.
  • That the physical address on the box matched your returns facility.
  • That the package passed through your receiving dock.

Model the carrier as an untrusted, eventually consistent data source. Its events are hints that move a return forward in your workflow. They should never authorize a payment by themselves.

Signals you can actually check

The carrier event is useful once you cross-check it against data you control. Depending on the carrier and your integration, these fields may be available:

Signal Source What it catches
Delivery scan location (city/postal code) Carrier tracking API scan events Label edited to send the package to a different destination
Scan path / origin postal code Carrier scan history Label reused from a different origin than the customer's address
Carrier-reported or billed weight Carrier API or invoice data Empty envelope or junk shipped as a decoy
Service type / package type Carrier metadata Envelope or letter service on an RMA for a boxed item
Your own dock intake scan Your WMS 'Delivered' events that never physically arrived at your facility
Intake weight and SKU scan Your WMS Weight and content mismatch, item swap
Time between label issue and first scan Your DB + carrier Labels scanned implausibly fast or from an unusual region

The last two rows are the only ones fully under your control. They are also the strongest signals. Everything above them is supporting evidence.

Model returns as a state machine

Do not keep a boolean refunded flag. Make the states explicit and limit which actors can trigger each transition:

REQUESTED -> LABEL_ISSUED -> IN_TRANSIT -> CARRIER_DELIVERED
                                              |
                                              v
                              RECEIVED_AT_DOCK -> INSPECTED -> REFUNDED
                                              |               
                                              +--> HELD_FOR_REVIEW -> REFUNDED | REJECTED
Enter fullscreen mode Exit fullscreen mode

The key rule is that carrier webhooks can only advance the return to CARRIER_DELIVERED. Only an internal event can move it to RECEIVED_AT_DOCK: a WMS scan, a staff action, or an automated scale. The refund transition requires INSPECTED or an explicit review decision.

If the business needs instant refunds for low-risk customers, implement them as a policy that operates on a risk score. Do not bypass the state machine for them.

Implementation

1. Webhook handler: record, don't act

app.post('/webhooks/carrier', verifySignature, async (req, res) => {
  const evt = normalizeCarrierEvent(req.body); // map carrier-specific codes

  // Idempotency: carriers retry, and events arrive out of order
  const inserted = await db.carrierEvents.insertIfAbsent(evt.eventId, evt);
  if (!inserted) return res.sendStatus(200);

  await queue.publish('return.carrier_event', { trackingNumber: evt.trackingNumber });
  res.sendStatus(200);
});
Enter fullscreen mode Exit fullscreen mode

The handler stores the event and enqueues a job. It never calls the payments service. This also keeps the handler fast, so the carrier does not time out and retry.

2. Evaluator: compare against the RMA

type Finding = { code: string; severity: 'info' | 'warn' | 'block' };

function evaluateDelivery(rma: Return, events: CarrierEvent[]): Finding[] {
  const findings: Finding[] = [];
  const delivered = latestByTimestamp(events.filter(e => e.status === 'DELIVERED'));
  if (!delivered) return findings;

  // Destination check
  if (delivered.postalCode && !rma.facility.postalCodes.includes(delivered.postalCode)) {
    findings.push({ code: 'DEST_MISMATCH', severity: 'block' });
  }

  // Origin check: first scan should be near the customer
  const first = earliestByTimestamp(events);
  if (first?.postalCode && !isNearby(first.postalCode, rma.customerPostalCode)) {
    findings.push({ code: 'ORIGIN_UNUSUAL', severity: 'warn' });
  }

  // Weight check, if the carrier reports one
  const reported = toGrams(delivered.weight ?? first?.weight);
  if (reported != null) {
    const ratio = reported / rma.expectedShippedWeightGrams;
    if (ratio < 0.5) findings.push({ code: 'WEIGHT_LOW', severity: 'block' });
    else if (ratio < 0.8) findings.push({ code: 'WEIGHT_SOFT_LOW', severity: 'warn' });
  }

  // Service type: envelope for a boxed item
  if (delivered.packageType === 'ENVELOPE' && rma.expectedPackageType === 'BOX') {
    findings.push({ code: 'PACKAGE_TYPE_MISMATCH', severity: 'block' });
  }

  return findings;
}
Enter fullscreen mode Exit fullscreen mode

The thresholds are placeholders. Calibrate them against your own historical returns (see below).

3. Intake: the authoritative check

At the dock, a scan of the return label should pull up the RMA and log the scale weight automatically. Requiring manual weight entry produces bad data.

async function onDockIntake(trackingNumber: string, scaleGrams: number, scannedSkus: string[]) {
  const rma = await db.returns.findByTracking(trackingNumber);
  await transition(rma, 'RECEIVED_AT_DOCK', { scaleGrams });

  const skuOk = sameMultiset(scannedSkus, rma.skus);
  const weightOk = within(scaleGrams, rma.expectedShippedWeightGrams, 0.15);

  if (skuOk && weightOk) await transition(rma, 'INSPECTED');
  else await transition(rma, 'HELD_FOR_REVIEW', { skuOk, weightOk });
}
Enter fullscreen mode Exit fullscreen mode

Decision table for the refund trigger

Carrier findings Dock intake Customer risk Action
None Not yet Low, established history Optional provisional refund, with clawback flag
None Not yet New or elevated Wait for intake
Any warn Not yet Any Wait for intake
Any block Not yet Any Hold, alert fraud queue, do not refund
Any Passed (SKU + weight) Any Refund
Any Failed Any Hold for manual review
'Delivered' but no intake after N business days — Any Investigate: search the dock, open a carrier trace

The last row matters. A DELIVERED event with no matching dock scan is a strong FTID indicator on its own. It means the carrier claims delivery to you and your building has no record of the package.

Worked example

A customer returns headphones. The RMA expects about 900 g shipped weight from postal code 30301, delivered to your facility at 75201.

Events received:

  1. PICKED_UP at 30301. Weight is reported as 0.12 lb.
  2. IN_TRANSIT through a regional hub.
  3. DELIVERED at postal code 75204.

Evaluator output:

  • DEST_MISMATCH (block): 75204 is not one of your facility codes.
  • WEIGHT_LOW (block): 0.12 lb is about 54 g, roughly 6% of the expected weight.

The naive handler would have refunded at step 3. With the evaluator, the return goes to HELD_FOR_REVIEW, the fraud queue gets the tracking history, and no dock intake arrives. You have evidence ready if the customer escalates or files a dispute.

Common mistakes

Mixing units. Carriers report weight in pounds, ounces, or kilograms, sometimes depending on the service. Normalize at ingestion. A unit bug can mark every legitimate return as an empty envelope.

Comparing against item weight instead of shipped weight. Packaging adds mass. Store an expected shipped weight per SKU or per outbound shipment. Your outbound label data is often the best source.

Using billed weight as actual weight. Dimensional weight can be much larger than the real mass. Treat carrier weight as a soft signal unless you know which field you are reading.

Trusting event order. Webhooks arrive late, duplicated, or out of order. Sort by the carrier's event timestamp and dedupe on event ID.

Refunding inside the webhook transaction. If the refund call succeeds and the DB write fails, the retry can refund twice. Use an outbox pattern and an idempotency key on the payment request.

Hard-coding a single facility postal code. Large facilities may span several codes, and you may add 3PL locations later. Make the list configurable.

Testing it

Build a set of fixture event streams and run them through the evaluator in CI:

  • Clean return: all signals match.
  • Edited destination: the delivery postal code differs.
  • Decoy envelope: correct destination with a very low weight.
  • Out-of-order duplicates: DELIVERED arrives before IN_TRANSIT, twice.
  • Delivered, never received: no intake event after the timeout.

Assert both the findings and the resulting state. Then replay several months of your real historical returns through the evaluator in shadow mode. Use that run to tune thresholds before any of them can block a refund. The aim is to catch decoys without holding refunds for every customer who used a heavier box.

Top comments (0)