Defending retail chargebacks with photo evidence

For shippers doing volume into Walmart, Amazon Vendor, Target, Kroger, and similar retailers, chargebacks are a continuous, six-figure annual leak. Walmart OTIF, Amazon's CRP / shortage deductions, Target compliance fines, and Kroger pallet-config penalties land at the DC and get deducted automatically. Many shippers we work with report $30,000+ per site per month in preventable or disputable claims.

The single biggest reason valid disputes never get filed is the photo-to-paperwork gap: the warehouse has a picture of the correctly loaded, undamaged pallet at dispatch, but no one ever ties it back to the deduction line item.

This guide wires that loop closed.

What you get

  1. A normalized shipments table the dispute engine joins against.
  2. A library of pre-seeded retailer-specific dispute letter templates (Walmart, Amazon, Target, Kroger — 10 in total at v1 launch).
  3. A drafter that consumes a verification + its shipment + the matching template and produces a ready-to-send dispute letter, linked to the source verification (so a reviewer or downstream automation can pull a signed evidence URL on demand).

Architecture

plaintext
verify_ai_verifications              ┐
  (photo captures, with               │
   metadata.bol_number)                │
                                       ├──►  draftDispute(verificationId)
verify_ai_shipments                   │      (lib/verify-ai/dispute-drafter.ts)
  (BOL # → retailer → load context)   │
                                       │           │
verify_ai_chargeback_templates        ┘           │
  (10 pre-seeded global templates +                │
   per-customer overrides)                         ▼
                                          verify_ai_chargeback_disputes
                                          (status: draft → submitted → won/lost)


                                          (manual review OR
                                           SupplyPike submission)

All three tables are defined in migration 20260611_tms_chargeback.sql. The 10 starter templates ship in 20260612_chargeback_template_seed.sql.

The shipments table

verify_ai_shipments is the join key between a photo and a deduction. The drafter looks up a shipment by (customer_id, bol_number), where customer_id is taken from the verification and bol_number is read from verification.metadata.bol_number (or metadata.bolNumber).

The table's first-class columns are:

| Column | Notes | | ----------------------- | -------------------------------------------------------------- | | customer_id (uuid) | FK to billing_customers. | | bol_number (text) | Unique per customer; this is the join key. | | pro_number (text) | Optional carrier PRO. | | retailer (text) | Lowercase (walmart, amazon, target, kroger, ...). | | ship_date / deliver_date (date) | Interpolated into letters. | | origin / destination (jsonb) | Address blobs. | | status (text) | Defaults to pending. | | metadata (jsonb) | Free-form. Used today for pieces, MABD, PO/DC, etc. | | integration_id (uuid) | Nullable; reserved for the Cluster 3 integrations FK. |

Populate the table via:

  • TMS sync — when the McLeod / SupplyPike adapters graduate out of preview (see TMS integrations).
  • Direct insert — POST through your ERP or a one-off load script. No public REST endpoint ships at v1; admins write through the service-role client.
  • Inline tagging — pass metadata.bol_number on the /api/v1/verify call so the verification carries the join key. The drafter performs the lookup at draftDispute() time; there is no automatic shipment-row insert on the verify path.

The template library

Ten pre-seeded global templates ship at v1 launch, all loaded by 20260612_chargeback_template_seed.sql with customer_id = NULL. They're stored in verify_ai_chargeback_templates as Mustache-style strings; the drafter performs the substitution.

Templates are looked up on the exact pair (retailer, deduction_code)retailer is lowercased before the query, deduction_code must match the seed value verbatim.

| Retailer | deduction_code | Template name | | --------- | ---------------- | ------------------------------------------ | | walmart | 22 | Walmart OTIF Code 22 — Early | | walmart | 24 | Walmart OTIF Code 24 — Late | | walmart | 25 | Walmart OTIF Code 25 — Short | | walmart | 90 | Walmart OTIF Code 90 — Damaged | | walmart | MABD | Walmart — MABD Miss | | amazon | 5 | Amazon Vendor Code 5 — Carton Compliance | | amazon | 8 | Amazon Vendor Code 8 — PO Compliance | | amazon | 11 | Amazon Vendor Code 11 — ASN Accuracy | | target | SHORTAGE | Target — Shortage | | kroger | PALLET | Kroger — Pallet Configuration |

Every template body interpolates the same placeholder vocabulary the drafter populates:

{{bol_number}}, {{pro_number}}, {{po_number}}, {{retailer}}, {{deduction_code}}, {{ship_date}}, {{deliver_date}}, {{pieces}}, {{amount_dollars}}, {{amount_cents}}, {{verification_image_url}}, {{shipper_name}}. Unmatched placeholders are left in the rendered letter as {{key}} rather than blanked out.

Customers can override any global template by inserting a row with their customer_id set, same retailer + deduction_code, and active = true. Lookup priority is customer-specific first, then the global row — so partner-specific wording, case numbers, and contact blocks can be customized without forking the seed.

The drafter

lib/verify-ai/dispute-drafter.ts exposes a single function:

ts
import { draftDispute } from "@/lib/verify-ai/dispute-drafter";
 
const result = await draftDispute(verificationId, {
  retailer: "walmart",       // optional — defaults to metadata.retailer / shipment.retailer
  deductionCode: "25",       // optional — defaults to metadata.deduction_code
  amountCents: 124750,       // optional — defaults to metadata.amount_cents (cents, not dollars)
  shipmentId: undefined,     // optional — explicit join if metadata.bol_number is absent
});
// →
// {
//   disputeId: "<uuid>",
//   shipmentId: "<uuid> | null",
//   templateId: "<uuid> | null",
//   letter: "<rendered text>",
//   status: "draft"
// }

Note: amountCents is cents, not dollars. The template renders {{amount_dollars}} from amountCents / 100 to two decimal places, and also exposes {{amount_cents}} if you want the raw integer.

It performs five steps:

  1. Loads the verification + its metadata (the bol_number is the join key, accepted in either bol_number or bolNumber form).
  2. Joins to the matching verify_ai_shipments row using (customer_id, bol_number), or shipmentId if you passed one explicitly. The shipment is optional — if no row exists, the drafter still proceeds and the rendered letter just has empty shipment-derived placeholders.
  3. Resolves retailer and deduction_code from options → metadata → joined shipment. Throws DisputeDraftError (code: "missing_retailer_or_code") if neither source provides them.
  4. Picks the template: customer-specific row first, then the global row, both filtered by retailer (lowercased) + deduction_code + active = true. Throws DisputeDraftError (code: "template_not_found") if no match.
  5. Renders the letter via the in-module renderTemplate() helper and inserts a row into verify_ai_chargeback_disputes with: status = 'draft', evidence_verification_ids set to a JSONB array containing the verification's ID, dispute_letter_md set to the rendered text, and template_id linking back to the source template.

The drafter does not pre-generate signed evidence URLs. The {{verification_image_url}} placeholder is rendered as a relative dashboard path (/verifications/<id>); fetch a signed bucket URL from there at review time. evidence_verification_ids is the source-of-truth list a reviewer or webhook consumer should iterate to assemble the attachment packet.

SupplyPike webhook integration (roadmap)

Outcome tracking from SupplyPike will come back through an inbound webhook at POST /api/v1/integrations/supplypike/webhook, signature-verified with X-SupplyPike-Signature (HMAC-SHA256 of the body against a per-customer shared secret). The receiver will match on supplypike_dispute_id — the column already exists on verify_ai_chargeback_disputes — and update status to won, lost, partial, or expired.

The verify_ai_chargeback_disputes.status CHECK constraint already enumerates the full set (draft, submitted, won, lost, partial, expired), and the table carries submitted_at + resolved_at timestamp columns. The route itself, the signature verification, and the matching logic are not implemented in this session — they land alongside the SupplyPike adapter graduation.

Preview status

At v1, both the McLeod LoadMaster runtime adapter (lib/integrations/runtime/mcleod.ts) and the SupplyPike runtime adapter (lib/integrations/runtime/supplypike.ts) are stubs. Their function bodies throw Error("Not implemented: ... deferred to the post-merge Cluster 3 framework"). They define the expected types and config shape, but do not authenticate against the live APIs. The SupplyPike inbound webhook handler is also not yet built. Customers can use the drafter today by populating verify_ai_shipments directly (via the service-role client), calling draftDispute(), and exporting the rendered letter (result.letter) for ops to submit through their existing channels. End-to-end auto-submission lands when the adapters graduate out of preview.

Worked example: a Walmart Code 25 short dispute

The shipper's warehouse staff dispatch a pallet at 06:42. They capture a photo of the loaded pallet through /api/v1/verify with the standard damage-detection policy, passing:

json
{
  "policy": "pol_damage_detect",
  "metadata": {
    "bol_number": "BOL-44128301",
    "po_number": "PO-9912204",
    "site_id": "site_47",
    "pieces": 24
  }
}

The verification is recorded with is_compliant: true. A shipment row already exists for this customer with bol_number = "BOL-44128301" and retailer = "walmart" (synced from the TMS, or inserted directly via the service-role client).

Three weeks later Walmart deducts $1,247.50 with OTIF Code 25 (short shipment). Ops kicks off:

ts
const result = await draftDispute(verificationId, {
  retailer: "walmart",
  deductionCode: "25",
  amountCents: 124750, // $1,247.50 in cents
});

The drafter looks up the global Walmart 25 template ("Walmart OTIF Code 25 — Short"), interpolates {{bol_number}}, {{po_number}}, {{pieces}}, {{amount_dollars}}, and the rest, and inserts a row in verify_ai_chargeback_disputes with status: "draft", dispute_letter_md set to the rendered text, and evidence_verification_ids containing the source verification. Ops reviews result.letter, signs it, and submits the packet to SupplyPike (or another channel) manually until the SupplyPike adapter graduates out of preview.

What's next

Get in Touch

Questions about pricing, integrations, or custom deployments? We'd love to hear from you.