Fulfillment and shipping

After an order is paid, a fulfillment records how its goods reach the buyer: which quantities, by which method, and how far along they are. You report the progress. Flint does not buy labels or poll carriers. In return it keeps the order's fulfillment_status, the stock ledger, and the buyer's shipping emails in step with what you report.

The Fulfillment API reference has every field. Delivery methods and shipping rates that the buyer picks at checkout are set up separately in Add shipping to a checkout.

The model in one paragraph#

A fulfillment covers some quantity of an order's line items and has a type: shipment, pickup, local_delivery, digital, or service. A shipment-type fulfillment holds one or more shipments, one per carrier leg, and each shipment holds packages, one per parcel. Tracking lives on the package, and package items say which order line quantities are in each box. Every change is recorded as a fulfillment event, and every shipping email Flint sends or skips is recorded as a fulfillment notification.

1. Find the work#

Start from order.paid, then list the order's fulfillments:

cURL
curl "https://api.withflintpay.com/v1/fulfillments?order_id=ord_1kmn0aExample" \
  -H "Authorization: Bearer YOUR_API_KEY"

Lines that ship, get picked up, or go out for local delivery get a fulfillment automatically. Flint creates it from the delivery method chosen for the order, splitting by stock location where needed, and it starts at pending. Creation runs a few seconds after payment, so a handler that reacts to order.paid can find the list empty; retry the list rather than creating one. Work with that fulfillment rather than creating another.

Digital and service lines have none. Create those yourself, as in Create a fulfillment yourself.

2. Pack and label#

Tracking belongs to the parcel, so a shipment-type fulfillment needs a shipment for the carrier leg and a package for each box. Create the shipment:

cURL
curl -X POST https://api.withflintpay.com/v1/fulfillments/ful_1kmn0aExample/shipments \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "external_system": "east-coast-wms", "external_reference_id": "shipment-1042" }'

Then add a package with the label's tracking details:

cURL
curl -X POST https://api.withflintpay.com/v1/shipments/shp_1kmn0aExample/packages \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "carrier": "ups",
    "service_code": "ground",
    "tracking_number": "1Z999AA10123456784",
    "tracking_url": "https://www.ups.com/track?tracknum=1Z999AA10123456784",
    "weight": { "value": 1.2, "unit": "pound" },
    "external_system": "east-coast-wms",
    "external_reference_id": "carton-1042-1",
    "buyer_notification_behavior": "suppress"
  }'

Say what is in each box with POST /v1/packages/{package_id}/items, sending order_line_item_id and quantity. Items across all packages cannot exceed the fulfillment's quantities. Boxes, items, and tracking can be changed until the shipment leaves.

  • The package starts at created, even with tracking. Adding tracking emails the buyer by default, so the sample suppresses it until the parcel is handed to the carrier.
  • Retries are safe. With external_system and external_reference_id set, a repeated create returns the original shipment or package with 200 and replayed: true instead of a duplicate, so a warehouse feed can resend.
  • The address is on the fulfillment. recipient holds the delivery address and buyer email copied from the order. Change it with PATCH /v1/fulfillments/{fulfillment_id} if the buyer asks.

3. Move it forward#

Every fulfillment lists the actions it accepts right now in supported_actions. Send one of them to the transitions endpoint:

cURL
curl -X POST https://api.withflintpay.com/v1/fulfillments/ful_1kmn0aExample/transitions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: ful_1kmn0aExample-packed" \
  -H "Content-Type: application/json" \
  -d '{ "action": "mark_packed", "expected_version": 2 }'

The response carries the updated fulfillment, the status-change event, and any fulfillment_notifications it produced. An action outside supported_actions returns 409 FULFILLMENT_ACTION_NOT_ALLOWED. If you send expected_version and someone else changed the fulfillment first, you get 409 FULFILLMENT_CHANGED, and the error carries current_version, current_status, and a current_resource with the fulfillment's current supported_actions. Re-read and decide again.

The steps depend on the type, and you can skip any of them:

TypeStepsStock leaves on hand at
shipmentaccept, mark_picked, mark_packed, dispatch, completedispatch
local_deliveryaccept, mark_preparing, dispatch, completedispatch
pickupaccept, mark_preparing, mark_ready, completecomplete
digitalaccept, completecomplete
serviceaccept, schedule, start, completecomplete

