Rate Card Manager
The Rate Card Manager (RCM) is a system that allows customers to bring their own rate cards for use in ShipEngine. For a carrier to support this, it needs to use Native Rating and have its rating logic written so that it can handle the pre-defined data and key formats generated by the RCM. After familiarizing yourself with building a Native Rating implementation and building the rating logic, continue on to learn about the details required to support the RCM.
Overview
The Rate Card Manager makes some assumptions about what rate cards look like and how they work. The data is treated a hierarchy.
Rate hierarchy:
- Origin location
- Service
- Zone
- Package Type
- Weight Band
- Rate
Surcharge hierarchy:
- Origin location
- Service
- Zone
The Origin location
will either be a country code, like GB
or US
, or a postal code/country code combination, like LS12JS-GB
or 63123-US
. A rate card may have both origin location types defined and the carrier logic needs to define what to do in this situation. The two most straightforward approaches to dealing with this would be to either use the most specific origin or use whichever would result in the better rate.
Service
will be restricted to what is defined in the ShippingServices
property of the carrier definition. The value that will be used comes from the Code
property of each service.
{
"Carriers": [
{
"ShippingServices": [
{
"Id": "198cd634-f562-4b5a-ac64-0558f1e0767a",
"Name": "Overnight Express Service",
"Code": "OV_1", // <-- This value will be used by the RCM
"Abbreviation": "OES"
}
]
}
]
}
Zone
will be restricted to what is defined in the Zones
property of the carrier definition. The value that will be used comes from the ApiCode
property of each zone. The zone data provided by the carrier must also use this value so that when a zone lookup is done, the value returned matches what the RCM provides.
{
"Carriers": [
{
"Zones": [
{
"Name": "United Kingdom",
"ApiCode": "uk" // <-- This value will be used by the RCM
}
]
}
]
}
Package Type
will be restricted to what is defined in the PackageRatingGroups
property of the carrier definition. The two important values are Id
and CarrierPackageTypeCode
. The shipment to be rated will use the CarrierPackageTypeCode
and the rating logic should return this value as well. Internally, however, the data provided by the RCM will use Id
. This is because there may be multiple package rating groups associated with a single package type.
{
"Carriers": [
{
"PackageRatingGroups": [
{
"Id": "sm_box", // <-- This value will be used by the RCM
"Name": "Small Box",
"PackageTypeId": "a0bd78f2-601b-4f38-b4b7-7a30e3e0d279",
"CarrierPackageTypeCode": "small-box" // <-- This value is used by the `RateShipments` interface
}
]
}
]
}
Weight Band
data is scoped to a service type. The weight unit to be used for each weight band will be included in the data, as will the currency that should be used for all associated rates. The packageTypes
property is an object with an entry for each package type that has rates for the given service. The value of each package type entry is an array of weight bands, which will take one of two forms. It can either be a weight range or an incremental weight band. A weight band definition for a given shipment may include both and it is up to the logic to determine what to do in this case. This is common for rate cards that define rates for ranges up to a certain weight and then switch to an incremental rate above that weight. If the applicable weight band is a range, the key
value should be used for building a rating key. The range itself will be an array with the maximum value first and an optional minimum value second. If the minimum value is not specified, the maximum value of the previous range should be used as the minimum and if it is the first entry, it means there is no minimum. The maximum value should be considered inclusive while minimum is exclusive. So if there are two ranges [2,1] and [3,2]
and the shipment weight is 2, the first band should be used.
{
"currency": "GBP",
"unit": "kilograms",
"packageTypes": {
"sm_box": [
{
"type": "range",
"key": "2kg", // <-- This value should be used to build the rating key
"range": [2, 1]
},
],
},
}
Walkthrough
This walkthrough assumes that you've created a Connect Carrier app that includes Native Rating, as described in the implementation documentation. It will focus on the differences between a regular Native Rating implementation and one that supports the Rate Card Manager.
Update the carrier definition
The Rate Card Manager needs some extra data in the carrier definition that is not supplied by default. The definitions of the properties mentioned below are in the carrier metadata definition, but examples will be provided here.
ApiCode
If the carrier ApiCode
property is not set, a guid will be used instead. The code is then manually changed later in the deployment process. This is normally fine, but it makes testing with the Rate Card Manager more difficult because the carrier association can be lost when the ApiCode changes. Therefore, it is best to set it before the first publish. If you are unsure what this value should be, please contact a member of the ShipEngine Connect Team for guidance.
PackageRatingGroups
The PackageRatingGroups
property defines which package types are available to the Rate Card Manager as well as weight and dimension limits. The PackageTypeId
and CarrierPackageTypeCode
must point to a valid entry in the PackageTypes
property, and multiple PackageRatingGroups
may point to the same PackageType
. This would allow a carrier to define a single package type for use in ShipStation or ShipEngine, but that could have multiple different weight or dimension limits depending on the service. Users of ShipStation or ShipEngine will get the list defined in PackageTypes
but users entering their rate card data will get the list defined in PackageRatingGroups
.
{
Carriers: [
{
PackageRatingGroups: [
{
Id: 'sm_box',
Name: 'Small Box',
PackageTypeId: '7cd4bee2-4859-46fa-a191-dcc8a1659ae2',
CarrierPackageTypeCode: 'small-box',
DimensionLimits: {
Unit: DimensionUnit.Centimeters,
Length: 30,
Width: 10,
Height: 10,
},
MaxWeight: {
Unit: WeightUnit.Kilograms,
Value: 10,
},
}]
}]
}
Zones
The Zones
property defines all possible zones available to a carrier. When a user enters their rate card data, they will select their zones from this list. The data that drives what zone should be used for a given shipment will be provided through the normal Native Rating mechanism and can use whatever scheme fits the carrier's needs. The RCM will use the ApiCode
from this list when building rating keys.
{
Carriers: [
{
Zones: [
{
Name: "United Kingdom",
ApiCode: "uk"
},
{
Name: "Mainland Europe",
ApiCode: "eu"
}]
}]
}
SupportsUserManagedRates
Finally, the NativeRating.SupportsUserManagedRates
must be set to true
. The RCM uses this value to determine whether a user is allowed to manage their own rates for a carrier.
{
Carriers: [
{
NativeRating: {
SupportsUserManagedRates: true,
Path: "/path/to/carrier/implementation"
}
}]
}
Add Native Rating variable and rate data
This is an optional step because the Rate Card Manager will be providing the data for users' rate cards. However, it can be very helpful when testing to have a rate card already in the system that can be changed quickly and easily. This will also allow you to create a carrier that can provide retail rates by default but let customers override those rates with their own. Even if you don't want a test rate card and don't want to provide retail rates, it is worth reviewing this step as it will provide examples of the data structures that the RCM will provide.
All of the following key definitions will start with a value <origin>
. This corresponds to the origin location
value specified above.
Services
For each rate card, the RCM will create a list of services included in that rate card. The key format is <origin>-services
and the value is an object whose keys are the list of services used in the rate card and whose values are the zones used for that service. The service keys use the Code
field of the ShippingService
entry. The zones are provided to reduce the data that needs to be retrieved. If a carrier has 30 possible zones, but a user only adds rates for two of them, only those two zones will be included in the list.
{
"<origin>-services": {
"OV_1": ["uk", "eu"],
"GND_1": ["uk"]
}
}
Packages
The RCM will also provide a list of packages used by the rate card. They key format is <origin>-package_types
and the value is an object whose keys are the list of packages and whose values are the list of package rating groups associated with those packages. The package keys use the CarrierPackageTypeCode
field of the PackageType
entry. The values are objects with an id
field that corresponds to the Id
field of PackageRatingGroup
entry. It may also include dimensionLimits that should be used for that package rating group.
{
"GB-package_types": {
"small-box": [
{
"id": "sm_box",
"dimensionLimits": {
"unit": "centimeters",
"length": 30,
"width": 20,
"height": 10,
"girth": 45,
"noTwoSides": 25,
"lengthPlusGirth": 60,
"volume": 100,
},
"maxWeight": {
"unit": "kilograms",
"value": "10"
}
}],
"large-box": [
{ "id": "lg_box" },
{ "id": "oversize_box" },
]
}
}
Weight Bands
Weight bands are the last variables that the RCM will provide based on the customer rate cards. The key format is <origin>-weight_bands-<service>-<zone>
. The value is an object that specifies the weight unit used by the weight bands and a list of weight bands for each package type. The keys of the package type list correspond to the Id
property of its corresponding PackageRatingGroup
retrieved from the packages
variable.
{
"GB-weight_bands-OV_1-uk": {
"currency": "GBP",
"unit": "kilograms",
"packageTypes": {
"sm_box": [
{ "type": "range", "key": "2kg", "range": [2, 0] },
{ "type": "range", "key": "10kg", "range": [10, 2] }
],
"lg_box": [
{ "type": "range", "key": "10kg", "range": [10, 2] },
{
"type": "incremental",
"increment": 1,
"amountPerIncrement": 0.95,
"baseCost": 25.45
}
],
},
}
}
Surcharges
Surcharges are optional and may not be supplied by the RCM if users don't enter any or if your carrier does not support them. Surcharges can be defined at the rate card level, service level, or zone level. Surcharges from any or all levels may apply to a given shipment. The key formats are <origin>-surcharges
, <origin>-surcharges-<service>
, and <origin>-surcharges-<service>-<zone>
, which correspond to the various levels of the hierarchy to which the surcharges apply. The value is an array of surcharges that include a code
field, an optional fix_amount
field for fixed surcharges, and an optional percentage_amount
field for percentage based surcharges.
{
"GB-surcharges": [
{ "code": "COD", "fix_amount": 1.95 }
],
"GB-surcharges-OV_1": [
{ "code": "handling", "fix_amount": 3.50 },
{ "code": "fuel surcharge", "percentage_amount": 0.29 }
]
}
Rates
This data contains the actual rate values entered by the customer for their rate card. The RCM will provide the data as simple key/value pairs where the key has the format <origin>-<service>-<zone>-<package>-<weight_band_key>
and the value is a string representation of a number. When your rating logic gets the rate, it will be as an actual number value but if you provide the rates for a test rate card, it must be as a string.
{
{ "key": "GB-OV_1-uk-sm_box-10kg", "value": "5.99" },
{ "key": "GB-OV_1-uk-lg_box-20kg", "value": "9.49" },
{ "key": "GB-OV_1-uk-sm_box-2kg", "value": "4.49" },
{ "key": "GB-OV_1-eu-sm_box-10kg", "value": "4.99" },
{ "key": "GB-OV_1-eu-sm_box-2kg", "value": "3.49" },
{ "key": "GB-GND_1-eu-sm_box-10kg", "value": "6.95" },
{ "key": "GB-GND_1-eu-sm_box-2kg", "value": "5.25" }
}
Add Native Rating zone data
The Rate Card Manager needs zones for rating but how your rating logic deals with that is up to you. If your carrier doesn't use zones, then you can create a single zone in the carrier definition and just hard code that key in your logic. If your zone scheme is simple, like domestic
if the country codes match and international
if they don't, your logic can take care of that and you can skip adding Native Rating zone data. Most likely, you will need to make use of the zone data lookup provided by Native Rating and it would be worth spending some time deciding on how that will work. The getZone
function provided by Native Rating can only look up full keys, so you can't do any "starts with" or "contains" queries.
Designing the best scheme for your zone data is beyond the scope of this document, but we'll discuss a few common scenarios.
If your zones are based on origin and destination postal code, it can be impractical to store every combination of all postal codes. Instead, it may be possible to store all combinations of the first few characters of the postal code and then store exceptions using the full postal code. Doing this means that there is much less data to store, but it means you'll need to request more zone keys. So instead of searching for 90210-10001
, you'd need to search for 902-100
, 902-10001
, 90210-100
, and 90210-10001
. Then you can use the most specific result as the zone.
Another option would be to store lookup information in the zone value itself. Native Rating does not impose a data structure on the zone value so you could create a zone where the key is US
and the value is { "east": [0,50000], "west": [50001,99999] }
. Then you would request the US
zone and when you get the result, iterate through the keys and check whether the destination postal code is within the range. This works well for simple scenarios like this where the ranges can include large numbers of postal codes, but it's much less suited to situations where the zone data would contain a large amount of data.
Create Native Rating logic
The rating logic for carriers that support the RCM should follow this basic flow:
- Fetch the services, packages, and zone for the shipment
- Fetch the weight band data based on this information
- Find the applicable weight band(s)
- Fetch rates and surcharges
- Return the list of applicable rates and surcharges
The logic can do more than this, like validating data, but that is outside the scope of this document. This will just focus on the most basic process for creating rates from a customer supplied rate card. When providing code examples, the following shipment will be used:
{
"ship_from": {
"postal_code": "LS1 2JS",
"country_code": "GB"
},
"ship_to": {
"postal_code": "SL1 3QG",
"country_code": "GB"
},
"packages": [{
"weight_details": {
"weight_in_grams": 5000,
"weight_in_ounces": 176.37,
"source_weight": 5000,
"source_weight_unit": "grams"
},
"dimension_details": {
"dimensions_in_centimeters": { "length": 32, "width": 10, "height": 5 },
"dimensions_in_inches": { "length": 12.6, "width": 3.94, "height": 1.97 },
"source_dimensions": { "length": 32, "width": 10, "height": 5 },
"source_unit": "centimeters"
}
}]
}
Fetch the services, packages, and zone
The first step is to request the preliminary data needed by the rest of the logic. Notice that the services
and package_types
keys are requested twice: once with the postalCode-country
prefix and once with just country
. This is because rate cards can be created using either as the origin, and both must be accounted for. You must keep track of which origin data came from because data from a key with the postalCode-country
prefix would be from a different rate card than data from a key with the country
prefix. This will apply to all data fetches.
const [ variableData, zoneData ] = await Promise.all([
context.getVariables([
"LS12JS-GB-services",
"LS12JS-GB-package_types",
"GB-services",
"GB-package_types"
]),
context.getZone(["GB-GB"])
]);
If there are no services or packages returned, it should be treated as an error condition. However, it is very likely that services and packages will only be returned for a single origin prefix.
if (
(variableData["LS12JS-GB-services"] && variableData["LS12JS-GB-package_types"]) ||
(variableData["GB-services"] && variableData["GB-package_types"])) {
// We have services and packages for at least one origin prefix
} else {
throw new Error("Could not find data for rate card")
}
Zones are not rate card specific so as long as something is returned, it should be treated as a success.
if (!zoneData["GB-GB"]) {
throw new Error("Could not find zone");
}
Once services, packages, and zones are retrieved, the logic can move on to the next step. For the example code, we will assume that there is a valid rate card with the country
origin prefix but not the postalCode-country
prefix. This will make the examples simpler, but remember that it is possible for data to be returned for both and that needs to be handled.
Fetch the weight band data
Now we need to fetch the weight band data for the rate card. We will assume the following was returned from the previous step:
const variableData = {
// Services
"GB-services": [{
"OV_1": ["uk", "eu"],
"GND_1": ["uk"],
"INT-1": ["eu"]
}],
// Packages
"GB-package_types": {
"sm_box": [ // <-- This corresponds to the `CarrierPackageTypeCode` property of the PackageRatingGroup entry
{
id: "small-box", // <-- This corresponds to the `Id` property of the PackageRatingGroup entry
dimensionLimits: {
unit: "centimeters",
length: 30
}
}],
"lg_box": [
{
id: "large-box",
maxWeight: {
unit: "kilograms",
value: "10"
}
},
{ id: "oversize-box" },
]
}
}
const zoneData = {
// Zone
"GB-GB": "uk"
}
When getting the weight bands, we can use the service list to filter what we need to request. You can see that the INT-1
service only applies to the eu
zone, but this shipment is in the uk
zone. So we can skip getting weight band data for that service.
const weightBands = await context.getVariables([
"GB-weight_bands-OV_1-uk",
"GB-weight_bands-GND_1-uk"
])
Just like before, if at least one set of weight bands is not returned, it should be treated as an error condition.
Find the applicable weight band(s)
Once we have the weight bands, we have all the data needed to finally get rates. Before we do that, however, we need to figure out which rate keys to fetch. In this example, we'll assume that we got the following weight band data:
const weightBands = {
"GB-weight_bands-GND_1-uk": {
currency: "GBP",
unit: "kilograms",
packageTypes: {
"sm_box": [
{ type: "range", key: "2kg", range: [2, 0] },
{ type: "range", key: "10kg", range: [10, 2] }
],
"lg_box": [
{ type: "range", key: "10kg", range: [10, 2] },
{
type: "incremental",
increment: 2,
amountPerIncrement: 0.95,
baseCost: 25.45
}
],
},
}
}
It is normal that even through two keys were requested, data for only one was returned. Your logic should handle that scenario. Since no weight bands were returned for the OV-1
service, it can be assumed that the service does not apply to this shipment. The logic should use the package types to find which bands fit the shipment. The package data fetched in the first step can be used to filter out package types that may not be applicable. For example, if the shipment has a length of 32cm, the sm_box
package type can be skipped because it had a maximum length of 30cm. In that case, we would just use the lg_box
package type.
If the shipment was under 10kg, the first weight band would apply. The key
property would be used to generate the rate key to fetch:
const rateData = await context.getRates([
"GB-GND_1-uk-lg_box-10kg"
])
If the shipment was over 10kg, the second weight band would apply. A rate would not need to be fetched because the weight band is incremental
. Instead, the rate can be calculated immediately. The shipment weight should be rounded up to the next increment
value and then multiplied by the amountPerIncrement
. For example, if the shipment is 16.7kg, we would use an incremental weight of 18kg because the increment amount is 2. So 18 multiplied by 0.95 is 17.1. This is the incremental part of the rate. It should be added to the baseCost
of the weight band, which may be zero, to get the final shipping rate. In this example, 17.1 should be added to the baseCost of 25.45 for a total of 42.55.
What should happen if the shipment was under 30cm? The shipment would now fit within a small box so both package types would apply. It is possible for the carrier logic to pick one based on some rules and return that, but it would be preferable to return rates for both package types so that the shipper can choose which rate they want to use.
Fetch rates and surcharges
Once all the required rate keys have been figured out, it's time to actually get the rate data. Surcharges can also be fetched at this point.
const [ surchargeData, rateData ] = await Promise.all([
context.getVariables([
"GB-surcharges",
"GB-surcharges-GND_1",
"GB-surcharges-GND_1-uk",
]),
context.getRates(["GB-GND_1-uk-lg_box-10kg"])
]);
Return the list of applicable rates and surcharges
The last step in the process is to use the rate data to build the list of rates available to the shipper. This example will assume the data request from the previous step returned the following data.
const surchargeData = {
"GB-surcharges-GND_1": [
{ code: "fuel", "percent_amount": "0.32" },
{ code: "convenience", "fix_amount": "3.95" }
]
};
const rateData = {
"GB-GND_1-uk-lg_box-10kg": {
currency: "GBP",
amount: 19.95
}
};
Given these results, we would expect a single rate returned with multiple billing line items. Your rating logic can control how surcharges may or may not apply, but the simplest approach would be to assume the surcharge applies if data is returned. In that case, a convenience
line item would be added for 3.95
, and a fuel
line item would be added for 6.38
, which is 32% of the 19.95
shipping cost. The result would look like this:
return {
rates: [
{
service_code: "GND_1",
billing_line_items: [{
billing_category: BillingCategories.Shipping,
carrier_billing_code: "shipping", // Arbitrary value that makes sense to the carrier
amount: { currency: "GBP", amount: "19.95" }
},
{
billing_category: BillingCategories.Uncategorized,
carrier_billing_code: "fuel", // Value can be pulled from the `code` property of the surcharge data
amount: { currency: "GBP", amount: "6.38" }
},
{
billing_category: BillingCategories.Uncategorized,
carrier_billing_code: "convenience", // Value can be pulled from the `code` property of the surcharge data
amount: { currency: "GBP", amount: "3.95" }
}],
package_type: "large-box", // This should be the value of `CarrierPackageTypeCode` and not `Id`
}
]
}
For surcharges, the rating logic can try and figure out accurate values for billing_category
which will provide a better experience to the customer. In this case, the fuel
surcharge could map to FuelCharge
. If it's not practical to do this, Uncategorized
can be used and ShipEngine will roll them up into a single Other
charge.
This assembly step is where some final decisions about which rates to return can be made. If there was a rate card for both the GB
and the LS12JS-GB
origin prefixes, a decision should be made here about what to return. Rates from only a single rate card could be returned, the cheapest rate for each service/package combination could be returned, or some other logic could be applied. If a service or package type was specified in the shipment to be rated, the rates returned could be filtered using those values. For example, if GND_1
was specified as the service for the shipment and there was a rate for both GND_1
and OV_1
, the logic could decide to return only GND_1
.
Publishing
When you perform a connect publish
to deploy your changes, the rating logic won't be applied to customer rate cards until the next time they update their data. If you need to force an update to all rate cards, contact the ShipEngine Connect Team for help.