Özgür Işık Damar
11 min read

Building a 45-state warehouse FSM with two languages and a customs office

Notes from modeling a real cross-border package lifecycle — Skopje warehouse, Macedonian operators, Turkish supervisors, a customs API that hates you, and why 45 states beats 12 with boolean flags.

cross-borderwarehousefsmgo

The whiteboard in our Skopje warehouse held 47 sticky notes when I walked in for the first time in October 2023. They were the states of a package. Received. Weighed. Photographed. Declared. Rejected by customs. Returned to seller. The operations lead had drawn each one by hand. Someone had spilled coffee on three of them. I took a photo, flew back to Istanbul, and started modeling.

Two of the notes turned out to be the same state, written twice with slightly different Macedonian spellings. One was a state nobody actually used anymore. That left forty-five.

What Stork actually does

Stork is a cross-border marketplace. A buyer in Turkey orders something from a seller in North Macedonia, or the other way around. The goods physically transit through our warehouse in Skopje. Every package goes through intake, weighing, photographing, customs declaration, customs clearance, repacking, and dispatch. That's the happy path.

The unhappy paths are the interesting ones. Returns. Damaged on arrival. Customs flagged for re-inspection. Seller missed the SLA and we're issuing a partial refund. Cross-border returns are not a corner case. They're maybe a fifth of total volume.

The operators in the Skopje warehouse speak Macedonian. Their supervisors speak Macedonian and Turkish. I speak Turkish and English. The customs office speaks Macedonian, sends a lot of faxes, and sometimes calls our country manager directly when something is wrong.

I needed a model that wouldn't lie to any of them.

Why FSM, not status flags

Before the rewrite, the warehouse app had a status column with twelve values and seven boolean flags: is_weighed, is_photographed, is_declared, customs_cleared, awaiting_repack, is_returned, is_partial. You can guess where this is going.

In the first two months I logged seven illegal transitions in production:

  • Packages marked customs_cleared = true while is_declared = false. A supervisor had manually edited the row to unblock a stuck shipment.
  • Packages with is_returned = true AND awaiting_repack = true. That combination means nothing.
  • A package that became customs_cleared = false after previously being true. Someone on the customs side had refiled the same declaration twice, and our webhook flipped the bit back.

None of these were bugs in the strict sense. The code did exactly what was asked. The model just had no opinion about which combinations were legal.

An FSM has opinions. A package in weighed_pending_photo can only move to photographed_pending_declaration, damaged, or removed_from_intake. Nothing else. The application server refuses everything else with a 409 and a log line nobody is allowed to silence.

// FSM definition lives in one file. Every state is a constant.
// Every transition is a tuple of (from, to, requiredRole, requiredMetadata).
// We do not allow code anywhere else to mutate Package.State directly.
 
type State string
 
const (
    StateReceived              State = "received"
    StateWeighedPendingPhoto   State = "weighed_pending_photo"
    StatePhotographedPending   State = "photographed_pending_weight"
    StateReadyForDeclaration   State = "ready_for_declaration"
    StateDeclaredPending       State = "declared_pending_customs"
    StateCustomsCleared        State = "customs_cleared"
    StateCustomsRejected       State = "customs_rejected"
    // ...40 more
)
 
type Transition struct {
    From             State
    To               State
    RequiredRole     Role
    RequiredFields   []string // metadata that must be present before transition
}

There are forty-five states, not fifteen, because the warehouse genuinely has that many distinguishable situations. Stop conflating them and the mysterious bugs stop too.

States that look like duplicates but aren't

Two early reviewers asked the same question: why have both weighed_pending_photo and photographed_pending_weight? Aren't those just two routes to the same destination?

They are. But operators don't work in a fixed order. Some weigh first because the scale is closer to the intake door. Some photograph first because they're waiting for the scale to free up. Both routes converge at ready_for_declaration. Collapse those two intermediate states into a single weighed_or_photographed and you lose the ability to say, at 10am on a Tuesday, that thirty packages have been weighed but not photographed and the camera station has a queue.

That sounds like a UI concern. It isn't. The end-of-shift report reads from the FSM. The supervisor walks the floor with that report in hand. If the report can't tell weighed-but-not-photographed from photographed-but-not-weighed, neither can the supervisor, and the camera station's queue stays invisible until someone starts yelling.

Another pair that looks like a duplicate: customs_rejected_appealable and customs_rejected_final. The Skopje customs office gives you a fourteen-day window to appeal certain rejections. After fourteen days the rejection is final and the package goes back to the seller. The two states have completely different downstream transitions and completely different supervisor permissions. They are not the same state with a timer attached.

