EasyPost

EasyPost shipping API at api.easypost.com/v2, basic auth with an API key as username, reading shipments, rates, labels, trackers, pickups and refunds.

EasyPost is a developer-first multi-carrier shipping API, used mostly in the United States but with a carrier list that now spans Canada, the United Kingdom, the European Union, Australia and Mexico. It is not a seller platform and it has no merchant user interface to speak of: the product is the API. A shipper creates an address, a parcel and a shipment, gets rates from every carrier account they have connected, buys one, and receives a label plus a tracking code. For a unified commerce database, EasyPost is a shipments and tracking source: it is the system that knows the real postage cost of an order, the label, the scan history and the delivery outcome. It knows nothing about listings, prices or stock, and that is by design.

At a glance

What it is

EasyPost was founded in 2012 in San Francisco and sells shipping infrastructure rather than a shipping app. The core proposition is one integration instead of one per carrier: connect USPS, UPS, FedEx, DHL Express, DHL eCommerce, Canada Post, Royal Mail, Evri, DPD, GLS, OnTrac, Veho, UniUni, Roadie, DoorDash and around a hundred more behind one object model, then rate-shop across all of them in a single call.

Two commercial shapes exist. In the standard shape the shipper connects their own carrier accounts, or uses EasyPost's wallet-based reseller accounts for USPS and a few others, and EasyPost bills per label. In the Forge shape, a platform (a marketplace, an order management system, a 3PL) becomes a parent account and creates child users for each of its own customers, with billing flowing through the parent. Forge is the relevant shape for anyone building a multi-tenant commerce database, because it gives every tenant its own API key under one billing relationship.

EasyPost is geography-agnostic in principle but United States domestic parcel is where the depth is. The carrier guides list is heavily weighted towards US regional and last-mile carriers. India is not a market EasyPost serves directly; there is no Delhivery, Blue Dart or Ecom Express in the carrier list as of September 2026.

The object model is small and consistent, which is the main reason it is pleasant to integrate:

  • Address, Parcel and CustomsInfo are the inputs.
  • Shipment binds them together and automatically populates rates.
  • Buying a Rate produces a PostageLabel and a tracking_code.
  • Tracker carries the scan history.
  • Pickup, ScanForm, Batch, Refund, Insurance and Report are the operational extras.
  • Event is what a webhook delivers.

Every object id is prefixed and self-describing: shp_, trk_, adr_, prcl_, rate_, rfnd_, ca_, user_, evt_. That prefix is worth preserving in your own database, because it makes an id unambiguous without a type column.

API access

Self-serve, no gate. Sign up at easypost.com, and the dashboard issues a test-mode key straight away. Adding a payment method upgrades the account and issues a production-mode key. There is no partner application, no approval queue and no contract minimum for the standard shape; Forge parent accounts do involve a commercial conversation.

Versions. There is one version, v2, at https://api.easypost.com/v2. EasyPost does not run parallel major versions and does not publish deprecation dates for the API as a whole; individual carrier integrations get versioned separately (the carrier list contains both royal-mail-guide and royal-mail-v3-guide, and both osm-guide and osm-v2-guide, which is how carrier-level migrations show up). Watch docs.easypost.com/releases rather than looking for an API version bump.

Official SDKs. All first party, all on GitHub under the EasyPost organisation, all actively maintained: easypost-node, easypost-python, easypost-php, easypost-ruby, easypost-java, easypost-csharp, easypost-go. EasyPost also publishes easyvcr libraries for recording and replaying API interactions in tests, which is a genuinely useful thing to lift for connector test suites.

No public OpenAPI specification. EasyPost does not publish a machine-readable spec on GitHub or at a well-known URL; the /openapi.json and /spec/openapi.json paths both return 404. The reference pages on docs.easypost.com render server side and are the authoritative source. If you want typed models, generate them from an SDK rather than from a spec.

TLS. All communication must use TLS 1.2 or higher.

Authentication

There is no token exchange. The API key is the credential, presented as the HTTP basic auth username with an empty password.

  1. Obtain the API key from the EasyPost dashboard, or from the child user creation response if you are operating a Forge parent account.
  2. Form the basic auth string as API_KEY: with nothing after the colon.
  3. Base64 encode it and send Authorization: Basic <encoded> on every request.

