Skip to content

Developer notes

Technical reference for developers who customize UPS REST Rates & Labels.

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.py validates 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.

Checked in the 4.17.2 (Ultimate and Multi-Vendor), 4.18.3 and 4.21.2 source trees:

  • app/Tygh/Shippings/Services/Ups.php is already an OAuth REST client (token at /security/v1/oauth/token, Rating at /api/rating/<version>/Rate, ShipperNumber and CustomerClassification 00 for negotiated rates, an Address Validation call only to detect “residential”). The path version is v1 in 4.17.2 / 4.18.3 (a deprecated path version in the Rating file) and v2409 in 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 (module kv_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:

  1. 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).
  2. Recovery of a lost purchase: the Tracking API reference search (above) and Label Recovery by TrackingNumber are 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.
  3. Reference numbers: Shipment.ReferenceNumber is valid for lanes other than US/US and PR/PR, Package.ReferenceNumber for 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 reference Code is optional and left out (the reference code table is in an appendix that is not in the files).
  4. Currency: DeclaredValue.CurrencyCode “must represent a currency that is valid for the Shipper country”, and the customs forms and InvoiceLineTotal use 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).
  5. Pickup service codes (003) and the Track status letters, see the table.
  6. LabelSpecification.LabelStockSize is required by the schema but “ignored for other label image codes” than EPL / ZPL / SPL, so GIF requests send 6 x 4 and no user-visible effect.
  7. Official examples that contradict their schema (our requests follow the schema; python tests/spec-check.py fixtures lists them): single objects where the schema says array (Package, ShipmentCharge, FormType, pickup AddressLine), ShipTo.Residential and ShipFrom.EMailAddress and Producer.EmailAddress which have no schema property, a boolean StateProvinceCode.
  8. 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.
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

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, a request_key (hash of order, kind and shipment content, not the day) and the reference kv-<order>-<attempt> (stored in provider_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 the TrackingNumber found (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 with UPDATE ... 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, U status); 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).
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

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.

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.php and php83 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 in tests/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> then python -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 fixtures does the same for the fixtures and reports where the official examples deviate from their own schema.
  • tests/mock-server/server.js (port 11860) and tests/e2e/e2e.mjs, tests/e2e/vendor-smoke.mjs: HTTP e2e against the mock on a dev store.

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';

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()).