Documentation
Everything you need to integrate the rateship SDK into your Node backend.
Everything you need to integrate the rateship SDK into your Node backend.
Providers send webhooks to a URL you own. The SDK verifies the signature, normalizes the payload into a typed NormalizedEvent, and throws WebhookVerificationError on mismatch. Never returns null on auth failure, that pattern is how silent auth-bypass bugs happen.
| Provider | Header | Algorithm | Notes |
|---|---|---|---|
| Shippo | Shippo-Auth-Signature | HMAC-SHA256 over <ts>.<body> | 5-min replay window. HMAC signing is opt-in on Shippo accounts , email account-manager@goshippo.com to enable. |
| EasyPost | X-Hmac-Signature | HMAC-SHA256 hex, with hmac-sha256-hex= prefix | Secret is NFKD-normalized to match EasyPost's official clients. |
| ShipEngine | v2.1+. Uses RSA-SHA256 + JWKS key rotation, which requires async key fetching. At v2.0 verifyWebhook throws CONFIGURATION_ERROR with a docs link. | ||
import express from "express";
import { RateShip, easypost, WebhookVerificationError } from "rateship";
const client = new RateShip({
providers: [easypost({ apiKey: process.env.EASYPOST_KEY! })],
});
const app = express();
// IMPORTANT: express.raw(), not express.json().
// HMAC is computed over the exact bytes the provider sent, if you
// JSON.parse then re-serialize, the signature no longer matches.
app.post(
"/webhooks/easypost",
express.raw({ type: "application/json" }),
(req, res) => {
try {
const event = client.webhooks.verify({
provider: "easypost",
rawBody: req.body, // Buffer
signature: req.header("X-Hmac-Signature")!,
secret: process.env.EASYPOST_WEBHOOK_SECRET!,
});
if (event.type === "tracking.delivered") {
// event.tracking_number, event.delivered_at, event.location
} else {
// event.type === "tracking.updated"
// event.status is one of: 'pre_transit' | 'in_transit' |
// 'out_for_delivery' | 'failure' | 'unknown'
}
res.sendStatus(200);
} catch (err) {
if (err instanceof WebhookVerificationError) {
res.sendStatus(401);
return;
}
res.sendStatus(500);
}
},
);type NormalizedEvent = TrackingUpdatedEvent | TrackingDeliveredEvent;
type TrackingStatus =
| "pre_transit" // label created, not yet scanned
| "in_transit" // on its way
| "out_for_delivery" // on the truck today
| "failure" // lost, damaged, undeliverable, return-to-sender
| "unknown"; // provider status didn't map cleanly
interface TrackingUpdatedEvent {
type: "tracking.updated";
provider: "easypost" | "shippo" | "shipengine";
tracking_number: string;
carrier: string;
status: TrackingStatus;
status_detail?: string; // raw provider string, pattern-matchable
location?: EventLocation;
estimated_delivery?: string; // ISO date
occurred_at: string; // ISO timestamp of the carrier scan
raw: object; // full provider payload
}
interface TrackingDeliveredEvent {
type: "tracking.delivered";
provider: "easypost" | "shippo" | "shipengine";
tracking_number: string;
carrier: string;
delivered_at: string; // ISO
location?: EventLocation;
signed_by?: string;
raw: object;
}
interface EventLocation {
city?: string;
state?: string;
zip?: string;
country?: string;
}HMAC is computed over the exact bytes the provider sent. If you call JSON.parse on the body and then re-serialize it, whitespace and property order change, the signature no longer matches. Always pass the raw Buffer (or unparsed string) to webhooks.verify. The SDK parses it after verification.
Next: Error Handling.
Providers send webhooks to a URL you own. The SDK verifies the signature, normalizes the payload into a typed NormalizedEvent, and throws WebhookVerificationError on mismatch. Never returns null on auth failure, that pattern is how silent auth-bypass bugs happen.
| Provider | Header | Algorithm | Notes |
|---|---|---|---|
| Shippo | Shippo-Auth-Signature | HMAC-SHA256 over <ts>.<body> | 5-min replay window. HMAC signing is opt-in on Shippo accounts , email account-manager@goshippo.com to enable. |
| EasyPost | X-Hmac-Signature | HMAC-SHA256 hex, with hmac-sha256-hex= prefix | Secret is NFKD-normalized to match EasyPost's official clients. |
| ShipEngine | v2.1+. Uses RSA-SHA256 + JWKS key rotation, which requires async key fetching. At v2.0 verifyWebhook throws CONFIGURATION_ERROR with a docs link. | ||
import express from "express";
import { RateShip, easypost, WebhookVerificationError } from "rateship";
const client = new RateShip({
providers: [easypost({ apiKey: process.env.EASYPOST_KEY! })],
});
const app = express();
// IMPORTANT: express.raw(), not express.json().
// HMAC is computed over the exact bytes the provider sent, if you
// JSON.parse then re-serialize, the signature no longer matches.
app.post(
"/webhooks/easypost",
express.raw({ type: "application/json" }),
(req, res) => {
try {
const event = client.webhooks.verify({
provider: "easypost",
rawBody: req.body, // Buffer
signature: req.header("X-Hmac-Signature")!,
secret: process.env.EASYPOST_WEBHOOK_SECRET!,
});
if (event.type === "tracking.delivered") {
// event.tracking_number, event.delivered_at, event.location
} else {
// event.type === "tracking.updated"
// event.status is one of: 'pre_transit' | 'in_transit' |
// 'out_for_delivery' | 'failure' | 'unknown'
}
res.sendStatus(200);
} catch (err) {
if (err instanceof WebhookVerificationError) {
res.sendStatus(401);
return;
}
res.sendStatus(500);
}
},
);type NormalizedEvent = TrackingUpdatedEvent | TrackingDeliveredEvent;
type TrackingStatus =
| "pre_transit" // label created, not yet scanned
| "in_transit" // on its way
| "out_for_delivery" // on the truck today
| "failure" // lost, damaged, undeliverable, return-to-sender
| "unknown"; // provider status didn't map cleanly
interface TrackingUpdatedEvent {
type: "tracking.updated";
provider: "easypost" | "shippo" | "shipengine";
tracking_number: string;
carrier: string;
status: TrackingStatus;
status_detail?: string; // raw provider string, pattern-matchable
location?: EventLocation;
estimated_delivery?: string; // ISO date
occurred_at: string; // ISO timestamp of the carrier scan
raw: object; // full provider payload
}
interface TrackingDeliveredEvent {
type: "tracking.delivered";
provider: "easypost" | "shippo" | "shipengine";
tracking_number: string;
carrier: string;
delivered_at: string; // ISO
location?: EventLocation;
signed_by?: string;
raw: object;
}
interface EventLocation {
city?: string;
state?: string;
zip?: string;
country?: string;
}HMAC is computed over the exact bytes the provider sent. If you call JSON.parse on the body and then re-serialize it, whitespace and property order change, the signature no longer matches. Always pass the raw Buffer (or unparsed string) to webhooks.verify. The SDK parses it after verification.
Next: Error Handling.