Keys do not expire and there is no refresh endpoint. There are exactly two modes: test and production. Objects created with a test key come back with "mode": "test" and never touch a carrier or a wallet, which makes the sandbox story genuinely good compared to most shipping APIs. Do not mix modes in one database without carrying the mode field through, because test and production ids share a namespace shape and are trivially confusable.

  The colon after the key matters: the password is empty.
curl -X GET https://api.easypost.com/v2/shipments?page_size=100 \
  -u "$EASYPOST_API_KEY": \
  -H 'Content-Type: application/json'
POST /v2/shipments HTTP/1.1
Host: api.easypost.com
Authorization: Basic RVpBSzEyMzQ1Njc4OTA6
Content-Type: application/json

{
  "shipment": {
    "to_address": { "id": "adr_cb0b69ec2d1511f094fcac1f6bc539aa" },
    "from_address": { "id": "adr_cb0e0bf42d1511f094feac1f6bc539aa" },
    "parcel": { "length": 20.2, "width": 10.9, "height": 5.0, "weight": 65.9 }
  }
}

Multi-account

Two mechanisms, and they are not interchangeable.

Child users are the supported multi-tenant model. POST /v2/users creates one under the authenticated parent, GET /v2/users/children lists them, DELETE /v2/users/:child_id removes one. The creation response carries the child's own API keys:

{
  "id": "user_dcc3db76f8c449768ca017d2cb7c14e3",
  "parent_id": "user_060ab38db3c04ffaa60f262e5781a9be",
  "name": "Test User",
  "api_keys": [
    {
      "mode": "test",
      "active": true,
      "id": "ak_883c725b724144cfb98afe3fde5a9a8c"
    }
  ]
}

Billing flows through the parent's payment method. Store one key per child against your channel_account_id and call as that child, so that rate limits, carrier accounts and reports are all scoped correctly. POST /v2/api_keys can mint further keys, but the documentation is explicit that this endpoint only works for referral customers or for managing child user accounts, not for rotating your own primary key.

include_children is the read-side counterpart: index endpoints and report creation accept it to sweep across every child from the parent key in one call. Convenient for a nightly reconciliation job, dangerous as a default, because it silently mixes tenants into one result set.

Objects we can read

All paths are relative to https://api.easypost.com/v2.

Orders

GET /orders, GET /orders/:id

Warning

An EasyPost Order is not a commerce order. It is a multi-parcel shipment group: the docs describe it as "a collection of packages and is intended only for multi-parcel shipping". There is no buyer, no line item, no revenue, no payment method. Do not map it to the unified orders table.

POST /orders takes a to_address, a from_address and an array of shipments, each with its own parcel, and returns rates that cover the whole group. POST /orders/:id/buy takes carrier and service and buys labels for every shipment in the group at once.

{
  "order": {
    "to_address": { "id": "adr_..." },
    "from_address": { "id": "adr_..." },
    "shipments": [
      { "parcel": { "weight": "10.2" } },
      { "parcel": { "predefined_package": "FedExBox", "weight": "17.5" } }
    ]
  }
}

Order items

Not applicable. EasyPost has no line item object. The nearest equivalent is customs_info.customs_items[], which exists only on international shipments and carries description, quantity, value, weight, hs_tariff_number and origin_country for customs declaration. It is a legal declaration, not a sales record, and quantities there are frequently rounded or aggregated by the shipper.

Products and listings

None. EasyPost holds no catalogue. Parcel has dimensions and weight but no SKU, no title and no price. If you need to join a shipment back to a product, carry your own identifier in the shipment's reference field, which is a free-text string that round-trips unchanged and appears in reports.

Inventory

None.

Shipments and tracking

This is the core object.

GET /shipments with cursor pagination: before_id, after_id, start_datetime, end_datetime, page_size (default 20, maximum 100). Filters: purchased to restrict to bought labels, include_children to sweep child users. GET /shipments/:id retrieves one.

