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 "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 -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 -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_systemandexternal_reference_idset, a repeated create returns the original shipment or package with200andreplayed: trueinstead of a duplicate, so a warehouse feed can resend. - The address is on the fulfillment.
recipientholds the delivery address and buyer email copied from the order. Change it withPATCH /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 -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:
| Type | Steps | Stock leaves on hand at |
|---|---|---|
shipment | accept, mark_picked, mark_packed, dispatch, complete | dispatch |
local_delivery | accept, mark_preparing, dispatch, complete | dispatch |
pickup | accept, mark_preparing, mark_ready, complete | complete |
digital | accept, complete | complete |
service | accept, schedule, start, complete | complete |
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:
| Field | Meaning |
|---|---|
request_status: accepted | The work was accepted. status stays pending until work starts |
active_hold | The fulfillment is paused, with a reason. status keeps its value from before the hold |
outcome | Why 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 -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 -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_readytells the buyer the order is waiting.completerecords the collection and takes the stock off hand. A buyer who never comes getscancelorfail, becausemark_no_showis for services only. - Local delivery. Works like a shipment without shipments or packages.
dispatchwhen the courier leaves,completeon delivery. Put the courier's link inlocal_delivery_details.tracking_url. - Digital. Put the download link in
digital_details.delivery_urlandcompleteonce the buyer has it. The email links the buyer to their account rather than to the file. - Service.
schedulebooks the appointment window,startmarks it underway, andmark_no_showrecords 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 -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.
| Action | Stock | Quantity | Use it when |
|---|---|---|---|
cancel | Released | Stays assigned. The order counts it as canceled | The goods will not be sent at all |
fail with release_quantity: true | Released, unless already dispatched | Returned to the order | You will send it another way, such as from another warehouse |
fail with release_quantity: false | Kept | Stays assigned | The 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:
| Source | Emails on |
|---|---|
| Fulfillment action | mark_ready, dispatch, complete, cancel, fail, mark_no_show |
| Package action | mark_shipped, mark_in_transit, mark_out_for_delivery, mark_delivery_attempted, mark_delivered, mark_exception, mark_returned |
| Package tracking | Tracking set on create, or changed with PATCH /v1/packages/{package_id} |
| Fulfillment event | The 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:
- Create the package with tracking and
suppress, because the label exists but the parcel has not left. - Send
mark_shippedon the package. The buyer gets one email with the tracking link. - Send
dispatchon the fulfillment withsuppress, because the buyer already knows. - Let
mark_deliveredsend. - Send
completewithsuppress.
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.
