Built by

macder

Category

Shipping

Version

1.1.5

Last updated

Jun 21, 2024, 16:39:20 PM6 months ago

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

Install:
Copy to clipboard> 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
},
}

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 clipboardshippo.order_created
{
order_id: "",
fulfillment_id: "",
customer_id: "",
shippo_order: {...}
}

Retrieve

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

Packing Slips

Retrieve

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

Returns

Request

Invoked when Request a Return Copy to clipboardreturn_shipping has Copy to clipboardprovider: shippo
Attempts fetching an existing return label from shippo.
Event: Copy to clipboardshippo.return_requested
{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}

Swaps

Create

Invoked when Create a Swap Copy to clipboardreturn_shipping has Copy to clipboardprovider: shippo
Attempts fetching an existing return label from shippo.
Event: Copy to clipboardshippo.swap_created
{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}

Fulfillment

Invoked when Create a Swap Fulfillment Copy to clipboardshipping_option has Copy to clipboardprovider: shippo
Creates an order in shippo.
Event: Copy to clipboardshippo.replace_order_created
{
order_id: "",
fulfillment_id: "",
customer_id: "",
shippo_order: {...}
}

Claims

Refund

Invoked when Create a Claim has Copy to clipboardtype: refund and Copy to clipboardreturn_shipping has Copy to clipboardprovider: shippo
Attempts fetching an existing return label from shippo.
Event: Copy to clipboardshippo.claim_refund_created
{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}

Replace

Invoked when Create a Claim has Copy to clipboardtype: replace and Copy to clipboardreturn_shipping has Copy to clipboardprovider: shippo
Attempts fetching an existing return label from shippo.
Event: Copy to clipboardshippo.claim_replace_created
{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}

Fulfillment

Invoked when Create a Claim Fulfillment Copy to clipboardshipping_option has Copy to clipboardprovider: shippo
Creates an order in shippo.
Event: Copy to clipboardshippo.replace_order_created
{
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>)
  1. Standard Shipping Canada
  2. Express Shipping Canada
  3. Standard Shipping USA
  4. 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

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 clipboardprice_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:
GET /store/shipping-options/:cart_id
Service:
const shippingOptions = await shippingProfileService.fetchCartOptions(cart)

Add to Cart

Add a Shipping Method and if Copy to clipboardshipping_option has Copy to clipboardprice_type: calculated the rate will be saved to the Copy to clipboardshipping_method
HTTP:
POST /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 clipboardadmin/src/domain/settings/regions/new-shipping.tsx
Options with Copy to clipboardprice_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:
price_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:
  1. Webhook receives POST data
  2. URL query token is verified
  3. The request json gets verified by fetching the same object directly from shippo API, following these steps:
    1. Request body contains json claiming to be a shippo object. 
    2. Ok, but lets fetch this object directly from Shippo's API
    3. If the fetch resolves to the object requested, then use that data instead of the untrusted input 
    4. 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 clipboardmedusa-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
},
},

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:
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

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 clipboardwebhook_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 clipboardshippo.transaction_created.shipment
{
order_id: "",
fulfillment_id: "",
transaction: {...}
}
Copy to clipboardshippo.transaction_created.return_label
{
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 clipboardshippo.transaction_updated.payload
{
order_id: "",
fulfillment_id: "",
transaction: {...}
}

track_updated

Copy to clipboard/hooks/shippo/track?token=SHIPPO_WEBHOOK_SECRET

Events

Copy to clipboardshippo.track_updated.payload
{
...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 clipboardshippoService as you would with any other service
For guide, see Using Custom Service

account.address()

Fetch default sender address

Return

Copy to clipboardPromise.<object>

Example

await shippoService.account.address()

order.fetch(id)

Fetch an order from shippo

Parameters

NameTypeDescription
idCopy to clipboardStringThe object_id for an order

Return

Copy to clipboardPromise.<object>

Example

await shippoService.order.fetch(object_id)

order.with([entity]).fetch(id)

Fetch a shippo order with a related entity.

Parameters

NameTypeDescription
idCopy to clipboardStringThe object_id for an order
entityCopy to clipboardArray.<string>The entity to attach

Supported Entities

Copy to clipboardfulfillment

Return

Copy to clipboardPromise.<object>

Example

await 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>]}
NameTypeDescription
entityCopy to clipboardstringThe entity type to fetch order by
idCopy to clipboardstringId of the entity

Supported Entities

Copy to clipboardfulfillment Copy to clipboardlocal_order Copy to clipboardclaim Copy to clipboardswap

Return

Copy to clipboardPromise.<object|object[]>

Example

