Skip to content

Developer notes

Everything below that concerns ShipStation is taken from ShipStation’s own help center. Sources (fetched 2026-11-08):

Points marked Unverified are not stated in these documents; the add-on is written so that either answer works.

ShipStation offers two ways for a store to connect:

Option What ShipStation documents Fit for a merchant-installed add-on
ShipStation API V1 (Basic auth with API key + secret) / V2 (API key) “V1 API access will be limited to accounts on a Standard or Accelerate plan or higher”; on lower plans “the V1 API will not be available … The V2 API will not be available … will return a 402 response” [S2]. Keys expire (3, 6 or 12 months) and only the account owner can generate them [S2]. V1 and V2 keys “aren’t interchangeable” [S2]. Would lock out merchants on lower plans and needs key rotation every few months.
Custom Store “allows a developer to build a custom connection for an order source that does not have a pre-built integration”; “import orders into ShipStation in a single click and … automatically send shipment status and tracking information updates back” [S1]. No plan restriction is stated in S1. The store exposes one endpoint; ShipStation pulls orders and pushes shipment notices. No API key, no outbound calls from the store.

The add-on uses the Custom Store. It makes no outbound HTTP calls at all; it does not use V1 or V2.

https://<store>/index.php?dispatch=kv_shipstation.endpoint&c=<account> (frontend controller controllers/frontend/kv_shipstation.php).

  • “The Web Endpoint will be used to handle two major requests: GET … POST” and must tell action = export from action = shipnotify [S1].
  • Unverified: how ShipStation appends its parameters to a URL that already has a query string. Request::params() splits the raw query string on both & and ?, and c= is placed last, so ...&c=0&action=export... and ...&c=0?action=export... give the same result (both tested).
  • “ShipStation uses Basic HTTP Authentication when calling your Web Endpoint. Use a Basic Authentication Header to pass the credentials.” [S1]; the username and password are those entered in the Custom Store form [S3].
  • The header is read through Webhook::headers(): HTTP_AUTHORIZATION, REDIRECT_HTTP_AUTHORIZATION, getallheaders(), apache_request_headers(), then PHP_AUTH_USER / PHP_AUTH_PW (Apache + PHP-FPM / CGI hide the header otherwise).
  • Accounts::verify() compares SHA-256 digests with hash_equals, always compares both username and password, and compares an unknown account against random values, so the comparison itself does not reveal which part was wrong. Failures answer 401 with WWW-Authenticate: Basic, wait 300 ms, are counted per account (shown on the Connection page) and logged at most once a minute. There is no lock-out (it would let anyone block ShipStation); use the generated 24-character passwords.
  • The endpoint and the cron URL are trusted controllers (schemas/permissions/trusted_controllers.post.php), so a closed storefront or one open to signed-in customers only still answers ShipStation (frontend init.php would otherwise show the “store closed” page or redirect a GET to the sign-in form).
  • CS-Cart Ultimate: the URL shown for a storefront’s account is built on that storefront’s address (fn_url(... company_id=N ...)), and the endpoint answers 403 when it is called with c=N on another storefront’s address (orders of storefront N are only readable on its own storefront).
  • Passwords are sealed with AES-256-GCM (Secret, key derived from the store’s crypt key) and never logged (Log::mask also masks Basic tokens).

Request: [endpoint]?action=export&start_date=[Start Date]&end_date=[End Date]&page=1 [S1].

Parameter Source [S1] Handling
start_date, end_date “in UTC time. Format: MM/dd/yyyy HH:mm (24 hour notation)” Request::utc(); 12-hour AM/PM and seconds also accepted. Anything else: 400.
page “On the first request … ShipStation will append a URL parameter called page”; it requests further pages while the root Orders node “indicates that there are additional pages” Orders pages="N"; page size setting 10 to 500.
window “return data for any order modified between the start and end date, regardless of the order’s status” CS-Cart orders have no update time, so ?:kv_shipstation_order_map.modified_at is kept by the hooks place_order, update_order, update_order_details_post, change_order_status_post, create_shipment_post (and filled from the order date at install and for unknown orders in the window). The window starts window_padding hours earlier (default 12) to absorb clock or time zone differences.

Statuses mapped to “not sent” are left out (Incomplete and Temporary always). Multi-Vendor parent orders are left out (is_parent_order = 'Y'); vendor orders are sent. The status map is one store-wide map in ?:kv_shipstation_options, changed only by administrators of the whole store. Pages are re-queried per request ordered by change time; an order changed between two page requests of the same import can move out of that import and is sent with the next one (the window overlap covers it). Text that other code prints while orders are read (PHP notices) is dropped from the XML and logged.

