Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/VisualGraphxLLC/API-HUB/llms.txt

Use this file to discover all available pages before exploring further.

PromoStandards is the wholesale promotional products industry’s open data standard. It defines a set of SOAP web services — Product Data, Pricing & Configuration, Media Content, Inventory — that compliant suppliers implement at their own URLs. API-HUB can connect to any of the 994+ suppliers registered in the PS directory without writing supplier-specific code, as long as you have valid credentials.

What PromoStandards is

PromoStandards specifies versioned WSDL contracts. Suppliers implement whichever service versions they support. The platform speaks to the following service types:
ServicePS versionSOAP operation
Product Data2.0.0getProduct, getProductSellable, getProductDateModified, getProductCloseOut
Pricing & Configuration1.0.0getConfigurationAndPricing
Media Content1.1.0getMediaContent
Inventory2.0.0getInventoryLevels
Every SOAP call passes wsVersion, id, and password as authentication fields. Some calls also include localizationCountry and localizationLanguage.

The PS directory

The PS directory is a public registry of all PromoStandards-compliant suppliers. API-HUB fetches it via:
GET /api/ps-directory/companies
This returns a list of companies, each with a Code (the PS supplier code) and metadata. You can look up the WSDL endpoints for a specific supplier:
GET /api/ps-directory/companies/{code}/endpoints
When a supplier is created with protocol: promostandards, API-HUB caches the WSDL URLs returned by the directory into the endpoint_cache JSONB column on the supplier record. The adapter reads endpoint_cache at runtime to resolve WSDL URLs for each service type without hitting the directory on every sync.
endpoint_cache_updated_at records when the cache was last refreshed. If a supplier updates their WSDL URLs (rare but possible), you can refresh the cache by fetching GET /api/suppliers/{id}/endpoints, which re-queries the directory and updates the record.

Finding a supplier’s PS code

1

Open the PS directory in the admin UI

Navigate to PS Directory in the left sidebar. The page lists all 994+ registered companies with their codes, names, and supported service versions.
2

Search by name or code

Use the search box to filter by company name (e.g. SanMar) or by code (e.g. SANMAR). The Code field is what you paste into the PromoStandards code field when creating a supplier.
3

Confirm services

Check which services the supplier has registered. A supplier must have at least Product Data endpoints for a basic import. Pricing and Media endpoints are fetched opportunistically — missing ones produce a warning log rather than an error.

SOAP communication via zeep

API-HUB uses zeep (a Python SOAP library) wrapped with asyncio.to_thread because zeep is synchronous. Each SOAP call runs in a thread-pool thread and does not block the FastAPI event loop.

WSDL caching

PromoStandardsClient is instantiated once per service type per adapter instance. On first use, zeep parses the WSDL and caches the parsed result to an SQLite file on disk via zeep.cache.SqliteCache. Subsequent instantiations for the same WSDL URL skip re-parsing, which reduces startup time for frequently synced suppliers.
transport = Transport(
    cache=SqliteCache(),
    timeout=30,           # connection timeout
    operation_timeout=120 # per-call timeout
)
client = ZeepClient(wsdl_url, transport=transport, settings=settings, plugins=[history])
self._service = client.service
The HistoryPlugin is attached to every client so the raw XML envelope is accessible after each call. This is required for SOAP fault classification, which reads the envelope directly rather than relying on zeep’s exception types.

Service bootstrap retry

Some suppliers return a WSDL at a URL that lacks the ?wsdl query parameter as a default service binding. If zeep raises ValueError: no default service defined, the client automatically retries with ?wsdl appended to the URL:
except ValueError as e:
    if "no default service defined" in str(e).lower() and "?wsdl" not in self.wsdl_url.lower():
        retry_url = self.wsdl_url + ("&wsdl" if "?" in self.wsdl_url else "?wsdl")
        client = ZeepClient(retry_url, ...)

SOAP fault classification

PromoStandards suppliers signal errors as SOAP Fault envelopes. The adapter parses every response envelope for a <Fault> element and raises the appropriate Python error type.
_AUTH_CODES = {"100", "104", "110"}

def _classify_fault_xml(xml_bytes: bytes) -> None:
    root = etree.fromstring(xml_bytes, _PARSER)
    fault = root.xpath("//*[local-name()='Fault']")
    if not fault:
        return   # Not a fault — caller continues normally

    code = ...  # extracted from <ErrorMessage><Code>
    message = ...

    if code in _AUTH_CODES:
        raise AuthError(f"[{code}] {message}")
    raise SupplierError(message, code or "999")