Until dispatch, every type also accepts hold and fail, plus cancel while it has no active shipment. service adds mark_no_show. A dispatched fulfillment accepts only complete and fail. Each action accepts only its own fields: hold needs a reason, schedule needs scheduled_start_at and scheduled_end_at, and fail needs release_quantity.

Three facts sit beside status rather than in it:

FieldMeaning
request_status: acceptedThe work was accepted. status stays pending until work starts
active_holdThe fulfillment is paused, with a reason. status keeps its value from before the hold
outcomeWhy it ended, such as a service no_show behind status: failed

A held fulfillment has no resume action. Send the next work action, such as mark_packed, and the hold clears. If the merchant turns on fulfillment.approval_required in settings, a new fulfillment offers only accept, hold, cancel, and fail until someone accepts it.

4. Report carrier progress#

As the parcel moves, send package actions to POST /v1/packages/{package_id}/transitions:

cURL
curl -X POST https://api.withflintpay.com/v1/packages/pkg_1kmn0aExample/transitions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "action": "mark_shipped", "occurred_at": "2026-09-22T16:05:00Z" }'

Package actions run from mark_packed and mark_shipped through mark_in_transit, mark_out_for_delivery, mark_delivery_attempted, and mark_delivered, plus mark_exception and mark_returned. Each package publishes its own supported_actions. The shipment has no actions: its status is computed from its packages, where an exception outranks everything else and otherwise the least advanced package sets the pace.

Carrier scans that do not change status, such as a departure from a sorting facility, go to POST /v1/fulfillments/{fulfillment_id}/events with an event_type like in_transit or custom. Send external_system and external_event_id together so a repeated scan is recorded once. Reusing an external_event_id with different details returns 409 FULFILLMENT_EVENT_DEDUPE_CONFLICT.

To correct a mistake before handoff, void it. POST /v1/shipments/{shipment_id}/void and POST /v1/packages/{package_id}/void work while the shipment is created or packed. Voiding a shipment voids its packages.

5. Complete it#

A delivered package does not complete the fulfillment. Carrier progress and the merchant's promise are separate: the package can read delivered while the fulfillment still reads dispatched. Send complete when the work is done:

cURL
curl -X POST https://api.withflintpay.com/v1/fulfillments/ful_1kmn0aExample/transitions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "action": "complete", "completed_at": "2026-09-24T18:20:00Z" }'

The order's fulfillment_status counts only completed fulfillments. It stays not_fulfilled through packing, dispatch, and delivery, moves to partially_fulfilled when some quantity is complete, and reaches fulfilled when all of it is. If some quantity was canceled instead, the order ends closed. The full value list is in Statuses and lifecycles.

Pickup, local delivery, digital, and service#

  • Pickup. mark_ready tells the buyer the order is waiting. complete records the collection and takes the stock off hand. A buyer who never comes gets cancel or fail, because mark_no_show is for services only.
  • Local delivery. Works like a shipment without shipments or packages. dispatch when the courier leaves, complete on delivery. Put the courier's link in local_delivery_details.tracking_url.
  • Digital. Put the download link in digital_details.delivery_url and complete once the buyer has it. The email links the buyer to their account rather than to the file.
  • Service. schedule books the appointment window, start marks it underway, and mark_no_show records a missed appointment with a reason.

Create a fulfillment yourself#

Create a fulfillment with POST /v1/orders/{order_id}/fulfillments when no fulfillment covers the quantity: digital and service lines, or quantity handed back by a failed fulfillment. For a shipment, shipment.packaging: single_package creates the shipment, one package, and its items in the same call:

cURL
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/fulfillments \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: order-1042-reship" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "shipment",
    "line_items": [{ "order_line_item_id": "li_1kmn0aExample", "quantity": 2 }],
    "external_reference_id": "wms-order-1042-reship",
    "shipment": {
      "packaging": "single_package",
      "package": {
        "carrier": "ups",
        "tracking_number": "1Z999AA10123456786",
        "buyer_notification_behavior": "suppress"
      }
    }
  }'

The response contains the created fulfillment in data, with the created shipment and package in its shipments and packages fields. Read its package items with GET /v1/packages/{package_id}/items.

  • Quantities are capped. A line can be split across several fulfillments, but the total cannot exceed what is still unassigned. Asking for more returns FULFILLMENT_QUANTITY_EXCEEDS_AVAILABLE.
  • Tracked stock comes from one location. For inventory items, a fulfillment draws on stock committed at a single location. Stock committed at two locations needs two fulfillments.
  • The address comes from the order. Without a recipient, Flint copies the order's delivery destination and buyer email.