Order fields (OrderXml::map() / render()), from the “Order Information Field Definitions” table and the XML schema [S1]. Free text is in CDATA (“All free text fields should be enclosed in a CDATA section” [S1]); ]]> inside text is split. Text is cut with mb_substr to the documented length (the shorter one where table and schema differ) after removing characters XML 1.0 forbids.

Element Doc length Value
OrderID, OrderNumber 50 CS-Cart order id (both)
OrderDate, LastModified “MM/dd/yyyy HH:mm … Default is UTC” order date, change time (UTC)
OrderStatus 50 unpaid / paid / shipped / cancelled / on_hold (status map)
ShippingMethod 100 shipping method names
PaymentMethod 50 payment method name
CurrencyCode 3, ISO 4217 store’s primary currency (order totals are stored in it)
OrderTotal, TaxAmount, ShippingAmount decimal 9,2 total, sum of every tax of the order (included in the prices or added), shipping_cost
CustomerNotes, InternalNotes 1000 customer notes, staff notes
CustomField1 100 Multi-Vendor: vendor name
Customer/CustomerCode 50 (table) / 100 (schema) e-mail, or guest-<order id>
BillTo Name, Company, Phone, Email 100, 100, 50, 100 billing
ShipTo Name, Company, Address1, Address2, City, State, PostalCode, Country, Phone 100, 100, 200, 200, 100, 100, 50, 2, 50 shipping address; billing address when the order has none; Country falls back to the store’s country, and an order still without a 2-letter country is left out and logged (the schema requires exactly 2 characters)
Items/Item LineItemID 50 CS-Cart order item id (used again by shipnotify)
SKU 50 (table) / 100 (schema) product code, or ID-<product id> when empty (SKU is required)
Name, ImageUrl 200, 500 product name, main image (absolute URL)
Weight, WeightUnits decimal; “Pounds, Ounces, Grams” product weight; pounds / ounces as configured, any other unit converted to grams
Quantity, UnitPrice integer 1..99999, decimal amount, unit price
Options/Option Name, Value 100, 100; “10 per Item” (table; schema allows 100) product options, first 10
Adjustment “true if the line item represents a coupon, discount … must have a negative UnitPrice” order discount as one line

Not sent: Gift, GiftMessage, CustomField2/3, RequestedWarehouse, Source, Dimensions, UPC, Location (no core CS-Cart data for them).

Unverified / documentation conflict: the XML schema published in S1 has no CurrencyCode and no UPC element, although the field table lists both and the guide’s own GET example uses them; the published schema also has a typo (xs:patternvalue=). Validating the guide’s example against the published schema fails. The tests use the schema with these three fixes (tests/fixtures/official/orders-schema.xsd, the published text is kept next to it); every rendered page is validated against it. The add-on sends CurrencyCode as documented in the field table.

Request: [endpoint]?action=shipnotify&order_number=[Order Number]&carrier=[Carrier]&service=&tracking_number=[Tracking Number] with an XML ShipNotice body; “Your Web Endpoint should return a 200 (or 2xx) HTTP status code to indicate that the tracking information was received successfully” [S1]. Notices are sent “whenever a label is created”, can be delayed in ShipStation’s settings, and “Mark as Shipped” also sends one [S1].