Fault codeMeaningError raisedEffect
100Invalid credentialsAuthErrorJob aborts immediately
104Account inactive / not authorizedAuthErrorJob aborts immediately
110Password expiredAuthErrorJob aborts immediately
Any other codePer-product supplier errorSupplierErrorProduct skipped, import continues
An AuthError stops the entire import job. If you see code 100 or 104 in the sync job log, check that the id and password in auth_config match the credentials issued by the supplier’s portal exactly — many PS suppliers use separate credentials for their SOAP APIs.

TransientError and retry

Network timeouts and zeep.exceptions.TransportError are caught and re-raised as TransientError. The import orchestrator retries up to three times with exponential backoff (4 s → 2 s → 1 s). If all retries are exhausted, the product is logged as skipped and the import continues with the next product.
try:
    svc.getProduct(productId=ref.supplier_sku, **client._auth(...))
except TransportError as te:
    raise TransientError(f"Network timeout: {te}") from te

Product hydration flow

For each ProductRef discovered, PromoStandardsAdapter.hydrate_product() makes up to three SOAP calls:
  1. getProduct — core product data: name, description, brand, categories, parts (color, size)
  2. getConfigurationAndPricing — pricing tiers per part, currency USD, price type Net, configuration Blank
  3. getMediaContent — image URLs, media type Image
Pricing and media calls are wrapped in try/except individually. If either fails, the product is stored with whatever data was successfully retrieved and a warning is logged. A supplier that only exposes Product Data will still produce importable records.

Defensive XML parsing

All XML is parsed with a hardened lxml parser that prevents external entity injection (XXE) attacks:
_PARSER = etree.XMLParser(
    resolve_entities=False,
    no_network=True,
    huge_tree=False,
)
  • resolve_entities=False — disables entity expansion entirely.
  • no_network=True — prevents the parser from fetching external DTDs or schemas.
  • huge_tree=False — rejects documents that exceed lxml’s default depth and node limits.
This parser is used for every etree.fromstring call in the adapter, including fault classification.

SanMarAdapter

SanMar is a PromoStandards supplier with a few platform-specific deviations. SanMarAdapter is a thin subclass of PromoStandardsAdapter that overrides WSDL resolution with hardcoded fallbacks.

Hardcoded WSDLs

SanMar’s PS endpoint cache entries are not always complete. SanMarAdapter._wsdl_for() first checks the PS directory cache, then falls back to these hardcoded URLs:
SANMAR_WSDLS = {
    "PRODUCT":    "https://promostandards.sanmar.com/ProductDataService/v200?wsdl",
    "MEDIA":      "https://promostandards.sanmar.com/MediaService/v110?wsdl",
    "PRICING":    "https://promostandards.sanmar.com/PricingAndConfigurationService/v100?wsdl",
    "INVENTORY":  "https://promostandards.sanmar.com/InventoryService/v200?wsdl",
}

Extended authentication

SanMar’s non-standard category extension service (getProductInfoByCategory) uses a webServiceUser complex type instead of the standard PS auth fields. PromoStandardsClient._sanmar_auth_payload() builds this type from the same auth_config dict, reading additional keys like customer_number and username alongside the standard id and password.

Category-driven discovery

SanMar publishes a fixed list of 19 categories in their Web Services Integration Guide. The client uses this static list (no SOAP call) to drive category-by-category fetches of getProductInfoByCategory. Discontinued products (status Discontinued or title prefixed with DISCONTINUED) are filtered out during normalization.

Setting up SanMar

{
  "name": "SanMar",
  "slug": "sanmar",
  "protocol": "promostandards",
  "promostandards_code": "SANMAR",
  "adapter_class": "SanMarAdapter",
  "auth_config": {
    "id": "your-ps-id",
    "password": "your-ps-password",
    "customer_number": "your-customer-number",
    "username": "your-sanmar-username"
  }
}
SanMar issues separate credentials for their PS SOAP services and their web portal. Use the credentials from their Web Services Integration Guide, not the ones from sanmargroup.com login.

Zeep response tolerance

PromoStandards implementations in the field deviate from the WSDL spec. Some suppliers return lowerCamelCase where the spec says CamelCase. Others omit optional array wrappers or return a single object where an array is expected. The client uses two helpers to absorb these deviations:
def _attr(obj, *names, default=None):
    """Return the first attribute in names that exists on obj."""

def _as_list(value):
    """Normalize a zeep single-item-or-list into a list."""
Per-item parse errors during batch fetches are swallowed with a warning log rather than failing the whole batch. This means one malformed product record does not abort a 5,000-product sync.

Build docs developers (and LLMs) love