medusa-fulfillment-shippo
ℹ️ Requires Medusa ^1.3.5
Shippo fulfillment provider for Medusa Commerce.
Provides fulfillment options using carrier service levels and user created service groups that can be used to create shipping options for profiles and regions.
Rates at checkout optimized with a first-fit-decreasing (FFD) bin packing algorithm.
Fulfillments create orders in shippo.
Supports returns, exchanges, and claims.
Public interface for rapid custom integration. Reference | Quick Reference
Eventbus payloading instead of arbitrary data assumption and storage.
Table of Contents
- Getting Started
- Orders
- Packing Slips
- Returns
- Swaps
- Claims
- Rates at Checkout
- Webhooks
- Public Interface
- Events
- Shippo Node Client
- Release Policy
- Limitations
- Resources
Getting started
Install:
Copy to clipboard
> npm i medusa-fulfillment-shippo
Add to medusa-config.js
12345678910{resolve: `medusa-fulfillment-shippo`,options: {api_key: SHIPPO_API_KEY,weight_unit_type: 'g', // valid values: g, kg, lb, ozdimension_unit_type: 'cm', // valid values: cm, mm, inwebhook_secret: '', // README section on webhooks before using!webhook_test_mode: false},}
Orders
Creating an order fulfillment makes a new order in shippo. An event is emitted with the response data and related internal ids.
Create a Subscriber to access the data.
Event:
Copy to clipboard
shippo.order_created
123456{order_id: "",fulfillment_id: "",customer_id: "",shippo_order: {...}}
Retrieve
12345await shippoService.order.fetch(object_id)await shippoService.order.fetchBy(["fulfillment", ful_id]await shippoService.order.with(["fulfillment"]).fetch(object_id)
See references for all methods
Packing Slips
Retrieve
1234567const { object_id } = orderawait shippoService.packingslip.fetch(object_id)await shippoService.packingslip.fetchBy(["fulfillment"], ful_id)await shippoService.packingslip.with(["fulfillment"]).fetch(object_id)
See references for all methods
Returns
Request
Invoked when Request a Return Copy to clipboard
return_shipping
has Copy to clipboardprovider: shippo
Attempts fetching an existing return label from shippo.
Event:
Copy to clipboard
shippo.return_requested
1234{order: {...}, // return ordertransaction: {...} // shippo transaction for return label OR null}
Swaps
Create
Attempts fetching an existing return label from shippo.
Event:
Copy to clipboard
shippo.swap_created
1234{order: {...}, // return ordertransaction: {...} // shippo transaction for return label OR null}
Fulfillment
Invoked when Create a Swap Fulfillment Copy to clipboard
shipping_option
has Copy to clipboardprovider: shippo
Creates an order in shippo.
Event:
Copy to clipboard
shippo.replace_order_created
123456{order_id: "",fulfillment_id: "",customer_id: "",shippo_order: {...}}
Claims
Refund
Invoked when Create a Claim has Copy to clipboard
type: refund
and Copy to clipboardreturn_shipping
has Copy to clipboardprovider: shippo
Attempts fetching an existing return label from shippo.
Event:
Copy to clipboard
shippo.claim_refund_created
1234{order: {...}, // return ordertransaction: {...} // shippo transaction for return label OR null}
Replace
Invoked when Create a Claim has Copy to clipboard
type: replace
and Copy to clipboardreturn_shipping
has Copy to clipboardprovider: shippo
Attempts fetching an existing return label from shippo.
Event:
Copy to clipboard
shippo.claim_replace_created
1234{order: {...}, // return ordertransaction: {...} // shippo transaction for return label OR null}
Fulfillment
Invoked when Create a Claim Fulfillment Copy to clipboard
shipping_option
has Copy to clipboardprovider: shippo
Creates an order in shippo.
Event:
Copy to clipboard
shippo.replace_order_created
123456{order_id: "",fulfillment_id: "",customer_id: "",shippo_order: {...}}
Rates at Checkout
Provide customers with accurate shipping rates at checkout to reduce over and under charges. This plugin implements a first-fit-decreasing bin packing algorithm to choose an appropriate parcel for the items in a cart. Follow this guide to get setup and then optimize.
Setup Shipping Options in Shippo App
Lets assume shipping from Canada to customers in Canada and USA via “Standard” and “Express” options
This would require setting up 4 shipping options in Shippo (<https://apps.goshippo.com/settings/rates-at-checkout>)
- Standard Shipping Canada
- Express Shipping Canada
- Standard Shipping USA
- Express Shiping USA
Set each shipping option to “Live rate” and assign service(s) to them
For example:
- Express Shipping Canada: Canada Post XpressPost
- Express Shipping USA: Canada Post XpressPost USA
- …
For more in-depth details see https://support.goshippo.com/hc/en-us/articles/4403207559963
Assign Shipping Options to Regions in Medusa
Create shipping options for regions as usual
Setup parcel templates
Create package templates in the Shippo app settings
To get most optimal results, it is recommended to create package templates for all your shipping boxes.
Verify product dimensions and weight
In your medusa store, make sure products have correct values for length, width, height, weight
During Checkout
Retrieve shipping options for cart as usual and any Copy to clipboard
price_type: calculated
options belonging to Copy to clipboardprovider: shippo
will have Copy to clipboardamount: Number
.Rates calculate only if cart has shipping address and items
HTTP:
1GET /store/shipping-options/:cart_id
Service:
1const shippingOptions = await shippingProfileService.fetchCartOptions(cart)
Add to Cart
Add a Shipping Method and if Copy to clipboard
shipping_option
has Copy to clipboardprice_type: calculated
the rate will be saved to the Copy to clipboardshipping_method
HTTP:
12POST /store/carts/:id/shipping-methods--data '{"option_id":"example_cart_option_id"}'
Help, adding a shipping method to cart throws an error
This is an issue with medusa-admin. Examine line 85 Copy to clipboard
admin/src/domain/settings/regions/new-shipping.tsx
Options with Copy to clipboard
price_type: flat_rate
will not pass through Copy to clipboardfulFillmentProviderService.calculatePrice()
medusa-admin is still early phase software.
Workaround it, use the REST api directly, or patch the issue for now
Possible interim solution:
123price_type: (options[optionIndex].type === "LIVE_RATE")? "calculated": "flat_rate",
Webhooks
Caution
Incoming HTTP requests from Shippo to webhook endpoints lack authentication. No secret token, no signature in the request header, no bearer, nothing.
Before enabling webhooks, understand the risks of an open and insecure HTTP endpoint that consumes data, and how to mitigate this. Please DO NOT use this without SSL/TLS. Whitelisting shippo IP's is a good idea. There are also intermediary third party services such as pipedream and hookdeck that can be used to relay requests.
You will also need to self generate a token and add it as a url query param. Ya I know… but it's better than nothing and it is encrypted over HTTPS
The flow at the code level is:
- Webhook receives POST data
- URL query token is verified
- The request json gets verified by fetching the same object directly from shippo API, following these steps:
- Request body contains json claiming to be a shippo object.
- Ok, but lets fetch this object directly from Shippo's API
- If the fetch resolves to the object requested, then use that data instead of the untrusted input
- Otherwise throw a HTTP 500 and do nothing
Setup
In Copy to clipboard
.env
add Copy to clipboardSHIPPO_WEBHOOK_SECRET=some_secret_string
Add to Copy to clipboard
medusa-config.js
12345678910111213const SHIPPO_API_KEY = process.env.SHIPPO_API_KEYconst SHIPPO_WEBHOOK_SECRET = process.env.SHIPPO_WEBHOOK_SECRET{resolve: `medusa-fulfillment-shippo`,options: {api_key: SHIPPO_API_KEY,weight_unit_type: 'g',dimension_unit_type: 'cm',webhook_secret: SHIPPO_WEBHOOK_SECRET,webhook_test_mode: false},},
Endpoints
Hooks need to be setup in Shippo app settings
transaction_created: Copy to clipboard
/hooks/shippo/transaction?token=SHIPPO_WEBHOOK_SECRET
transaction_updated: Copy to clipboard
/hooks/shippo/transaction?token=SHIPPO_WEBHOOK_SECRET
track_updated: Copy to clipboard
/hooks/shippo/track?token=SHIPPO_WEBHOOK_SECRET
Then send a sample. If everything is good you will see this in console:
12Processing shippo.received.transaction_created which has 0 subscribersProcessing shippo.rejected.transaction_created which has 0 subscribers
This is the expected behaviour because the data could not be verified. Since it is a sample, when the plugin tried to verify the transaction by requesting the same object back directly from shippo api, it did not exist. It will NOT use input data beyond making the verification, so it gets rejected.
Test Mode
Test mode bypasses input authenticity verification, i.e. it will use the untrusted input data instead of requesting the same data back from shippo.
This allows testing using data that does not exist in shippo.
To enable, set Copy to clipboard
webhook_test_mode: true
in Copy to clipboardmedusa-config.js
plugin options.Running in test mode is a security risk, enable only for testing purposes.
transaction_created
Copy to clipboard
/hooks/shippo/transaction?token=SHIPPO_WEBHOOK_SECRET
Receives shippo transaction object when label purchased
- Updates fulfillment to “shipped”
- Adds tracking number and link to fulfillment
Events
Copy to clipboard
shippo.transaction_created.shipment
12345{order_id: "",fulfillment_id: "",transaction: {...}}
Copy to clipboard
shippo.transaction_created.return_label
1234{order_id: "",transaction: {...}}
transaction_updated
Copy to clipboard
/hooks/shippo/transaction?token=SHIPPO_WEBHOOK_SECRET
Receives shippo transaction object when transaction updated
Events
Copy to clipboard
shippo.transaction_updated.payload
12345{order_id: "",fulfillment_id: "",transaction: {...}}
track_updated
Copy to clipboard
/hooks/shippo/track?token=SHIPPO_WEBHOOK_SECRET
Events
Copy to clipboard
shippo.track_updated.payload
123{...track}
Public Interface
References the declared public interface for client consumption, the semver "Declared Public API"
Although there is nothing stopping you from accessing and using public methods behind the interface, be aware that those implementation details can and will change. The purpose of the interface is semver compliant stability.
Getting Started
Dependency inject Copy to clipboard
shippoService
as you would with any other serviceFor guide, see Using Custom Service
account.address()
Fetch default sender address
Return
Copy to clipboard
Promise.<object>
Example
1await shippoService.account.address()
order.fetch(id)
Fetch an order from shippo
Parameters
Name | Type | Description |
---|---|---|
id | Copy to clipboardString | The object_id for an order |
Return
Copy to clipboard
Promise.<object>
Example
1await shippoService.order.fetch(object_id)
order.with([entity]).fetch(id)
Fetch a shippo order with a related entity.
Parameters
Name | Type | Description |
---|---|---|
id | Copy to clipboardString | The object_id for an order |
entity | Copy to clipboardArray.<string> | The entity to attach |
Supported Entities
Copy to clipboard
fulfillment
Return
Copy to clipboard
Promise.<object>
Example
123456789await shippoService.order.with(["fulfillment"]).fetch(object_id)/* @return */{...order,fulfillment: {...fulfillment}}
order.fetchBy([entity, id])
Fetch a shippo order using the id of a related entity
Parameters
Copy to clipboard
@param {[entity: string, id: string>]}
Name | Type | Description |
---|---|---|
entity | Copy to clipboardstring | The entity type to fetch order by |
id | Copy to clipboardstring | Id of the entity |
Supported Entities
Copy to clipboard
fulfillment
Copy to clipboardlocal_order
Copy to clipboardclaim
Copy to clipboardswap
Return
Copy to clipboard
Promise.<object|object[]>
Example
123/* @return {Promise.<object>} */await shippoService.order.fetchBy(["fulfillment", id])
1234567/* @return {Promise.<object[]>} */await shippoService.order.fetchBy(["local_order", id])await shippoService.order.fetchBy(["claim", id])await shippoService.order.fetchBy(["swap", id])
package.for([entity, id]).fetch()
Bin pack items to determine best fit parcel using package templates from shippo account
Will return full output from binpacker, including locus. The first array member is best fit
See also: override package templates
Parameters
Copy to clipboard
@param {[entity: string, id: string>]}
Name | Type | Description | |
---|---|---|---|
entity | Copy to clipboardstring | Entity type | |
id | items | Copy to clipboardstring\|array | The id of {[entity]} or array of items |
Supported Entities
Copy to clipboard
cart
Copy to clipboardlocal_order
Copy to clipboardfulfillment
Copy to clipboardline_items
Return
Copy to clipboard
Promise.<object[]>
Example
123456789// use parcel templates defined in shippo accountawait shippoService.package.for(["cart", id]).fetch()await shippoService.package.for(["local_order", id]).fetch()await shippoService.package.for(["fulfillment", id]).fetch()await shippoService.package.for(["line_items", [...lineItems]]).fetch()
Override Parcel Templates
Copy to clipboard
package.set("boxes", [...packages])
12345678910111213141516const packages = [{id: "id123",name: "My Package",length: "40",width: "30",height: "30",weight: "10000", // max-weight},{...}]shippoService.package.set("boxes", packages)await shippoService.package.for(["cart", id]).get()...
packingslip.fetch(id)
Fetch the packingslip for shippo order
Parameters
Name | Type | Description |
---|---|---|
id | Copy to clipboardString | The object_id of the order to get packingslip for |
Return
Copy to clipboard
Promise.<object>
Example
123const { object_id } = orderawait shippoService.packingslip.fetch(object_id)
packingslip.with([entity]).fetch(id)
Fetch the packingslip for shippo order with a related entity.
Parameters
Name | Type | Description |
---|---|---|
id | Copy to clipboardString | The object_id of the order to get packingslip for |
entity | Copy to clipboardArray.<string> | The entity to attach |
Supported Entities
Copy to clipboard
fulfillment
Return
Copy to clipboard
Promise.<object>
Example
123456789await shippoService.packingslip.with(["fulfillment"]).fetch(object_id)/* @return */{...packingslip,fulfillment: {...fulfillment}}
packingslip.fetchBy([entity, id])
Fetch the packing slip for a shippo order, using the id of a related entity
Parameters
Copy to clipboard
@param {[entity: string, id: string>]}
Name | Type | Description |
---|---|---|
entity | Copy to clipboardstring | The entity type to fetch packingslip by |
id | Copy to clipboardstring | Id of the entity |
Supported Entities
Copy to clipboard
fulfillment
Copy to clipboardlocal_order
Copy to clipboardclaim
Copy to clipboardswap
Return
Copy to clipboard
Promise.<object|object[]>
Example
123/* @return {Promise.<object>} */await shippoService.packingslip.fetchBy(["fulfillment", id])
1234567/* @return {Promise.<object[]>} */await shippoService.packingslip.fetchBy(["local_order", id])await shippoService.packingslip.fetchBy(["claim", id])await shippoService.packingslip.fetchBy(["swap", id])
track.fetch(carrier_enum, track_num)
Fetch a tracking status object
Parameters
Name | Type | Description |
---|---|---|
carrier_enum | Copy to clipboardstring | The carrier enum token |
track_num | Copy to clipboardstring | The tracking number |
Return
Copy to clipboard
Promise.<object>
Example
1await shippoService.track.fetch("usps", "trackingnumber")
track.fetchBy([entity, id])
Fetch a tracking status object using id of related entity
Copy to clipboard
@param {[entity: string, id: string>]}
Name | Type | Description |
---|---|---|
entity | Copy to clipboardstring | The entity type to fetch tracking status by |
id | Copy to clipboardstring | Id of the entity |
Supported Entities
Copy to clipboard
fulfillment
Return
Copy to clipboard
Promise.<object>
Example
1await shippoService.track.fetchBy(["fulfillment", id])
transaction.fetch(id)
Fetch a transaction object from shippo.
To fetch an extended version with additional fields, use Copy to clipboard
transaction.fetch(id, { type: extended})
Name | Type | Description |
---|---|---|
id | Copy to clipboardString | The object_id for transaction |
Return
Copy to clipboard
Promise.<object>
Example
123await shippoService.transaction.fetch(object_id)await shippoService.transaction.fetch(object_id, { type: "extended" })
transaction.fetchBy([entity, id])
Fetch a transaction using the id of a related entity
Parameters
Copy to clipboard
@param {[entity: string, id: string>]}
Name | Type | Description |
---|---|---|
entity | Copy to clipboardstring | The entity type to fetch transaction by |
id | Copy to clipboardstring | Id of the entity |
Supported Entities
Copy to clipboard
fulfillment
Copy to clipboardlocal_order
Copy to clipboardclaim
Copy to clipboardswap
Return
Copy to clipboard
Promise.<object|object[]>
Example
123456789101112131415await shippoService.transaction.fetchBy(["fulfillment", id])await shippoService.transaction.fetchBy(["fulfillment", id], { type: "extended" })await shippoService.transaction.fetchBy(["local_order", id])await shippoService.transaction.fetchBy(["local_order", id], { type: "extended" })await shippoService.transaction.fetchBy(["claim", id])await shippoService.transaction.fetchBy(["claim", id], { type: "extended" })await shippoService.transaction.fetchBy(["swap", id])await shippoService.transaction.fetchBy(["swap", id], { type: "extended" })
Misc
is([entity, id], attr).fetch()
123await shippoService.is(["transaction", id], "return").fetch()await shippoService.is(["order", id], "replace").fetch()
Client
Copy to clipboard
shippo-node-client
(forked)1const client = shippoService.client
find(entity).for([entity, id])
12345/* @experimental */await shippoService.find("fulfillment").for(["transaction", id])await shippoService.find("order").for(["transaction", id])
Quick Reference
Account
1await shippoService.account.address()
Order
1await shippoService.order.fetch(object_id)
1await shippoService.order.with(["fulfillment"]).fetch(object_id)
1234567await shippoService.order.fetchBy(["fulfillment", id])await shippoService.order.fetchBy(["local_order", id])await shippoService.order.fetchBy(["claim", id])await shippoService.order.fetchBy(["swap", id])
1await shippoService.is(["order", id], "replace").fetch()
Package
1234567await shippoService.package.for(["line_items", [...lineItems]]).fetch()await shippoService.package.for(["cart", id]).fetch()await shippoService.package.for(["local_order", id]).fetch()await shippoService.package.for(["fulfillment", id]).fetch()
12345678910111213141516// use any parcel templatesconst packages = [{id: "id123",name: "My Package",length: "40",width: "30",height: "30",weight: "10000", // max-weight},{...}]shippoService.package.set("boxes", packages)await shippoService.package.for(["cart", id]).get()
Packingslip
1await shippoService.packingslip.fetch(object_id)
1await shippoService.packingslip.with(["fulfillment"]).fetch(object_id)
1234567await shippoService.packingslip.fetchBy(["fulfillment", id])await shippoService.packingslip.fetchBy(["local_order", id])await shippoService.packingslip.fetchBy(["claim", id])await shippoService.packingslip.fetchBy(["swap", id])
Track
1await shippoService.track.fetch("usps", "trackingnumber")
1await shippoService.track.fetchBy(["fulfillment", id])
Transaction
123await shippoService.transaction.fetch(object_id)await shippoService.transaction.fetch(object_id, { type: "extended" })
123456789101112131415await shippoService.transaction.fetchBy(["fulfillment", id])await shippoService.transaction.fetchBy(["fulfillment", id], { type: "extended" })await shippoService.transaction.fetchBy(["local_order", id])await shippoService.transaction.fetchBy(["local_order", id], { type: "extended" })await shippoService.transaction.fetchBy(["claim", id])await shippoService.transaction.fetchBy(["claim", id], { type: "extended" })await shippoService.transaction.fetchBy(["swap", id])await shippoService.transaction.fetchBy(["swap", id], { type: "extended" })
1await shippoService.is(["transaction", id], "return").fetch()
Client
1const client = shippoService.client
Find
12345/* @experimental */await shippoService.find("fulfillment").for(["transaction", id])await shippoService.find("order").for(["transaction", id])
Events
List of all events, their triggers, and expected payload for handlers
Subscribe to events to perform additional operations
These events only emit if the action pertains to Copy to clipboard
provider: shippo
shippo.order_created
Triggered when a new fulfillment creates a shippo order.
Payload
123456{order_id: "",fulfillment_id: "",customer_id: "",shippo_order: {...}}
shippo.return_requested
Triggered when a return is requested
If the return Copy to clipboard
ShippingMethod
has Copy to clipboardprovider: shippo
it attempts to find an existing return label in shippo.Payload
1234{order: {...}, // return ordertransaction: {...} // shippo transaction for return label OR null}
shippo.swap_created
Triggered when a swap is created
If return Copy to clipboard
ShippingMethod
has Copy to clipboardprovider: shippo
it attempts to find an existing return label in shippo.Payload
1234{order: {...}, // return ordertransaction: {...} // shippo transaction for return label OR null}
shippo.replace_order_created
If the Copy to clipboard
ShippingMethod
has Copy to clipboardprovider: shippo
a shippo order is createdPayload
123456{order_id: "",fulfillment_id: "",customer_id: "",shippo_order: {...}}
shippo.claim_refund_created
Triggered when a Copy to clipboard
type: refund
claim is createdIf return Copy to clipboard
ShippingMethod
has Copy to clipboardprovider: shippo
, it attempts to find an existing return label in shippoPayload
1234{order: {...}, // return ordertransaction: {...} // shippo transaction for return label OR null}
shippo.claim_replace_created
Triggered when a Copy to clipboard
type: replace
claim is createdIf return Copy to clipboard
ShippingMethod
has Copy to clipboardprovider: shippo
, it attempts to find an existing return label in shippoPayload
1234{order: {...}, // return ordertransaction: {...} // shippo transaction for return label OR null}
shippo.transaction_created.shipment
Triggered when the Copy to clipboard
transaction_created
webhook updates a Copy to clipboardFulfillment
status to Copy to clipboardshipped
Payload
12345{order_id: "",fulfillment_id: "",transaction: {...}}
shippo.transaction_created.return_label
Triggered when the Copy to clipboard
transaction_created
webhook receives a return label transactionPayload
1234{order_id: "",transaction: {...}}
shippo.transaction_updated.payload
Triggered when the Copy to clipboard
transaction_updated
webhook receives an updated transactionPayload
12345{order_id: "",fulfillment_id: "",transaction: {...}}
shippo.track_updated.payload
Triggered when the Copy to clipboard
track_updated
webhook receives an updated trackPayload
123{...track}
Shippo Node Client
This plugin is using a forked version of the official shippo-node-client.
The fork adds support for the following endpoints:
- live-rates
- service-groups
- user-parcel-templates
- orders/:id/packingslip
- ...
- Doc is WIP
The client is exposed on the Copy to clipboard
useClient
property of Copy to clipboardshippoClientService
123456789const client = shippoService.client// Forks additional methodsawait client.liverates.create({...parms})await client.userparceltemplates.list()await client.userparceltemplates.retrieve(id)await client.servicegroups.list()await client.servicegroups.create({...params})...
See Shippo API Reference for methods
Release Policy
Versioning
Follows Semantic versioning (semver) principles.
Breaking Changes
Breaking change refers to a backwards incompatible change to the public interface or core feature.
The public interface and core features are declared and defined in this document. Breaking changes will be announced in advance, and once released the major version number is incremented.
Undocumented API and internal data structures are considered implementation details and are subject to change without notice. In other words, you are on your own when relying on undocumented usage.
Limitations
No support for customs declarations. Planned for future release.
Resources
Medusa Docs\
https://docs.medusajs.com/
Medusa Shipping Architecture:\
https://docs.medusajs.com/advanced/backend/shipping/overview