WritingCommerce
Validate a cross-border address at checkout, not when the label fails
A playbook for structuring, validating and versioning a cross-border delivery address at checkout, so the label and customs declaration never fail on it later.
A customer in Dhaka orders a USD 86.00 camera lens from a US marketplace at 14:05 and types the delivery address into one box: "House 12, Road 5, Block C, Bashundhara R/A, Dhaka 1229", with a phone number that has no country code. The form accepts it, the card is charged, and procurement buys the lens that afternoon. Nine days later the parcel reaches the export hub and the carrier label call rejects it: the only line is 51 characters, the city field is empty, and the phone cannot be dialled from abroad. The lens can no longer be returned, and the customer has watched "preparing" for over a week. The outcome of this playbook is an order flow where an address that would fail at the label fails at checkout instead, with a named rule, and where the label and the customs declaration both print from the one stored version the customer confirmed.
Store the address as country keyed fields, not lines
A carrier label, a customs declaration and the last mile courier each read a different part of the same address. The label wants a locality and a postal code in their own fields. The declaration wants the recipient name and a phone the importer can be reached on. The courier in Dhaka wants the block and the house number, which a form designed for UK addresses has no box for. A single free text line can be printed, but it cannot be checked by a rule, and once printed it can only be split by a person.
Fix the country at the top of the form and render the rest from a field list keyed by that country. The columns below are the ones every cross-border order needs; which of them are required, and in what shape, is the country's business.
| Field | Holds | Who reads it |
|---|---|---|
country_code | Two letter code, fixed when the form opens | Every rule set, the duty quote |
recipient_name | The name on the identity document the courier may ask for | Label, declaration |
phone_e164 | Number with country code, such as +8801712345678 | Carrier notifications, declaration contact |
address_lines | One to three lines: house, road, block | Label |
dependent_locality | Area within the city, such as Bashundhara | Last mile courier |
locality | City | Label, rules |
admin_area | Division, state or province, where the country uses one | Label, rules |
postal_code | Checked against the country pattern | Label, rules |
You know it worked when every address row has a country_code and a locality, and the free text box no longer exists in the checkout.
Run the destination country's rules at checkout and name every failure
Postal code shape, which fields are required, and whether a phone must carry a country code all differ by destination. A validator that returns true or false sends the customer back to a form with no idea what to change, and leaves support just as blind. Return one rule id per failure: the form can highlight the field, support can quote the id from the order, and a weekly count by rule id tells you which field to redesign. Version the rule set, so an address validated last month can be re-checked against the rule that was live then.
// simplified: one rule set per destination country, one id per failure
type Rule = {
id: string;
field: keyof Address;
test: (a: Address) => boolean;
};
const BD: Rule[] = [
{ id: 'bd.postal_code.format', field: 'postalCode',
test: a => /^\d{4}$/.test(a.postalCode) },
{ id: 'bd.phone.country_code', field: 'phone',
test: a => a.phone.startsWith('+880') },
{ id: 'bd.locality.required', field: 'locality',
test: a => a.locality.trim().length > 0 },
];
export function validate(a: Address, rules: Rule[]): Failure[] {
return rules
.filter(r => !r.test(a))
.map(r => ({ rule: r.id, field: r.field }));
}Run the rules on every submit and again on every later edit, and store the rule set version beside the result. You know it worked when every rejected checkout in the logs carries at least one rule id, and no order reaches procurement with a failure row that has not been resolved.
flowchart TD
A[Customer submits address] --> B[Country rule set]
B --> C{All rules pass}
C -- no --> D[Return rule ids to form]
D --> A
C -- yes --> E[Fit to carrier label]
E --> F{Fits without truncation}
F -- no --> G[Hold and send fix link]
G --> A
F -- yes --> H[Store version and confirm]:::accent
H --> I[Procurement may buy]Fit the address to the carrier's label before procurement buys
A label API accepts a fixed number of address lines of a fixed length, and anything longer is either rejected or cut. Cut is worse than rejected: the parcel ships with "House 12, Road 5, Block C, Bashundhara" and loses the part the courier needed, and nobody learns that until it comes back. Which carrier will take the parcel is usually settled at checkout by the rate shop, so the limits are known then. I have built cross-border procurement that delivered through DHL, FedEx, UPS and USPS, and the rule I apply is that the fit runs against the strictest limit among the carriers the order can route to, not against the one most likely to win.
Treat the address lines as tokens split on commas and pack them into lines without breaking a token. Say the strictest carrier on the order takes three lines of 35 characters. "House 12, Road 5, Block C" is 25 characters and fills line one; "Bashundhara R/A" is 15 and takes line two; the locality and postal code print in their own fields. A token longer than the line limit, or more lines than the limit allows, is a failure with its own id, label.line_overflow. Keep the customer's original text on the record as address_native, in whatever script they wrote it, and derive the label lines from it. If the native text is not in Latin script, ask the customer for a Latin version rather than transliterating it yourself: the courier reads the native line and the carrier's scanner reads the Latin one.
You know it worked when no label call fails on an address that passed checkout, and no printed label carries fewer characters than the stored lines.
Hold the order on a failing fit and send a fix link before the buy
Here is the failure the hold prevents, step by step. A customer passes every country rule, but one token in their address, "Bashundhara Residential Area Block C", is 36 characters, one over the line limit. Without a hold, nothing stops the next step: procurement buys the lens at 15:10, the parcel lands at the export hub on day nine, and the label call returns an error. An operator shortens the token by hand to "Bashundhara Res Area Blk C", the courier does not recognise it, the parcel comes back after 40 days, and the customer is refunded less the return shipping, having seen "preparing" for nine days and "returned" after forty.
With a hold, the fit failure at checkout writes a row to address_holds with order_id, rule_id, field, created_at and expires_at, and moves the order to address_hold. The purchase transition reads the order state and refuses while a hold is open, so procurement cannot buy. The customer receives a fix link by SMS and email that opens the form on the failing field with the rule's message. A corrected submission creates a new address version, runs the rules and the fit again, and closes the hold with resolved_version_id. If expires_at passes with the hold still open, a job cancels the order and refunds the full amount: nothing has been bought, so the cancellation costs nothing but the sale.
You know it worked when no purchase row exists for an order whose latest address version has an open hold.
Pin the address version on the label and the declaration
Customers edit addresses after checkout: a move, a workplace that accepts parcels, a relative's house. If the edit overwrites the row, a label printed yesterday and a declaration generated today name different places, and the contact phone on the declaration is whichever one was current when it ran. Every edit must create a new address version, re-validated against the rules and the fit, and the label row and the declaration row must each store the address_version_id they printed from.
stateDiagram-v2 [*] --> Entered Entered --> Rejected : rule fails Rejected --> Entered : customer edits Entered --> Fitted : rules pass Fitted --> Held : label overflow Held --> Entered : fix link used Fitted --> Confirmed:::accent Confirmed --> Superseded : edit before label Confirmed --> Pinned : label printed Pinned --> Redirect : edit after label
The transition that matters is the one from Pinned. An edit that lands after the label is printed cannot apply to that shipment, because the parcel already carries the old address and the declaration already names it. It becomes a redirect request instead, a separate decision with its own carrier fee and approval, and the form says so rather than showing a green tick. An edit that lands before the label marks the old version Superseded, and the new one goes through the whole flow again, hold included.
You know it worked when every label row and every declaration row stores an address_version_id, and the two match on every shipment.
Build the versioned address table with its country keyed fields first, because every later step writes to it or reads from it. Then add the rule set for your largest destination and wire its ids into the checkout form, and add the carrier fit and the hold once the rules are returning ids you can count.
Before an address can reach procurement
- The checkout has no free text address box; every field is rendered from the country's field list
- Every validation failure carries a rule id and a field, and the rule set version is stored with the result
- The carrier fit runs at checkout against the strictest line limit among the carriers the order can route to
- The customer's native script address is stored unchanged, and label lines are derived from it
- An open row in
address_holdsblocks the purchase transition, and the hold expires into a full refund - Every edit creates a new address version that runs the rules and the fit again
- Every label and every customs declaration stores the
address_version_idit printed from - An edit after the label is printed opens a redirect request instead of changing the address