Fields read (ShipNotice::parse(), “ShipNotify Field Definitions” [S1]): OrderNumber, OrderID, CustomerCode, NotesToCustomer, NotifyCustomer, LabelCreateDate (“UTC time”), ShipDate (“The date the package will be shipped”), Carrier, Service, TrackingNumber, ShippingCost, Items/Item (SKU, Name, Quantity, LineItemID). The body wins over the URL parameters; the URL fills gaps. Bodies over 1 MB, DOCTYPE / ENTITY declarations or another root element are refused (400); libxml runs with LIBXML_NONET.

  • Order: only OrderNumber is used. Unverified: whether ShipNotice/OrderID echoes the store’s OrderID or is ShipStation’s own id (the guide only says “Unique identifier for an order”), so it is never used alone. Not a CS-Cart order id: 400; unknown order or parent order: 404; order of another ShipStation store (another vendor or storefront): 403 (allowed when that account exported the order last).
  • Idempotency: UNIQUE (order_id, dedupe_key) on ?:kv_shipstation_shipments. The key is the tracking number (upper case, no spaces) or, without one, a hash of carrier, service, ship date and items. The row is stored (INSERT IGNORE) before anything else; a repeated notice whose row is done answers 200 OK (duplicate). Applying it runs under a per-order GET_LOCK (15 s; when busy the stored notice goes to the retry queue and ShipStation still gets 200). Before creating a CS-Cart shipment the add-on looks for an existing shipment of the order with the same tracking number and adopts it (crash between creation and bookkeeping, or a shipment entered by hand), and then still sets the order status. An error after the shipment was created never turns the row back to Failed; the retry only repeats the status step. Rows left Pending by a request that died are picked up by the cron after 10 minutes. Three parallel identical notices create one shipment (tested). Limit: a notice WITHOUT a tracking number that crashed right after its shipment was created cannot be matched to that shipment and may ship the remaining items on retry (“Mark as Shipped” without tracking is the only such case).
  • Partial shipments: items are matched by LineItemID (our order item id), then by SKU, quantities capped at what is still unshipped (ShipNotice::match()); a notice without Items ships everything left. Unmatched items are logged; nothing to ship = row “Recorded”, no shipment.
  • CS-Cart shipment: fn_update_shipment() with the order’s first shipping method, status S (shipped, ShipmentStatuses::SHIPPED), carrier kv_ss_<family>, comments “Carrier - Service (ShipStation)” plus NotesToCustomer; shipment date = ShipDate at noon in the shop time zone (a calendar date), else LabelCreateDate (UTC), else now.
  • Order status: all items shipped -> “shipped” setting; otherwise the “partly shipped” setting. Never changed when the order is Cancelled, Declined, Failed, Incomplete or Temporary, in any status whose Inventory setting is “Increase” (custom Refunded / Returned statuses: fn_get_status_by_type_and_param(STATUSES_ORDER, ['inventory' => 'I'])), or in a status the status map sends as cancelled; never back from “shipped” to “partly shipped”.
  • E-mails: NotifyCustomer: “If ShipStation send the customer a shipment notification email, this value will be ‘false’” [S1]. Unverified (the sentence is ambiguous and the sample leaves it empty): “Automatic” e-mails from CS-Cart only on an explicit true.
  • Errors after storing: the row becomes Failed, a shipnotify job retries it (5 min, 15 min, 1 h, 3 h, 12 h) and ShipStation still gets 200 because the notice is safely stored.
  • Switched off / unlicensed: export answers 403; notices are still applied (the labels exist already).

carrier is one of the “Supported API codes for available carriers” [S1] (e.g. usps, ups, fedex, dhl_express, royal_mail) or a display name (the guide’s own example sends USPS). Carriers::groupOf() folds case, spaces and dashes and maps codes to a family. The tracking page URLs are not part of ShipStation’s documentation: they are the carriers’ public tracking pages, checked 2026-11-08, and can be overridden or extended in the Tracking URLs setting.

Family ShipStation codes [S1] (+ aliases) Link
usps usps, endicia, express_1 (+ stamps_com, usps*) https://tools.usps.com/go/TrackConfirmAction?tLabels=%s
ups ups, ups_mail_innovations (+ ups_*) https://www.ups.com/track?tracknum=%s
fedex fedex, fedex_uk (+ fedex*) https://www.fedex.com/fedextrack/?trknbr=%s
dhl_express dhl_express, dhl_express_uk, _canada, _australia, _de_walleted https://www.dhl.com/global-en/home/tracking/tracking-express.html?submit=1&tracking-id=%s
dhl_ecommerce dhl_ecommerce, dhl_global_mail https://webtrack.dhlecs.com/?trackingnumber=%s
dhl_de deutsche_post_dhl https://www.dhl.de/en/privatkunden/pakete-empfangen/verfolgen.html?piececode=%s
canada_post canada_post, canada_post_walleted https://www.canadapost-postescanada.ca/track-reperage/en#/search?searchFor=%s
purolator purolator_ca, purolator_walleted https://www.purolator.com/en/shipping/tracker?pin=%s
canpar canpar, canpar_walleted https://www.canpar.com/en/tracking/track.htm?barcode=%s
australia_post australia_post, australia_post_mypost_business https://auspost.com.au/mypost/track/details/%s
startrack star_track https://startrack.com.au/track/details/%s
aramex_au / aramex_nz fastway_au, aramex_au_walleted / fastway_nz https://www.aramex.com.au/tools/track?l=%s / https://www.aramex.co.nz/tools/track?l=%s
nz_post courierpost, new_zealand_post_international https://www.nzpost.co.nz/tools/tracking/item/%s
royal_mail, parcelforce royal_mail, parcelforce https://www.royalmail.com/track-your-item#/tracking-results/%s, https://www.parcelforce.com/track-trace?trackNumber=%s
evri hermes, hermescorp, evri_international https://www.evri.com/track/parcel/%s/details
yodel yodel https://www.yodel.co.uk/tracking/%s
dpd_de dpd_germany, dpd_germany_walleted https://tracking.dpd.de/status/en_US/parcel/%s
ontrac ontrac, lasership https://www.ontrac.com/tracking/?number=%s
colissimo, chronopost colissimo, colissimo_walleted, chronopost https://www.laposte.fr/outils/suivre-vos-envois?code=%s, https://www.chronopost.fr/tracking-no-cms/suivi-page?listeNumerosLT=%s
sendle, landmark sendle; landmark_global, landmark_global_ca https://track.sendle.com/tracking?ref=%s, https://track.landmarkglobal.com/?search=%s
other everything else none (name and tracking number only) unless set in Tracking URLs