Idempotency-Key is optional. Send one you stored before the request so you can replay the same key and body after a timeout and get the original IDs back. external_reference_id lets you find the fulfillment later with GET /v1/fulfillments?order_id=...&external_reference_id=....

Cancel, fail, and try again#

Canceling and failing both end a fulfillment, but they leave the order in different places.

ActionStockQuantityUse it when
cancelReleasedStays assigned. The order counts it as canceledThe goods will not be sent at all
fail with release_quantity: trueReleased, unless already dispatchedReturned to the orderYou will send it another way, such as from another warehouse
fail with release_quantity: falseKeptStays assignedThe goods are gone, such as a parcel lost after it shipped

Once a fulfillment has a shipment that is not voided, cancel drops out of supported_actions and releasing quantity returns FULFILLMENT_ACTIVE_SHIPMENT_EXECUTION. Before handoff, void the shipment first. After a parcel has left, the shipment can no longer be voided, so fail with release_quantity: false is the only way to end the fulfillment without completing it.

A payment can also stop shipping. When a payment provider asks Flint to pause fulfillment for a payment, every open fulfillment on the order goes on hold with reason payment_review and only cancel and hold remain. You receive payment_intent.fulfillment_hold.updated when the pause starts and when it lifts. Lifting it does not restart anything: decide whether to ship, then send the next action.

Refunding money never cancels a fulfillment or restocks anything on its own. Returned goods come back through Returns.

What the buyer receives#

Flint emails the buyer when your calls reach one of these moments:

SourceEmails on
Fulfillment actionmark_ready, dispatch, complete, cancel, fail, mark_no_show
Package actionmark_shipped, mark_in_transit, mark_out_for_delivery, mark_delivery_attempted, mark_delivered, mark_exception, mark_returned
Package trackingTracking set on create, or changed with PATCH /v1/packages/{package_id}
Fulfillment eventThe same moments, sent to POST .../events

accept, mark_picked, mark_packed, mark_preparing, schedule, start, and hold never email. Emails with a package behind them include a tracking button.

Each email goes to the fulfillment's recipient.email. Without one, Flint uses the order's buyer_contact.email, then the email of the order's customer. With no address at all, the email is skipped with suppression_reason: "recipient_missing".

Every call that reaches one of those moments sends by default, so an unplanned integration sends one email for tracking, one for shipped, one for dispatched, one for delivered, and one for completed. Decide which calls the buyer hears about and send buyer_notification_behavior: "suppress" on the rest. A common single-box pattern:

  1. Create the package with tracking and suppress, because the label exists but the parcel has not left.
  2. Send mark_shipped on the package. The buyer gets one email with the tracking link.
  3. Send dispatch on the fulfillment with suppress, because the buyer already knows.
  4. Let mark_delivered send.
  5. Send complete with suppress.

Flint drops exact duplicates, such as two integrations reporting the same package moment. It does not merge different moments.

Every email Flint sends or skips is recorded. Command responses include fulfillment_notifications, and GET /v1/fulfillment-notifications?fulfillment_id=... lists them with a status of pending, sent, failed, or suppressed and a suppression_reason. Buyers can unsubscribe from shipping progress emails: shipped, dispatched, in transit, out for delivery, delivered, and tracking changes. Ready, completed, canceled, and failed emails always go out.

To send these emails yourself, set customer_email_delivery.fulfillment_updates to merchant_sends. See Customer email delivery.

A signed-in buyer can read their own fulfillments, shipments, and packages through GET /v1/me/fulfillments, /v1/me/shipments, and /v1/me/packages with a customer session.

Staying in sync#

Payloads carry summaries with a resource_url, so fetch the fulfillment when you need its recipient or line items. The summary's status and request_status match the fetched fulfillment: acceptance leaves status as pending and sets request_status to accepted, a hold preserves the status from before the hold, and a no-show reports failed. The full list, including shipment events, is in the event catalog.

Events fire for your own calls too, so deduplicate by event ID. To rebuild a timeline without webhooks, page through GET /v1/fulfillment-events?fulfillment_id=..., which returns every status change alongside tracking changes and carrier events.

Common errors#

The full list is in the error reference. For return labels and inbound tracking, see Return labels and tracking.

Was this helpful?