{
  "id": "shp_c1bb96ace9e943758c3bd61be420ab08",
  "object": "Shipment",
  "created_at": "2025-05-09T20:40:07Z",
  "updated_at": "2025-05-09T20:40:07Z",
  "mode": "test",
  "status": "unknown",
  "tracking_code": null,
  "reference": null,
  "is_return": false,
  "to_address": {
    "id": "adr_cb0b69ec2d1511f094fcac1f6bc539aa",
    "object": "Address",
    "name": "Dr. Steve Brule",
    "street1": "179 N Harbor Dr",
    "city": "Redondo Beach",
    "state": "CA",
    "zip": "90277",
    "country": "US",
    "phone": "8573875756",
    "email": "dr_steve_brule@gmail.com"
  },
  "from_address": {
    "id": "adr_cb0e0bf42d1511f094feac1f6bc539aa",
    "object": "Address",
    "name": "EasyPost",
    "street1": "417 Montgomery Street",
    "street2": "5th Floor",
    "city": "San Francisco",
    "state": "CA",
    "zip": "94104",
    "country": "US",
    "phone": "4153334445",
    "email": "support@easypost.com"
  },
  "parcel": {
    "id": "prcl_386d2ee1f23245e4a40bb214ce02f22b",
    "object": "Parcel",
    "length": 20.2,
    "width": 10.9,
    "height": 5.0,
    "weight": 65.9
  },
  "rates": [
    {
      "id": "rate_fce6cab8718549c68f4017128c522ade",
      "object": "Rate",
      "service": "Priority",
      "carrier": "USPS",
      "rate": "11.01",
      "currency": "USD",
      "delivery_days": 2
    }
  ],
  "selected_rate": null,
  "postage_label": null,
  "tracker": null,
  "fees": []
}

to_address, from_address and the email and phone on each are PII and are not masked.

status on a shipment uses the same vocabulary as the tracker: unknown, pre_transit, in_transit, out_for_delivery, delivered, available_for_pickup, return_to_sender, failure, cancelled, error. Before a rate is bought, status is unknown and tracking_code is null, which is the state the sample above is in.

Money lives in two places and they mean different things. selected_rate.rate is the carrier rate as quoted. fees[] is what EasyPost actually charged, including its own per-label fee and any insurance. For a true landed shipping cost use fees[]; for a carrier cost comparison use selected_rate.

Trackers. POST /trackers registers a tracking code for monitoring, GET /trackers/:id retrieves one, GET /trackers lists with before_id, after_id, start_datetime, end_datetime, page_size, plus tracking_codes (an array) and carrier. A tracker is created automatically when you buy a label through EasyPost; you create one manually when the label was bought elsewhere.

{
  "tracker": {
    "tracking_code": "EZ1000000001",
    "carrier": "USPS"
  }
}

Carrier is optional and will be auto-detected, but the docs recommend specifying it. Auto-detection on ambiguous code formats is the most common source of a tracker that never updates.

{
  "id": "trk_9135862729734d96ad2f9ed0f0565a27",
  "object": "Tracker",
  "mode": "test",
  "tracking_code": "EZ1000000001",
  "status": "pre_transit",
  "status_detail": "status_update",
  "created_at": "2025-05-09T20:40:17Z",
  "updated_at": "2025-05-09T20:40:17Z",
  "signed_by": null,
  "weight": null,
  "est_delivery_date": "2025-05-09T20:40:17Z",
  "shipment_id": null,
  "carrier": "USPS",
  "tracking_details": [
    {
      "object": "TrackingDetail",
      "message": "Pre-Shipment Info Sent to USPS",
      "description": "",
      "status": "pre_transit",
      "status_detail": "status_update",
      "datetime": "2025-04-09T20:40:17Z",
      "source": "USPS",
      "carrier_code": "",
      "tracking_location": {
        "object": "TrackingLocation",
        "city": null,
        "state": null,
        "country": null,
        "zip": null
      },
      "est_delivery_date": null
    },
    {
      "object": "TrackingDetail",
      "message": "Shipping Label Created",
      "description": "",
      "status": "pre_transit",
      "status_detail": "status_update",
      "datetime": "2025-04-10T09:17:17Z",
      "source": "USPS",
      "carrier_code": "",
      "tracking_location": {
        "object": "TrackingLocation",
        "city": "HOUSTON",
        "state": "TX",
        "country": null,
        "zip": "77063"
      },
      "est_delivery_date": null
    }
  ],
  "fees": [],
  "carrier_detail": {
    "object": "CarrierDetail",
    "service": "First-Class Package Service",
    "container_type": null,
    "origin_location": "HOUSTON TX, 77001",
    "destination_location": "CHARLESTON SC, 29401",
    "guaranteed_delivery_date": null,
    "alternate_identifier": null,
    "initial_delivery_attempt": null
  },
  "public_url": "https://track.easypost.com/djE6dHJrXzkxMzU4NjI3Mjk3MzRkOTZhZDJmOWVkMGYwNTY1YTI3"
}