CS-Cart shows a shipment’s carrier name and link from Tygh\Shippings\Services\<Carrier>::getInfo() (Shippings::getCarrierInfo(), which runs sprintf with the tracking number). The add-on ships Tygh\Shippings\Services\KvSs<Family> classes (not rate services: no shipping_services rows) and ten KvSsCustom1..10 classes. A “Tracking URLs” line whose code is a family key changes that family; a line for any other ShipStation code takes the next custom slot (in the order of the lines), and shipments of that carrier are stored as kv_ss_custom<n>, so the customer’s order page and CS-Cart’s shipment e-mail show its name and link. Keep the order of the lines and add new ones at the end (a slot is the line’s position). Carriers without a family or a line are stored as kv_ss_other (name “Carrier”, no link); the real carrier and service are in the shipment comment and on the add-on’s tab. Every other % of a URL is escaped for sprintf. Tracking numbers are URL-encoded in the add-on’s own links.

  • Scope::accountFor() / orderCondition(): Ultimate = account per storefront company; MV owner mode = account 0 for all; MV owner mode off = vendor’s own account if active, else account 0. Parent orders never.
  • Vendor panel (schemas/permissions/vendor_multivendor.post.php), mode by mode: manage, connection, generate, log, log_csv, jobs, job_retry, job_discard, reapply allowed and checked against runtime.company_id in the controller; cron, run_now, backfill admin only. In owner mode vendors may look but not act: job_retry, job_discard and reapply are refused. The status map is saved only by administrators of the whole store. Lists and counters are scoped to the vendor’s company.
  • fn_allowed_for('MULTIVENDOR') gates every edition difference; no fn_mve_* / fn_ult_* calls.
Table Purpose
?:kv_shipstation_accounts endpoint credentials per account (password sealed), last import / notice / failure
?:kv_shipstation_order_map change time per order, last export time and account
?:kv_shipstation_shipments one row per shipment notice (dedupe key, carrier, tracking, link, items, CS-Cart shipment id, result)
?:kv_shipstation_jobs retry queue
?:kv_shipstation_log log
?:kv_shipstation_options store-wide options (status map)

All are dropped on uninstall (no legal records, so no retention exception). CS-Cart shipments stay; while the add-on is disabled or after uninstalling, CS-Cart cannot load the KvSs* classes and shows those shipments with the carrier code and without a link.

  • PHP 7.4 syntax floor; linted with PHP 8.1 and 8.3 (PHPCompatibility 7.4-8.3). Smarty 4 and 5 safe (no plugin files). No storefront output, so nothing for the full page cache to store; the endpoint sends Cache-Control: no-store.
  • Hooks used exist unchanged from 4.17.2 to 4.21.2: place_order, update_order, update_order_details_post, change_order_status_post, create_shipment_post, delete_order, init_templater_post (upgrade synchronizer).
  • Upgrades in place are completed by Upgrade\Synchronizer on the next admin request.
  • php81 tests/unit.php, php83 tests/unit.php: request parsing, auth helpers, multibyte cuts, status map, order XML (validated against the guide’s schema), ShipNotice parsing of the guide’s sample, dedupe keys, partial matching, carriers, carrier classes, recurring-defect checks.
  • tests/mock-server/shipstation-sim.mjs: a ShipStation simulator that follows S1 (Basic auth, paging, schema validation of every page, ShipNotice bodies); refuses non-local hosts.
  • tests/e2e/e2e.mjs (CS-Cart and Multi-Vendor) and tests/e2e/vendor-smoke.mjs (vendor panel at vendor.php).