Developer notes
Everything below that concerns ShipStation is taken from ShipStation’s own help center. Sources (fetched 2026-11-08):
- [S1] Custom Store Development Guide: https://help.shipstation.com/hc/en-us/articles/360025856192
- [S2] ShipStation API (V1 / V2 overview): https://help.shipstation.com/hc/en-us/articles/360025856212
- [S3] ShipStation Custom Store Development Guide, 2012 PDF (older edition of S1; used only to confirm the Basic authentication fields)
Points marked Unverified are not stated in these documents; the add-on is written so that either answer works.
Integration model and why
Section titled “Integration model and why”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.
Endpoint
Section titled “Endpoint”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 = exportfromaction = 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?, andc=is placed last, so...&c=0&action=export...and...&c=0?action=export...give the same result (both tested).
Authentication
Section titled “Authentication”- “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(), thenPHP_AUTH_USER/PHP_AUTH_PW(Apache + PHP-FPM / CGI hide the header otherwise). Accounts::verify()compares SHA-256 digests withhash_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 answer401withWWW-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 (frontendinit.phpwould 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 answers403when it is called withc=Non 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::maskalso masksBasictokens).
action=export (GET)
Section titled “action=export (GET)”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.
action=shipnotify (POST)
Section titled “action=shipnotify (POST)”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/OrderIDechoes 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 answers200 OK (duplicate). Applying it runs under a per-orderGET_LOCK(15 s; when busy the stored notice goes to the retry queue and ShipStation still gets200). 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 withoutItemsships 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, statusS(shipped,ShipmentStatuses::SHIPPED), carrierkv_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 ascancelled; 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
shipnotifyjob retries it (5 min, 15 min, 1 h, 3 h, 12 h) and ShipStation still gets200because the notice is safely stored. - Switched off / unlicensed: export answers
403; notices are still applied (the labels exist already).
Carriers and tracking links
Section titled “Carriers and tracking links”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.
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.
Multi-Vendor and editions
Section titled “Multi-Vendor and editions”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,reapplyallowed and checked againstruntime.company_idin the controller;cron,run_now,backfilladmin only. In owner mode vendors may look but not act:job_retry,job_discardandreapplyare 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; nofn_mve_*/fn_ult_*calls.
Tables
Section titled “Tables”| 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.
Compatibility notes
Section titled “Compatibility notes”- 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\Synchronizeron 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) andtests/e2e/vendor-smoke.mjs(vendor panel atvendor.php).