/* @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])

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

Parameters

Copy to clipboard@param {[entity: string, id: string>]}
NameTypeDescription
entityCopy to clipboardstringEntity type
iditemsCopy to clipboardstring\|arrayThe id of {[entity]} or array of items

Supported Entities

Copy to clipboardcart Copy to clipboardlocal_order Copy to clipboardfulfillment Copy to clipboardline_items

Return

Copy to clipboardPromise.<object[]>

Example

// 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()

Override Parcel Templates

Copy to clipboardpackage.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()
...

packingslip.fetch(id)

Fetch the packingslip for shippo order

Parameters

NameTypeDescription
idCopy to clipboardStringThe object_id of the order to get packingslip for

Return

Copy to clipboardPromise.<object>

Example

const { object_id } = order
await shippoService.packingslip.fetch(object_id)

packingslip.with([entity]).fetch(id)

Fetch the packingslip for shippo order with a related entity.

Parameters

NameTypeDescription
idCopy to clipboardStringThe object_id of the order to get packingslip for
entityCopy to clipboardArray.<string>The entity to attach

Supported Entities

Copy to clipboardfulfillment

Return

Copy to clipboardPromise.<object>

Example

await 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>]}
NameTypeDescription
entityCopy to clipboardstringThe entity type to fetch packingslip by
idCopy to clipboardstringId of the entity

Supported Entities

Copy to clipboardfulfillment Copy to clipboardlocal_order Copy to clipboardclaim Copy to clipboardswap

Return

Copy to clipboardPromise.<object|object[]>

Example

/* @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])

track.fetch(carrier_enum, track_num)

Fetch a tracking status object

Parameters

NameTypeDescription
carrier_enumCopy to clipboardstringThe carrier enum token
track_numCopy to clipboardstringThe tracking number

Return

Copy to clipboardPromise.<object>

Example

await 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>]}
NameTypeDescription
entityCopy to clipboardstringThe entity type to fetch tracking status by
idCopy to clipboardstringId of the entity

Supported Entities

Copy to clipboardfulfillment

Return

Copy to clipboardPromise.<object>

Example

await 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 clipboardtransaction.fetch(id, { type: extended})
NameTypeDescription
idCopy to clipboardStringThe object_id for transaction

Return

Copy to clipboardPromise.<object>

Example

await 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>]}
NameTypeDescription
entityCopy to clipboardstringThe entity type to fetch transaction by
idCopy to clipboardstringId of the entity

Supported Entities

Copy to clipboardfulfillment Copy to clipboardlocal_order Copy to clipboardclaim Copy to clipboardswap

Return

Copy to clipboardPromise.<object|object[]>

Example

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" })

Misc

is([entity, id], attr).fetch()

await shippoService.is(["transaction", id], "return").fetch()
await shippoService.is(["order", id], "replace").fetch()

Client

Copy to clipboardshippo-node-client (forked)
const client = shippoService.client

find(entity).for([entity, id])

/* @experimental */
await shippoService.find("fulfillment").for(["transaction", id])
await shippoService.find("order").for(["transaction", id])

Quick Reference

Account

await shippoService.account.address()

Order

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()

Package

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()

Packingslip

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])

Track

await shippoService.track.fetch("usps", "trackingnumber")
await shippoService.track.fetchBy(["fulfillment", id])

Transaction

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()

Client

const client = shippoService.client

Find

/* @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 clipboardprovider: shippo

shippo.order_created

Triggered when a new fulfillment creates a shippo order.

Payload

{
order_id: "",
fulfillment_id: "",
customer_id: "",
shippo_order: {...}
}

shippo.return_requested

Triggered when a return is requested
If the return Copy to clipboardShippingMethod has Copy to clipboardprovider: shippo it attempts to find an existing return label in shippo.

Payload

{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}

shippo.swap_created

Triggered when a swap is created
If return Copy to clipboardShippingMethod has Copy to clipboardprovider: shippo it attempts to find an existing return label in shippo.

Payload

{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}

shippo.replace_order_created

Triggered when a swap or claim fulfillment is created.
If the Copy to clipboardShippingMethod has Copy to clipboardprovider: shippo a shippo order is created

Payload

{
order_id: "",
fulfillment_id: "",
customer_id: "",
shippo_order: {...}
}

shippo.claim_refund_created

Triggered when a Copy to clipboardtype: refund claim is created
If return Copy to clipboardShippingMethod has Copy to clipboardprovider: shippo, it attempts to find an existing return label in shippo

Payload

{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}

shippo.claim_replace_created

Triggered when a Copy to clipboardtype: replace claim is created
If return Copy to clipboardShippingMethod has Copy to clipboardprovider: shippo, it attempts to find an existing return label in shippo

Payload

{
order: {...}, // return order
transaction: {...} // shippo transaction for return label OR null
}

shippo.transaction_created.shipment

Triggered when the Copy to clipboardtransaction_created webhook updates a Copy to clipboardFulfillment status to Copy to clipboardshipped

Payload

{
order_id: "",
fulfillment_id: "",
transaction: {...}
}

shippo.transaction_created.return_label

Triggered when the Copy to clipboardtransaction_created webhook receives a return label transaction

Payload

{
order_id: "",
transaction: {...}
}

shippo.transaction_updated.payload

Triggered when the Copy to clipboardtransaction_updated webhook receives an updated transaction

Payload

{
order_id: "",
fulfillment_id: "",
transaction: {...}
}

shippo.track_updated.payload

Triggered when the Copy to clipboardtrack_updated webhook receives an updated track

Payload

{
...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 clipboarduseClient property of Copy to clipboardshippoClientService
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

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

Build your own plugins

Develop your own plugins with our API to speed up your processes.

Make your plugin available via npm for it to be shared in our Plugin Library with the broader Medusa community.