ℹ️ 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.
- Getting Started
- Orders
- Packing Slips
- Returns
- Swaps
- Claims
- Rates at Checkout
- Webhooks
- Public Interface
- Events
- Shippo Node Client
- Release Policy
- Limitations
- Resources
Install:
> npm i medusa-fulfillment-shippo
Add to medusa-config.js
{
resolve: `medusa-fulfillment-shippo`,
options: {
api_key: SHIPPO_API_KEY,
weight_unit_type: 'g', // valid values: g, kg, lb, oz
dimension_unit_type: 'cm', // valid values: cm, mm, in
webhook_secret: '', // README section on webhooks before using!
webhook_test_mode: false
},
}
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:
shippo.order_created
{
order_id: "",
fulfillment_id: "",
customer_id: "",
shippo_order: {...}
}
await 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
const { object_id } = order
await 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
Invoked when Request a Return return_shipping
has provider: shippo
Attempts fetching an existing return label from shippo.
Event:
shippo.return_requested
{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}
Invoked when Create a Swap return_shipping
has provider: shippo
Attempts fetching an existing return label from shippo.
Event:
shippo.swap_created
{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}
Invoked when Create a Swap Fulfillment shipping_option
has provider: shippo
Creates an order in shippo.
Event:
shippo.replace_order_created
{
order_id: "",
fulfillment_id: "",
customer_id: "",
shippo_order: {...}
}
Invoked when Create a Claim has type: refund
and return_shipping
has provider: shippo
Attempts fetching an existing return label from shippo.
Event:
shippo.claim_refund_created
{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}
Invoked when Create a Claim has type: replace
and return_shipping
has provider: shippo
Attempts fetching an existing return label from shippo.
Event:
shippo.claim_replace_created
{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}
Invoked when Create a Claim Fulfillment shipping_option
has provider: shippo
Creates an order in shippo.
Event:
shippo.replace_order_created
{
order_id: "",
fulfillment_id: "",
customer_id: "",
shippo_order: {...}
}
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.
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
Create shipping options for regions as usual
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.
In your medusa store, make sure products have correct values for length, width, height, weight
Retrieve shipping options for cart as usual and any price_type: calculated
options belonging to provider: shippo
will have amount: Number
.
Rates calculate only if cart has shipping address and items
HTTP:
GET /store/shipping-options/:cart_id
Service:
const shippingOptions = await shippingProfileService.fetchCartOptions(cart)
Add a Shipping Method and if shipping_option
has price_type: calculated
the rate will be saved to the shipping_method
HTTP:
POST /store/carts/:id/shipping-methods
--data '{"option_id":"example_cart_option_id"}'
This is an issue with medusa-admin. Examine line 85 admin/src/domain/settings/regions/new-shipping.tsx
Options with price_type: flat_rate
will not pass through fulFillmentProviderService.calculatePrice()
medusa-admin is still early phase software.
Workaround it, use the REST api directly, or patch the issue for now
Possible interim solution:
price_type: (options[optionIndex].type === "LIVE_RATE")
? "calculated"
: "flat_rate",
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
In .env
add SHIPPO_WEBHOOK_SECRET=some_secret_string
Add to medusa-config.js
const SHIPPO_API_KEY = process.env.SHIPPO_API_KEY
const 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
},
},
Hooks need to be setup in Shippo app settings
transaction_created: /hooks/shippo/transaction?token=SHIPPO_WEBHOOK_SECRET
transaction_updated: /hooks/shippo/transaction?token=SHIPPO_WEBHOOK_SECRET
track_updated: /hooks/shippo/track?token=SHIPPO_WEBHOOK_SECRET
Then send a sample. If everything is good you will see this in console:
Processing shippo.received.transaction_created which has 0 subscribers
Processing 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 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 webhook_test_mode: true
in medusa-config.js
plugin options.
Running in test mode is a security risk, enable only for testing purposes.
/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
shippo.transaction_created.shipment
{
order_id: "",
fulfillment_id: "",
transaction: {...}
}
shippo.transaction_created.return_label
{
order_id: "",
transaction: {...}
}
/hooks/shippo/transaction?token=SHIPPO_WEBHOOK_SECRET
Receives shippo transaction object when transaction updated
shippo.transaction_updated.payload
{
order_id: "",
fulfillment_id: "",
transaction: {...}
}
/hooks/shippo/track?token=SHIPPO_WEBHOOK_SECRET
shippo.track_updated.payload
{
...track
}
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.
Dependency inject shippoService
as you would with any other service
For guide, see Using Custom Service
Fetch default sender address
Promise.<object>
await shippoService.account.address()
Fetch an order from shippo
Name | Type | Description |
---|---|---|
id | String |
The object_id for an order |
Promise.<object>
await shippoService.order.fetch(object_id)
Fetch a shippo order with a related entity.
Name | Type | Description |
---|---|---|
id | String |
The object_id for an order |
entity | Array.<string> |
The entity to attach |
fulfillment
Promise.<object>
await shippoService.order.with(["fulfillment"]).fetch(object_id)
/* @return */
{
...order,
fulfillment: {
...fulfillment
}
}
Fetch a shippo order using the id of a related entity
@param {[entity: string, id: string>]}
Name | Type | Description |
---|---|---|
entity | string |
The entity type to fetch order by |
id | string |
Id of the entity |
fulfillment
local_order
claim
swap
Promise.<object|object[]>
/* @return {Promise.<object>} */
await shippoService.order.fetchBy(["fulfillment", id])
/* @return {Promise.<object[]>} */
await shippoService.order.fetchBy(["local_order", id])
await shippoService.order.fetchBy(["claim", id])
await shippoService.order.fetchBy(["swap", id])
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
@param {[entity: string, id: string>]}
Name | Type | Description |
---|---|---|
entity | string |
Entity type |
id | items | string|array |
cart
local_order
fulfillment
line_items
Promise.<object[]>
// use parcel templates defined in shippo account
await 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()
package.set("boxes", [...packages])
const 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()
...
Fetch the packingslip for shippo order
Name | Type | Description |
---|---|---|
id | String |
The object_id of the order to get packingslip for |
Promise.<object>
const { object_id } = order
await shippoService.packingslip.fetch(object_id)
Fetch the packingslip for shippo order with a related entity.
Name | Type | Description |
---|---|---|
id | String |
The object_id of the order to get packingslip for |
entity | Array.<string> |
The entity to attach |
fulfillment
Promise.<object>
await shippoService.packingslip.with(["fulfillment"]).fetch(object_id)
/* @return */
{
...packingslip,
fulfillment: {
...fulfillment
}
}
Fetch the packing slip for a shippo order, using the id of a related entity
@param {[entity: string, id: string>]}
Name | Type | Description |
---|---|---|
entity | string |
The entity type to fetch packingslip by |
id | string |
Id of the entity |
fulfillment
local_order
claim
swap
Promise.<object|object[]>
/* @return {Promise.<object>} */
await shippoService.packingslip.fetchBy(["fulfillment", id])
/* @return {Promise.<object[]>} */
await shippoService.packingslip.fetchBy(["local_order", id])
await shippoService.packingslip.fetchBy(["claim", id])
await shippoService.packingslip.fetchBy(["swap", id])
Fetch a tracking status object
Name | Type | Description |
---|---|---|
carrier_enum | string |
The carrier enum token |
track_num | string |
The tracking number |
Promise.<object>
await shippoService.track.fetch("usps", "trackingnumber")
Fetch a tracking status object using id of related entity
@param {[entity: string, id: string>]}
Name | Type | Description |
---|---|---|
entity | string |
The entity type to fetch tracking status by |
id | string |
Id of the entity |
fulfillment
Promise.<object>
await shippoService.track.fetchBy(["fulfillment", id])
Fetch a transaction object from shippo.
To fetch an extended version with additional fields, use transaction.fetch(id, { type: extended})
Name | Type | Description |
---|---|---|
id | String |
The object_id for transaction |
Promise.<object>
await shippoService.transaction.fetch(object_id)
await shippoService.transaction.fetch(object_id, { type: "extended" })
Fetch a transaction using the id of a related entity
@param {[entity: string, id: string>]}
Name | Type | Description |
---|---|---|
entity | string |
The entity type to fetch transaction by |
id | string |
Id of the entity |
fulfillment
local_order
claim
swap
Promise.<object|object[]>
await 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" })
await shippoService.is(["transaction", id], "return").fetch()
await shippoService.is(["order", id], "replace").fetch()
shippo-node-client
(forked)
const client = shippoService.client
/* @experimental */
await shippoService.find("fulfillment").for(["transaction", id])
await shippoService.find("order").for(["transaction", id])
await shippoService.account.address()
await shippoService.order.fetch(object_id)
await shippoService.order.with(["fulfillment"]).fetch(object_id)
await shippoService.order.fetchBy(["fulfillment", id])
await shippoService.order.fetchBy(["local_order", id])
await shippoService.order.fetchBy(["claim", id])
await shippoService.order.fetchBy(["swap", id])
await shippoService.is(["order", id], "replace").fetch()
await 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()
// use any parcel templates
const 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()
await shippoService.packingslip.fetch(object_id)
await shippoService.packingslip.with(["fulfillment"]).fetch(object_id)
await shippoService.packingslip.fetchBy(["fulfillment", id])
await shippoService.packingslip.fetchBy(["local_order", id])
await shippoService.packingslip.fetchBy(["claim", id])
await shippoService.packingslip.fetchBy(["swap", id])
await shippoService.track.fetch("usps", "trackingnumber")
await shippoService.track.fetchBy(["fulfillment", id])
await shippoService.transaction.fetch(object_id)
await shippoService.transaction.fetch(object_id, { type: "extended" })
await 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" })
await shippoService.is(["transaction", id], "return").fetch()
const client = shippoService.client
/* @experimental */
await shippoService.find("fulfillment").for(["transaction", id])
await shippoService.find("order").for(["transaction", id])
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 provider: shippo
Triggered when a new fulfillment creates a shippo order.
{
order_id: "",
fulfillment_id: "",
customer_id: "",
shippo_order: {...}
}
Triggered when a return is requested
If the return ShippingMethod
has provider: shippo
it attempts to find an existing return label in shippo.
{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}
Triggered when a swap is created
If return ShippingMethod
has provider: shippo
it attempts to find an existing return label in shippo.
{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}
Triggered when a swap or claim fulfillment is created.
If the ShippingMethod
has provider: shippo
a shippo order is created
{
order_id: "",
fulfillment_id: "",
customer_id: "",
shippo_order: {...}
}
Triggered when a type: refund
claim is created
If return ShippingMethod
has provider: shippo
, it attempts to find an existing return label in shippo
{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}
Triggered when a type: replace
claim is created
If return ShippingMethod
has provider: shippo
, it attempts to find an existing return label in shippo
{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}
Triggered when the transaction_created
webhook updates a Fulfillment
status to shipped
{
order_id: "",
fulfillment_id: "",
transaction: {...}
}
Triggered when the transaction_created
webhook receives a return label transaction
{
order_id: "",
transaction: {...}
}
Triggered when the transaction_updated
webhook receives an updated transaction
{
order_id: "",
fulfillment_id: "",
transaction: {...}
}
Triggered when the track_updated
webhook receives an updated track
{
...track
}
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 useClient
property of shippoClientService
const client = shippoService.client
// Forks additional methods
await 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
Follows Semantic versioning (semver) principles.
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.
No support for customs declarations. Planned for future release.
Medusa Docs
https://docs.medusajs.com/
Medusa Shipping Architecture:
https://docs.medusajs.com/advanced/backend/shipping/overview
Shippo API
https://goshippo.com/docs/intro\
https://goshippo.com/docs/reference