status_detail is the field that makes EasyPost worth using for an exception pipeline. The published vocabulary is: address_correction, arrived_at_destination, arrived_at_facility, arrived_at_pickup_location, awaiting_information, cancelled, damaged, delayed, delivery_exception, departed_facility, departed_origin_facility, expired, failure, held, in_transit, label_created, lost, missorted, out_for_delivery, received_at_destination_facility, received_at_origin_facility, refused, return, status_update, transferred_to_destination_carrier, transit_exception, unknown, weather_delay.

signed_by is the proof-of-delivery field. carrier_detail.initial_delivery_attempt is the first attempt timestamp, which is what you need to compute a failed-delivery age. public_url is a hosted tracking page you can hand a customer without building one.

Returns and cancellations

Returns are shipments with is_return: true, created by swapping the addresses or by setting the flag; POST /shipments accepts return shipment creation directly. There is no return authorisation object, no reason code and no refund amount, because EasyPost never sees the merchandise transaction.

Cancellation of a label is a refund, covered below. Cancellation of a pickup is POST /pickups/:id/cancel.

Payments and settlements

There is no settlement object for merchandise, but there is real money data about shipping.

  • Refunds. POST /refunds bulk-processes refunds; POST /shipments/:id/refund refunds a single label. GET /refunds and GET /refunds/:id read them back. status is submitted, refunded or rejected. Carrier windows matter: the docs state USPS labels are refundable within 30 days with 15 or more days of processing, UPS and FedEx within 90 days. A refund object is immutable once created.
{
  "id": "rfnd_92d852c508204fc3a34e62977eaa6c50",
  "object": "Refund",
  "created_at": "2025-05-09T20:40:01Z",
  "updated_at": "2025-05-09T20:40:01Z",
  "tracking_code": "9405500208303109884137",
  "confirmation_number": null,
  "status": "submitted",
  "carrier": "USPS",
  "shipment_id": "shp_744d6715c6794d8c8cd0874c368878ba"
}
  • Reports. This is the bulk read path and the one to build a warehouse on. POST /reports/:type where type is one of cash_flow, insurance, payment_log, refund, shipment, shipment_invoice, tracker. Required body: start_date and end_date as YYYY-MM-DD, and they must be less than 31 days apart. Optional: include_children, send_email, columns to pin an exact column set, additional_columns to add to the defaults. Report generation is asynchronous; poll GET /reports/:id or wait for the report.available event. The result is a CSV at url, and that URL expires one hour after issue, so download immediately rather than storing the link.

  • Fees. Every shipment carries a fees[] array itemising what EasyPost charged. That plus the payment_log report is the complete picture of shipping spend.

Customers

None. EasyPost stores addresses, not customers. Address objects are reusable by id and carry name, company, street1, street2, city, state, zip, country, phone, email and verification results, all PII and all unmasked. GET /addresses lists them.

Locations

There is no warehouse object. The ship-from location is whatever Address you attach to a shipment, and the account address can be referenced on a pickup with is_account_address: true. EndShipper is a related concept used for compliance, identifying the legal party responsible for an international shipment, not a physical location.

GET /carrier_accounts lists the connected carrier accounts, each with a ca_ id, the carrier type and its credentials metadata. This is the lookup table for interpreting the carrier string on every rate, shipment and tracker. GET /carrier_types and the carrier metadata endpoint give you service levels, predefined packages and supported features per carrier; cache all of it at connector start.

Writing back: listings, price and stock

Not applicable, this is a carrier. EasyPost has no listing, no price and no stock. There is nothing on the platform a buyer can see and nothing whose price you could change. Anything in the unified listings or inventory tables has to come from a sales channel connector.

