Developer notes
Technical reference for developers who customize UPS REST Rates & Labels.
Add-on identity
Section titled “Add-on identity”| Item | Value |
|---|---|
| Add-on id | kv_ups_rest |
| Function prefix | fn_kv_ups_rest_ |
| Language variables | kv_ups_rest.* |
| CSS scope | .kv-ups-rest |
| PHP namespace | Tygh\Addons\KvUpsRest |
| Shipping service module | kv_ups_rest (class Tygh\Shippings\Services\KvUpsRest). The core carrier is ups (class Tygh\Shippings\Services\Ups, OAuth REST) and the deprecated XML carrier upsdeprecated; the module ids do not overlap and nothing in core is overridden |
UPS APIs used and where each is documented
Section titled “UPS APIs used and where each is documented”Sources: the OpenAPI 3 files that UPS publishes on GitHub (github.com/UPS-API/api-documentation, MIT licence, fetched 2026-11-07) and the UPS Developer Portal (developer.ups.com). The files that matter are kept in tests/spec/ (OAuthClientCredentials, Rating, Shipping, AddressValidation, Tracking, Pickup). Hosts from their servers[]: test = https://wwwcie.ups.com (UPS Customer Integration Environment), live = https://onlinetools.ups.com; the API paths start with /api, the OAuth path does not. Status of a row:
- official-spec: field names, nesting, lengths and enums checked against the OpenAPI file named in the row.
tests/spec-check.pyvalidates every request the add-on builds against those schemas (see “Tests”). - flagged: the OpenAPI file leaves the value to an appendix it does not contain, or the official examples contradict the schema. The add-on does what the row says and the point is repeated under “Open items”.
Every call after the token carries Authorization: Bearer <token>, Accept: application/json, a User-Agent naming the product, and the two headers every UPS API takes: transId (unique per request, at most 32 characters) and transactionSrc (kv_ups_rest, at most 512). For label purchases transId is our own attempt reference kv-<order>-<attempt>.
| Purpose | Endpoint | What we send / read | Status |
|---|---|---|---|
| OAuth token | POST /security/v1/oauth/token (OAuthClientCredentials.yaml, operation CreateToken). Headers: Authorization: Basic base64(client id:client secret) (security scheme BasicAuth, x-tokenEndpointAuthMethod: client_secret_basic), optional x-merchant-id (6 digit UPS account number); body application/x-www-form-urlencoded grant_type=client_credentials |
reads access_token, expires_in (a string of seconds, 14399 in practice; the token is cached encrypted and renewed 30 s before it ends, one new login on HTTP 401). Errors {"response": {"errors": [{"code", "message"}]}} |
official-spec |
| Rates | POST /api/rating/v2409/{requestoption} (Rating.yaml, operation Rate): Shop returns every service of the lane in one answer; Shoptimeintransit adds the transit time. The add-on tries Shoptimeintransit and, when UPS refuses it with a 4xx other than 401 / 403 and the message is not about the address, repeats the request as Shop |
RateRequest.Request.{RequestOption, TransactionReference}, PickupType.Code (01 / 03 / 06 / 19 / 20), CustomerClassification.Code (00 / 01 / 04 / 05, US origin only), Shipment.{Shipper{ShipperNumber, Address}, ShipTo.Address{..., ResidentialAddressIndicator}, ShipFrom.Address, ShipmentRatingOptions.NegotiatedRatesIndicator, Package[]{PackagingType, Dimensions, PackageWeight, PackageServiceOptions{DeliveryConfirmation.DCISType, DeclaredValue}}, ShipmentTotalWeight, DeliveryTimeInformation{PackageBillType, Pickup.Date}, InvoiceLineTotal}. Reads RateResponse.RatedShipment[] (Service.Code, TotalCharges, NegotiatedRateCharges.TotalCharge, TimeInTransit.ServiceSummary.EstimatedArrival.{Arrival.Date, BusinessDaysInTransit}, GuaranteedDelivery) |
official-spec (NegotiatedRatesIndicator takes any value, the official examples send "Y"; ResidentialAddressIndicator exists = residential; RateRequest.Request.RequestOption is listed as required but has no property in the schema, every official example sends it, so we send it too) |
| Create shipment (label) | POST /api/shipments/v2409/ship (Shipping.yaml, operation Shipment) |
ShipmentRequest.{Request{RequestOption "nonvalidate", TransactionReference}, Shipment{Description, ReturnService, Shipper{Name, AttentionName, Phone, ShipperNumber, EMailAddress, Address}, ShipTo, ShipFrom, PaymentInformation.ShipmentCharge[]{Type 01 / 02, BillShipper / BillThirdParty}, Service.Code, ShipmentServiceOptions.InternationalForms, ShipmentRatingOptions, ReferenceNumber[], Package[]{Description, Packaging, Dimensions, PackageWeight, ReferenceNumber[], PackageServiceOptions}, InvoiceLineTotal{CurrencyCode, MonetaryValue}}, LabelSpecification{LabelImageFormat.Code GIF / ZPL, HTTPUserAgent, LabelStockSize}}. Shipment.InvoiceLineTotal is sent for forward shipments from the US to Canada or Puerto Rico only (the schema: “Required for forward shipments whose origin is the US and destination is Puerto Rico or Canada. Not available for any other shipments”): the goods value of the customs lines without an included tax, rounded up to a whole number from 1 to 99999999. Reads ShipmentResponse.ShipmentResults.{ShipmentIdentificationNumber, PackageResults[]{TrackingNumber, ShippingLabel{ImageFormat.Code, GraphicImage}}, ShipmentCharges.TotalCharges, NegotiatedRateCharges.TotalCharge, Form.Image{ImageFormat.Code, GraphicImage}} |
official-spec (limits: names 35, address lines 35 x 3, city 30, state 5, postal code 9, phone 15, e-mail 50, Shipment.Description 50, Package.Description 35) |
| Return label | same endpoint with Shipment.ReturnService.Code = 9 (UPS Print Return Label, PRL); ShipFrom is the customer, ShipTo and Shipper the merchant, Package.Description is required. Services 13, 59, 82, 83, 84, 85, 86, C6, C7, C8 are not available for returns and are refused before the call |
official-spec | |
| International invoice | ShipmentServiceOptions.InternationalForms: FormType ["01"], Contacts.SoldTo{Name, AttentionName, Phone, Address}, Product[]{Description[], Unit{Number, UnitOfMeasurement.Code, Value}, CommodityCode, PartNumber, OriginCountryCode}, InvoiceNumber, InvoiceDate, ReasonForExport "SALE", CurrencyCode, TermsOfShipment (DAP / DDP), OverridePaperlessIndicator (“the application will automatically provide a copy of the invoice … regardless of whether the user has enabled Paperless”), FormGroupIdName. The invoice PDF comes back in ShipmentResults.Form.Image.GraphicImage |
official-spec; flagged: FormType is an array in the schema but a string in the official International Forms example (we send the array); UnitOfMeasurement.Code PCS comes from the “Product Unit of Measure Codes” appendix that the OpenAPI file does not contain |
|
| Duties and taxes payer | PaymentInformation.ShipmentCharge[] with Type 02: BillShipper.AccountNumber (DDP) or BillThirdParty{AccountNumber, Address{PostalCode, CountryCode}}. “If Duty and Tax charges are applicable … and a payer is not specified, the default payer … is Bill to Receiver”, so the recipient option sends nothing |
official-spec | |
| Void shipment | DELETE /api/shipments/v2409/void/cancel/{shipmentidentificationnumber} (operation VoidShipment), optional query trackingnumber=["1Z...","1Z..."] |
reads VoidShipmentResponse.SummaryResult.Status.Code (1 = voided) |
official-spec |
| Label recovery | POST /api/labels/v1/recovery (operation LabelRecovery, v1 = labels only) |
LabelRecoveryRequest.{Request, LabelSpecification, TrackingNumber, ReferenceValues{ReferenceNumber.Value, ShipperNumber}}. Reads LabelRecoveryResponse.{ShipmentIdentificationNumber, LabelResults[]{TrackingNumber, LabelImage{LabelImageFormat.Code, GraphicImage}}} |
official-spec; the by-reference path is tied to Roadie shipments in the file (“This is mandatory for only Roadie shipments”), so it is only the last, best-effort try; the main path is by TrackingNumber, see “Safe label purchase” |
| Track by reference | GET /api/track/v1/reference/details/{referenceNumber}?locale=en_US&refNumType=SmallPackage&fromPickUpDate=YYYYMMDD&toPickUpDate=YYYYMMDD&shipperNum=<account> (Tracking.yaml, transId and transactionSrc required) |
reads trackResponse.shipment[].package[].{trackingNumber, referenceNumber[]{number, type}}; used to find the tracking number of a lost purchase by our reference |
official-spec (the file does not state the date format of fromPickUpDate / toPickUpDate, only the default currentDate-14; the add-on sends YYYYMMDD, not confirmed in the UPS test environment) |
| Track | GET /api/track/v1/details/{inquiryNumber}?locale=en_US (Tracking.yaml, transId and transactionSrc required) |
reads trackResponse.shipment[0].package[0].{currentStatus{type, code, description, simplifiedTextDescription}, activity[]{date, time, status.description, location.address{city, stateProvince, countryCode}}}; HTTP 404 = “not found” (a new label is unknown until its first scan) |
official-spec; flagged: the status type letters (D delivered, X exception, RS returned to sender, MV voided, M label created, I / P / O in transit) come from the Track API appendix, which the OpenAPI file does not contain; an unknown letter keeps the shipment “in transit” |
| Address validation | POST /api/addressvalidation/v2/3?maximumcandidatelistsize=3 (AddressValidation.yaml, requestoption 3 = validation and classification) |
XAVRequest.AddressKeyFormat{AddressLine[], PoliticalDivision2, PoliticalDivision1, PostcodePrimaryLow, PostcodeExtendedLow, CountryCode}; reads XAVResponse.{ValidAddressIndicator, AmbiguousAddressIndicator, NoCandidatesIndicator, AddressClassification.Code (0 / 1 commercial / 2 residential), Candidate[].AddressKeyFormat}. Street level validation exists for the US and Puerto Rico only (“Applicable to US and PR only”); other countries are refused without a call |
official-spec |
| Create pickup | POST /api/pickupcreation/v2409/pickup (Pickup.yaml, operation Pickup Creation) |
PickupCreationRequest.{Request, RatePickupIndicator "N", Shipper.Account{AccountNumber, AccountCountryCode}, PickupDateInfo{CloseTime HHmm, ReadyTime HHmm, PickupDate yyyyMMdd}, PickupAddress{CompanyName 27, ContactName 22, AddressLine[], City, StateProvince, PostalCode, CountryCode, ResidentialIndicator, Phone.Number}, AlternateAddressIndicator, PickupPiece[]{ServiceCode, Quantity, DestinationCountryCode, ContainerCode 01}, TotalWeight, OverweightIndicator, PaymentMethod 01, SpecialInstruction 57}; reads PickupCreationResponse.PRN (11 characters; “the same PRN will be returned if a pickup request already exists for a pickup address”) |
official-spec; flagged: PickupPiece.ServiceCode has three characters (“Service Codes in the Appendix”); the add-on pads the two character ship code (03 becomes 003), which matches the official example (001, 012) but the appendix is not in the file. PickupDateInfo.PickupDate has maxLength 4 in the schema while its format is yyyyMMdd (the official example sends 8 characters) |
| Cancel pickup | DELETE /api/shipments/v2409/pickup/02 (CancelBy 02 = PRN) with header Prn: <PRN> |
reads PickupCancelResponse.Response.ResponseStatus.Code (1) |
official-spec |
| Error body | HTTP 4xx / 5xx: {"response": {"errors": [{"code", "message"}]}} (Rating, Shipping, Address Validation, Pickup, OAuth) or {"errors": [...]} (Tracking) |
message and code are shown (masked) in the log and to the admin | official-spec |
Not used: the Paperless Documents API (/api/paperlessdocuments/v2/upload, /image, DocumentId): documents the merchant uploads (form type 07, UserCreatedForm.DocumentID) are out of scope; the invoice is generated by the Ship API (InternationalForms) instead. Also out of scope: Ground Freight Pricing, freight, Mail Innovations, UPS Access Point delivery, dangerous goods, Smart Pickup and the Locator, Landed Cost and Time in Transit APIs.
How this relates to the core UPS module
Section titled “How this relates to the core UPS module”Checked in the 4.17.2 (Ultimate and Multi-Vendor), 4.18.3 and 4.21.2 source trees:
app/Tygh/Shippings/Services/Ups.phpis already an OAuth REST client (token at/security/v1/oauth/token, Rating at/api/rating/<version>/Rate,ShipperNumberandCustomerClassification 00for negotiated rates, an Address Validation call only to detect “residential”). The path version isv1in 4.17.2 / 4.18.3 (a deprecated path version in the Rating file) andv2409in 4.21.2. It quotes rates only: one service per shipping method, one call per method, no labels, voids, returns, tracking, pickups, customs or paperwork.Upsdeprecated.php(XML, access key,onlinetools.ups.com/ups.app/xml/Rate) is still shipped in all three trees but is the deprecated path.- This add-on adds everything after the rate: labels, multi-package, returns, void, label recovery, tracking, pickups, international invoice and address validation, one Rating call for all services (
Shop) and delivery dates. It registers its own carrier (modulekv_ups_rest) and tables, so it can run next to the core module; use one or the other for the same checkout method.
Open items to confirm in the UPS test environment
Section titled “Open items to confirm in the UPS test environment”The field names of every call are verified against the official OpenAPI files (above). What remains:
- The add-on has not been run against the UPS Customer Integration Environment or production (a UPS Developer Portal app and a UPS account are needed); it was run against a local mock that follows the same files (
tests/mock-server/server.js). - Recovery of a lost purchase: the Tracking API reference search (above) and Label Recovery by
TrackingNumberare documented calls, but neither was run against UPS; whether a shipment that is only minutes old is already found by the reference search is not stated. Label Recovery by our reference (ReferenceValues) is documented for Roadie shipments only and is a last try. If nothing produces a label the attempt stays “unresolved” (no second purchase) and the admin adopts a tracking number found in the UPS shipping history; an empty answer is never presented as proof that UPS holds no shipment. - Reference numbers:
Shipment.ReferenceNumberis valid for lanes other than US/US and PR/PR,Package.ReferenceNumberfor US/US and PR/PR (the schema’s own wording), so the add-on puts our reference on the package for those two lanes and on the shipment elsewhere. The referenceCodeis optional and left out (the reference code table is in an appendix that is not in the files). - Currency:
DeclaredValue.CurrencyCode“must represent a currency that is valid for the Shipper country”, and the customs forms andInvoiceLineTotaluse the store currency as well. The add-on always sends the store’s primary currency, so use declared value and international labels only when the store currency is accepted for the ship-from country and the UPS account (an account billed in another currency may be refused). - Pickup service codes (
003) and the Track status letters, see the table. LabelSpecification.LabelStockSizeis required by the schema but “ignored for other label image codes” than EPL / ZPL / SPL, so GIF requests send6 x 4and no user-visible effect.- Official examples that contradict their schema (our requests follow the schema;
python tests/spec-check.py fixtureslists them): single objects where the schema says array (Package,ShipmentCharge,FormType, pickupAddressLine),ShipTo.ResidentialandShipFrom.EMailAddressandProducer.EmailAddresswhich have no schema property, a booleanStateProvinceCode. - Brand: UPS marks may only be used under the UPS brand rules. The add-on and its gallery use the name “UPS” as text only; no logo, no brown livery.
Structure
Section titled “Structure”| Class | Role |
|---|---|
Client\BaseClient |
HTTP (cURL, TLS verification, timeouts), JSON, OAuth2 token cache (encrypted), one re-login on 401, error mapping, masked logging |
Client\Api |
The UPS calls above (paths, versions, Basic token auth, transId / transactionSrc) |
Client |
Client per company, the connection test |
Accounts, Secret |
Credentials (test and live) sealed with AES-256-GCM; one account per store owner or per vendor; non-secret fields (account number, ship-from, defaults) in extra |
Config, Access |
Settings, owner mode, who may see and manage what (Access::canLabel() guards every money action) |
RateCalculator |
Rate request, rate list parse (negotiated rate wins), transit time, markup, free shipping, fallback; memo and 10 minute cache per request |
Shipments |
Payload (outbound, return, international invoice, duties payer), multi-package, safe purchase, label recovery, void, tracking, downloads |
Pickups, Addresses |
Pickup API, Address Validation API |
Parties, Packer, Clock, Money |
Address and party mapping (multi-byte safe cuts), units and package split, shop time zone dates |
OrderTax |
Reads order taxes in both CS-Cart tax calculation methods; used to state the customs value without tax that is already in the price |
Idempotency |
key(), per-object lock() / unlock(), atomic claim() |
Queue, Jobs, JobException, Log |
Job queue (back-off 5 min / 15 min / 1 h / 3 h / 12 h), recurring work, event log with masking |
Safe label purchase
Section titled “Safe label purchase”UPS has no idempotency key for Ship. The add-on guarantees that it never buys a second label by itself, and it offers documented lookups to find a lost one:
- A MySQL named lock per order (
Idempotency::lock('order-<id>')) serialises create and return; void and tracking use a per-shipment lock. Discarding a pending or unresolved attempt takes the order lock without waiting and is refused while a request is still in flight, so a paid label can never lose its row. - Record before the call: a row with status
Q, arequest_key(hash of order, kind and shipment content, not the day) and the referencekv-<order>-<attempt>(stored inprovider_ref) is written first. The reference travels in the request (transId,Request.TransactionReference.CustomerContext,ReferenceNumber). The same request finds the row: a finished one is returned, a running one makes the job wait. - A timeout, a connection error or HTTP 408 / 500 / 502 / 504 turns the row into
U(unresolved) and the add-on looks for the shipment: (1) Tracking API search by our reference, then Label Recovery by theTrackingNumberfound (it must belong to the account: characters 3 to 8 of a 1Z number are the shipper number); (2) the admin can paste a tracking number found in the UPS shipping history (Adopt this tracking number; it is recovered the same way, refused when it is not a 1Z number, belongs to another account or is already stored); (3) Label Recovery by reference as a best-effort last try. A label found is adopted (claimed withUPDATE ... WHERE status = 'U', stored, core shipment created, tracking queued) and no second purchase is made. When nothing is found, nothing retries automatically and no new label is possible for the order: an empty answer is not conclusive, so the admin presses Check with UPS again later or searches the UPS shipping history, and discards the attempt only when sure. HTTP 400 / 401 / 403 / 404 / 429 / 503 delete the row because UPS did not process the request. - Pickups use the same pattern (
request_key,Ustatus); UPS itself answers a repeated request for the same pickup address with the same PRN. - Orders with status cancelled, declined, failed, backordered, incomplete or parent are refused for labels (returns stay possible for cancelled and backordered orders).
- The named lock needs MySQL 5.7+ / MariaDB 10.0.2+ to coexist with the queue’s own lock (older servers release the first lock when another is taken; purchases stay protected by the attempt rows).
Database tables
Section titled “Database tables”| Table | Purpose |
|---|---|
?:kv_ups_rest_accounts |
Credentials per company (secrets sealed), last connection check, token cache. The *_webhook columns are unused (shared schema) |
?:kv_ups_rest_log |
Log |
?:kv_ups_rest_jobs |
Queue |
?:kv_ups_rest_shipments |
Shipments: kind (outbound / return), service code, status, tracking number, shipment identification number (provider_ref), scan events |
?:kv_ups_rest_pieces |
One row per package label and per document (commercial invoice), file data (base64) |
?:kv_ups_rest_pickups |
Pickups: the pickup request number is in confirmation, the service code in carrier |
?:kv_ups_rest_addr |
Address validation result per order and address hash |
Tracking link
Section titled “Tracking link”Tygh\Shippings\Services\KvUpsRest::getInfo() returns https://www.ups.com/track?loc=en_US&tracknum=%s; core replaces the %s with the tracking number (Shippings::getCarrierInfo). The e2e checks that the admin order page and the stored link carry the real number and never a %s or [tracking_number] placeholder.
Hooks used
Section titled “Hooks used”PHP: change_order_status_post (label at a status), delete_order (removes shipments, labels and the address check), init_templater_post (upgrade synchronizer, only while pending). Template hooks: orders:tabs_extra (admin tab), addons:manage / addons:tabs_content (licence card). Controllers: kv_ups_rest (admin, vendor, storefront cron), orders.post (tab data). No storefront output apart from the shipping rates, so there is nothing to cache or exclude from the full page cache; rates are only requested while a cart is calculated.
php81 tests/unit.phpandphp83 tests/unit.php: mappers, requests, answers, errors, idempotency keys and the recurring-defect checks (tests/defects.php). The sample requests of the official OpenAPI files are intests/fixtures/(rate_request_*,ship_request_*,label_recovery_request,xav_request,pickup_create_request); the OpenAPI files carry no response examples (except Pickup creation), so the response fixtures are built from the response schemas.php tests/php/dump_requests.php <dir>thenpython -I tests/spec-check.py requests <dir>: validates every request the add-on can build (rate, ship domestic / ZPL / return / international DDP, DAP and third party, recovery, address validation, pickup) against the official schemas: unknown properties, required properties, lengths, enums.python -I tests/spec-check.py fixturesdoes the same for the fixtures and reports where the official examples deviate from their own schema.tests/mock-server/server.js(port 11860) andtests/e2e/e2e.mjs,tests/e2e/vendor-smoke.mjs: HTTP e2e against the mock on a dev store.
Testing against a mock
Section titled “Testing against a mock”Define the base URL in config.local.php to send all UPS calls to another host, for example the mock in tests/mock-server/server.js:
$config['kv_ups_rest_base_url'] = 'http://127.0.0.1:11860';Extending
Section titled “Extending”The service list is Installer::services() (code to name; codes are the UPS Service.Code values). Add a code there and in fn_kv_ups_rest_upgrade_sync() (it re-runs Installer::install()).