The bilingual UI is a state property, not a translation file

Every state has two labels — Macedonian and Turkish — and they live next to the state definition, not in a separate i18n bundle.

var stateLabels = map[State]struct {
    MK string
    TR string
}{
    StateWeighedPendingPhoto: {
        MK: "Измерен, чека фотографија",
        TR: "Tartıldı, fotoğraf bekliyor",
    },
    StateDeclaredPending: {
        MK: "Декларирано, чека царина",
        TR: "Beyan edildi, gümrük bekliyor",
    },
    // The reason these labels live here and not in i18n.json:
    // when a new state is added, the developer cannot forget to
    // give it both labels. The compiler refuses to start without them.
}

Operators use a tablet at each station. The tablet shows the current state in Macedonian, large font, with the next allowed transitions below it as buttons, also in Macedonian. The supervisor's back-office view stacks both labels. The supervisor doesn't have to translate anything on the fly during a phone call with Istanbul.

The first week after we rolled this out, a senior operator who had been at the warehouse since before Stork existed told my country manager that the app finally "spoke her language." A small thing. It also cut about twenty support tickets a week.

The customs office is an external state machine you don't own

The Macedonian customs API deserves its own post. The short version: we POST a declaration, get back a tracking ID, and then wait for a webhook. The webhook can flip our package from declared_pending_customs into one of three states: customs_cleared, customs_rejected_appealable, customs_rejected_final.

func (h *CustomsWebhookHandler) Handle(w http.ResponseWriter, r *http.Request) {
    var payload customsWebhookPayload
    if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
        h.log.Warn("customs webhook unparseable", "err", err)
        // We acknowledge anyway. Customs retries forever otherwise.
        w.WriteHeader(http.StatusOK)
        return
    }
 
    pkg, err := h.repo.GetByDeclarationID(r.Context(), payload.DeclarationID)
    if err != nil {
        // Unknown declaration ID — log and ack. Manual queue picks it up.
        h.log.Warn("customs webhook for unknown declaration", "id", payload.DeclarationID)
        w.WriteHeader(http.StatusOK)
        return
    }
 
    target := mapCustomsOutcomeToState(payload.Outcome)
    // The FSM is the gatekeeper. If customs tries to flip us into a state
    // we cannot legally reach from our current one, we record the attempt
    // and surface it on the supervisor dashboard for manual reconciliation.
    if err := h.fsm.Transition(r.Context(), pkg, target, RoleSystem); err != nil {
        h.log.Error("illegal customs transition", "pkg", pkg.ID, "from", pkg.State, "to", target)
        h.queue.PushManualReview(pkg.ID, "customs webhook attempted illegal transition")
    }
    w.WriteHeader(http.StatusOK)
}

The interesting case is when customs sends a clearance for a package we never declared. Because, you guessed it, we forgot to mark it as declared after the fax confirmation arrived. Before the FSM, that webhook would silently flip a weighed_pending_photo package straight to customs_cleared. A few days later someone would find that package in the warehouse with no photo and no paperwork. After the FSM, the webhook is rejected, the supervisor sees a queue item, and a human chases down the missing intake step.

What four months of production looked like

I shipped the FSM in February 2024. By June 2024:

  • Zero illegal transitions in production logs. Not "low", not "rare" — zero. The transition validator made it structurally impossible.
  • Twelve new states added since launch. Each one was a real warehouse situation we hadn't anticipated, not a refactor of an existing state. Every new state required explicit incoming and outgoing transitions, so the model absorbed them without breaking anything.
  • A 31% drop in operator support tickets, measured against the rolling three-month baseline before launch. Most of the disappeared tickets were variations on "the app says X but the package is actually Y" — which used to mean a stuck boolean flag and now means nothing, because the app and the package can no longer disagree.

Six months in, the supervisor messaged me after a Friday debrief in Skopje. "The operators trust the app now," she wrote. "It can't lie." I pinned that line to the top of the README.

What I'd tell someone starting

Don't compress your states to look elegant. The warehouse isn't elegant. The customs office isn't elegant. Operators don't work in a fixed order and you can't force them to. If you have 45 distinguishable situations, model 45 states.

Put the labels next to the state. Make the compiler enforce that every state has every label. Translation files are where states go to grow inconsistencies.

Make the FSM the only thing that can mutate state. Not "by convention." By architecture. Every other path is a sticky note waiting to be written in a language you don't speak.

// while you're here