The write path that does matter is shipment creation and cancellation.

Create a shipment. POST /shipments with to_address, from_address and parcel required, and reference, return_address, customs_info, carrier_accounts and options optional. A shipment created with valid addresses and a parcel automatically populates its rates array, so rating is not a separate call. Passing carrier_accounts restricts which accounts are rated, which is the correct way to keep a rating call fast; without it every enabled account is rated, up to a hard ceiling of 60 carrier accounts per rating request.

Buy the label. POST /shipments/:id/buy with the chosen rate object, and optionally insurance as a string dollar amount. The response carries tracking_code and postage_label. This is the only irreversible, billable call in the flow; everything before it is free.

Void the label. POST /shipments/:id/refund for one, POST /refunds for many. Success is not immediate: the refund enters submitted and the carrier decides. Watch for the refund.successful event rather than assuming.

Schedule a pickup.

{
  "pickup": {
    "reference": "my-first-pickup",
    "min_datetime": "2022-10-01 10:30:00",
    "max_datetime": "2022-10-02 10:30:00",
    "shipment": "shp_...",
    "address": "adr_...",
    "is_account_address": "false",
    "instructions": "Special pickup instructions"
  }
}

POST /pickups creates it and automatically fetches pickup rates for every carrier account that supports scheduled collection. POST /pickups/:id/buy with carrier and service confirms it. POST /pickups/:id/cancel cancels. GET /pickups lists with the standard cursor parameters.

Bulk. POST /batches creates a batch of up to several thousand shipments and buys them asynchronously, reporting completion through the batch.created and batch.updated events. ScanForm (POST /scan_forms) produces the single manifest barcode a driver scans instead of scanning every parcel; create one per carrier per day per location.

Update semantics. Most EasyPost objects are effectively immutable once created. You do not update a shipment, you create a new one. The exceptions are webhooks, carrier accounts, users and trackers. Design the connector around create-and-supersede rather than update-in-place.

Webhooks and notifications

POST /webhooks registers an endpoint:

{
  "webhook": {
    "url": "example.com",
    "webhook_secret": "A1B2C3",
    "custom_headers": [
      {
        "name": "X-Header-Name",
        "value": "header_value"
      }
    ]
  }
}

custom_headers is unusually useful: it lets you attach a tenant identifier or a shared secret to every delivery without encoding it into the URL.

Event types, as published:

tracker.updated is the one that carries scan events and is the reason you do not need to poll for tracking. Note what is absent: there is no shipment.purchased event. Label purchase is a synchronous call, so you already know.

An Event carries object, id, mode, description, previous_attributes, result, status, pending_urls, completed_urls, created_at, updated_at. result is the full changed object, so a tracker.updated event contains the whole tracker including tracking_details, and you do not need a follow-up fetch. previous_attributes gives you the diff, which makes idempotent processing straightforward.

{
  "mode": "production",
  "description": "batch.created",
  "previous_attributes": { "state": "purchasing" },
  "pending_urls": ["example.com/easypost-webhook"],
  "completed_urls": [],
  "created_at": "2015-12-03T19:09:19Z",
  "updated_at": "2015-12-03T19:09:19Z",
  "id": "evt_...",
  "object": "Event"
}

Verification. When the webhook was registered with a webhook_secret, every delivery carries an X-Hmac-Signature header. The first-party Python SDK shows the exact construction: normalise the secret with Unicode NFKD, UTF-8 encode it, compute HMAC SHA256 over the raw request body bytes, hex-encode the digest and prefix it with the literal hmac-sha256-hex=, then compare in constant time against the header value.

  Reference implementation, matching easypost/util.py validate_webhook
  header X-Hmac-Signature == "hmac-sha256-hex=" + hex(HMAC_SHA256(NFKD(secret), raw_body))
printf '%s' "$RAW_BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex

Verify against the raw bytes before any JSON parsing or re-serialisation; a re-encoded body will not match.

Retries. EasyPost expects a 2xx within seven seconds, and prefers a plain 200. Failed deliveries are retried six times with an increasing delay between attempts. After that the event is dropped. Seven seconds is tight: acknowledge first, queue second, process third. Never do database work on the request thread.

If you cannot take webhooks. Poll GET /trackers?start_datetime=...&page_size=100 for trackers updated since the last high-water mark, and GET /shipments?purchased=true&start_datetime=... for new labels. Fifteen minutes is a reasonable cadence, but be aware of the five-per-second index limit below.

Rate limits and pagination

EasyPost does not publish a global requests-per-minute number, and no X-RateLimit-* style headers are documented. What is published:

  • Index endpoints are limited to five requests per second. Exceeding that returns HTTP 429. "Index endpoint" means the list forms: GET /shipments, GET /trackers, GET /refunds and so on. Single-object retrieves and creates are not covered by that published figure.
  • A rating request may reference at most 60 carrier accounts, counting both an explicit carrier_accounts parameter and the account's default enabled set. Beyond that the request is rejected, not silently truncated.
  • 429 is documented in the general error table as "Too Many Requests", with no number attached.

The official guidance is retry with exponential backoff, and the guide's own example is a Python urllib3 Retry with backoff_factor=1 and status_forcelist=[429, 500, 502, 503, 504]. Since no Retry-After is documented, backoff is the only signal you have.

Practical cadence: run list sweeps at a hard four requests per second to stay under the index ceiling, and take tracking updates from webhooks rather than polling. For a historical backfill, use POST /reports/shipment and POST /reports/tracker in 30 day windows instead of paging the index endpoints. A year of history is twelve report jobs and twelve CSV downloads rather than tens of thousands of paged requests, and it does not touch the index limit at all.

Pagination is cursor based and uniform across every list endpoint: before_id and after_id to position, start_datetime and end_datetime to bound, page_size with a default of 20 and a maximum of 100. The response carries a has_more boolean; page forward by passing the last id of the current page as before_id until has_more is false. Because the cursor is an object id rather than an offset, the usual offset-paging drift does not apply, which is a real advantage over ShipStation v1.

Mapping to the unified model

orders

EasyPost has no commerce order. Do not populate orders from this connector. If you must record an EasyPost Order (the multi-parcel group), model it as a shipment group and carry its id on each child shipment, not as a row in orders.

The one bridge that exists is Shipment.reference, a free-text field that survives round trip and appears in reports. Write your own order_id into it at label creation time; it is the only reliable join back to commerce data.

order_items

Not available. The nearest data is customs_info.customs_items[] on international shipments:

listings

Not applicable. EasyPost has no catalogue.

inventory

Not applicable.

shipments

This is where the whole connector earns its keep.

Proof of delivery is Tracker.signed_by plus the delivered event's datetime and tracking_location. There is no signature image endpoint.

returns

settlements

There is no merchandise settlement, but shipping spend maps cleanly:

customers

Not available as an object. to_address on a shipment is the nearest thing:

locations

Gaps and open questions

  • No published global rate limit. The only concrete figure is five requests per second on index endpoints. Whether create and retrieve calls have their own ceiling, and whether the limit is per key or per account across child users, is not documented. Measure it on a real production key before sizing a backfill.
  • No rate limit headers. Nothing tells you how much quota is left, so the only strategy available is backoff after the fact.
  • No Retry-After on 429. Not documented, so the backoff schedule has to be guessed.
  • No public OpenAPI specification. Confirmed by probing the usual paths and the EasyPost GitHub organisation. Typed models must be derived from an SDK.
  • HMAC details are not fully in the docs. The guide points at a Help Center article for the signature construction, and support.easypost.com returns 403 to automated fetchers. The construction documented on this page comes from reading the first-party Python SDK source, which is authoritative but is an implementation rather than a specification. The guide also mentions timestamp verification, which the Python validate_webhook does not perform; that discrepancy is unresolved and worth confirming before relying on replay protection.
  • Webhook retry intervals. Six retries with increasing delay is published; the actual intervals are not. Size your dead-letter window conservatively.
  • Insurance claims. A claims API exists with its own event types, but the claim object shape was not read for this page.
  • India and other markets. No Indian domestic carrier appears in the carrier list as of September 2026. For Indian shipping, use a local aggregator connector instead.
  • mode collisions. Test and production objects share an id format. Nothing stops a test object entering a production database except your own discipline; carry mode through to the warehouse and constrain on it.

Sources