The UnoPim connector

The whole UnoPim route — what has to exist in the PIM, how the connection is configured, what the first sync publishes, and where the data surfaces in DDM and on the published passport page.

Two halves, and only one of them is installed

The UnoPim route has two halves, and it helps to keep them apart from the first minute. One half is the connector: a service of ours that reads UnoPim over its REST API, maps what it finds onto GS1 terms, publishes it into the catalog and writes a short answer back. That half needs nothing installed inside the PIM. The other half is an optional PHP package that puts a panel above the product form, so that the person editing a product can see what the platform currently says about it, draw a number, or deposit one they already have.

The reason the data half installs nothing is deliberate. An OAuth client, one api_keys row, a handful of attributes and one webhook are the entire footprint, so nothing in a customer's PIM has to be kept in step with our repository. A connector that shipped a plugin would tie every upgrade of ours to a deployment window in someone else's system; a connector that only speaks the published REST API does not.

Everything below is verified against UnoPim 3.0.0.

What has to exist in UnoPim

Start from the products themselves. UnoPim holds them the way a PIM does: a SKU, an attribute family, values in up to four sections — common, locale_specific, channel_specific and channel_locale_specific — and media files in its own storage. The connector reads all four sections and lets the more specific value win: channel and locale beats channel, which beats locale, which beats common.

Figure 1: The product list in UnoPim — the SKU is the record key the connector works from

An API client, and the row beside it

The REST API uses OAuth2 with the password grant (Laravel Passport). Create the client in the admin UI under Integrations → API Keys, because that route creates the OAuth client and the matching api_keys row in one step. The row matters more than it looks: ScopeMiddleware refuses any request whose admin has no row in api_keys, answering This action is unauthorized even when the token itself is perfectly valid. If you build the client by hand instead, three details of 3.0.0 will bite — the artisan command creates the client and then dies before printing its credentials, client secrets are stored hashed so a plaintext secret answers Client authentication failed, and grant_types is already cast to an array, so writing JSON into it double-encodes the value.

A handful of attributes

UnoPim's own passport preset (php artisan unopim:passport:install-preset) creates the passport template and its fields and leaves every field unsourced — it does not create attributes. So the GTIN attribute is yours to make: create dpp_gtin as a text attribute, add it to the families in use, and point the template's GTIN field at it. For lot-level records the same preset names dpp_batch_identifier (role Batch), which the connector reads as the batch qualifier; source.options.batch-from points it elsewhere.

Two more attributes exist because UnoPim has no field of its own for them, and both are input rather than output: ddm_gpc_code carries the GS1 product classification, and ddm_target_market the market a product is destined for. One attribute is written by the connector, ddm_action_required — described further down. The ddm_ prefix runs through all of ours so that nothing collides with UnoPim's own dpp_* preset codes; removing the connector means deleting the ddm_ attributes and nothing else.

Figure 2: The attributes the route needs — UnoPim's own dpp_ passport codes and our ddm_ additions, side by side in one group

One webhook

Under Settings → Webhooks, subscribe to product.created and product.updated and point them at:

https://<connectors-host>/webhooks/unopim/<connection-name>

Set the same secret you configure as source.webhook-secret. UnoPim signs with X-Unopim-Signature: sha256=<hex>, an HMAC-SHA256 over the raw body, and the connector verifies that signature before it does anything else; an unsigned or wrongly signed call is answered with 401.

The webhook is a doorbell, and nothing more. Its payload is never read as data — it names a SKU, and the record is then fetched fresh from the API. That is not caution for its own sake: measured against 3.0.0, a create delivers twice (product.created and product.updated, identical bodies), a failed delivery is never retried, and on a create the payload does not even carry the dpp_gtin set in the very same request. A doorbell that rings twice costs nothing, because publishing is idempotent; a doorbell whose payload you trusted would cost data.

The connection on our side

A connection is a block of configuration naming one UnoPim and one catalog:

openepcis.connectors.connections.pim1.source.type=unopim
openepcis.connectors.connections.pim1.source.url=https://pim.example.test/api/v1/rest
openepcis.connectors.connections.pim1.source.auth.kind=oauth2-password
openepcis.connectors.connections.pim1.source.auth.key=<oauth client id>
openepcis.connectors.connections.pim1.source.auth.secret=<oauth client secret>
openepcis.connectors.connections.pim1.source.auth.username=<admin email>
openepcis.connectors.connections.pim1.source.auth.password=<admin password>
openepcis.connectors.connections.pim1.source.webhook-secret=<shared secret>
openepcis.connectors.connections.pim1.target.resolver-url=https://id.example.test
openepcis.connectors.connections.pim1.target.gcp-prefixes=9521234
openepcis.connectors.connections.pim1.target.access-level=Public
openepcis.connectors.connections.pim1.target.default-net-content=1 H87

There is no configuration file of our own: this is the whole surface, read as ordinary Quarkus properties. In a cluster the same keys arrive as environment variables — the property name upper-cased, every non-alphanumeric character turned into _, so …pim1.target.offline-token becomes OPENEPCIS_CONNECTORS_CONNECTIONS_PIM1_TARGET_OFFLINE_TOKEN — and the secret-bearing ones (the OAuth secret, the admin password, the webhook secret, the offline token) belong in a Kubernetes secret mounted into the deployment, not in a properties file. The block above names the keys; it is not the place to keep their values. Where the offline token itself comes from — issuing one, why it has to be an offline token, and what revoking it does to a running connection — is Integration Tokens.

source.url is the REST base path; the token endpoint is derived from the origin (/oauth/token) and only needs configuring if a deployment moves it. Two optional settings name the scope values are read in, source.options.locale and source.options.channel. They decide which scope a single value is read from; leaving them out reads common values only, which is the honest default, because choosing a locale on the tenant's behalf publishes a value nobody asked for. Translations are a separate matter and are collected whatever these say — see the language map below.

Two of the target settings deserve a sentence. access-level is the publication tier, and resolution answers an anonymous reader with 404 No records found until a record is Public — a passport meant to land on a consumer's phone has to say so. default-net-content is the quantity a record states when it states none, written as "<value> <unit code>" and usually 1 H87, one piece: GS1 Germany rejects a product registration without a net content, and for an indivisible article there is nothing to measure. Where the source does say something — 250 g of coffee — the source wins.

One identifier, and where it lives

UnoPim knows nothing about GS1. It knows a SKU, which is a unique record key, and the connector decides per connection what role that SKU plays:

openepcis.connectors.connections.pim1.source.options.gtin-from=composed   # the default
openepcis.connectors.connections.pim1.source.options.gtin-from=sku
openepcis.connectors.connections.pim1.source.options.gtin-from=dpp_gtin

With composed the SKU carries the article reference: everything up to the first hyphen — the whole SKU where there is none — is left-padded with zeroes, joined to the company prefix from target.gcp-prefixes and completed with a computed check digit. Nothing is held twice, and nobody can mistype a check digit because nobody types it. With sku the SKU already is the full GTIN, which is the case for merchandise that arrives with someone else's EAN — composing would be wrong there, because the prefix in such a key is not yours. With an attribute code, usually dpp_gtin, the key lives in a field of its own; that is the mode for non-numeric SKUs and for keys that come from elsewhere. A tenant licensed for several prefixes is a normal case rather than an error, but it does rule composing out: a composition would have to guess which prefix an article belongs to, and the connector will not guess. The key then has to come from a field — the SKU itself where it already is the full GTIN, or an attribute that holds it.

The hyphen is what lets batch records live as ordinary products in the PIM. 95201 and 95201-L240815 compose to the same key, 9521234952015, so a batch shares its model's GTIN and the suffix serves nothing but UnoPim's own requirement that a SKU be unique. The batch number itself comes from the attribute — dpp_batch_identifier unless source.options.batch-from names another — and such a record is published one level down, at the instance path, while the same record is deliberately skipped as a model record so that batch data never overwrites the model's. In sku mode and in attribute mode nothing is split at a hyphen: the value is taken as the key exactly as it stands. The hyphen rule is the UnoPim connector's own; the other connectors read their key from the source and know nothing of it.

Every refusal is loud. A missing prefix, a non-numeric article reference, a reference that will not fit — each is named in the log and the record is skipped, rather than producing a key that points at somebody else's product. And composed refuses outright as soon as a connection lists more than one prefix; that choice is never guessed.

The first sync

Leave dry-run at its factory value of true for the first pass. Arming a connection is an explicit act, because downstream of the catalog sit real registries, GS1 production among them. Before that first pass, ask the diagnosis ladder what it can already do:

curl https://<connectors-host>/connections/pim1/diagnostics

It reports the platform token, an authenticated catalog read, the tenant claim on the catalog, source reachability, the webhook configuration and — where the connection has it enabled — EPCIS capture as separate rungs, so a failure tells you which of them it was. Then run one poll with dry-run=true and read the log: it prints exactly what it would have published.

The poll itself pages /products a hundred at a time, filtered on updated_at, and carries a running maximum as its cursor. Records that have not changed are recognised by a canonical SHA-256 hash of the payload and are not written again; the stamp lives in a declared control field on the catalog record, connectorStamps, keyed by connection name, so several connections can stamp the same record without erasing each other. On top of the delta poll runs a nightly full sweep, and that sweep — not the webhook — is the real reconciliation. The webhook only makes things prompt.

What the default mapping carries

A mapping is data, not code: one YAML file per connection, unopim-default.yaml being the one every connection starts from. It is worth knowing what it already carries, because most installations only ever add to it.

UnoPim's name becomes productName. Short and long text are kept apart on purpose: short_description becomes productDescription, the one-line description a consumer app shows under the name, while the long copy in description becomes productMarketingMessage, which the vocabulary defines as the consumer-friendly detailed description. Neither becomes additionalProductDescription, which in the GS1 vocabulary means variants — style, colour, fragrance — and would be the wrong word however plausible it reads. UnoPim stores both texts as HTML, so the adapter strips the markup before publishing, because the GS1 terms are plain strings: block elements become blank lines, list items become bullets, entities are resolved.

Images travel as referenced files. UnoPim reports a storage path; the adapter turns each one into a public URL and builds an entry of the form { "referencedFileType": "PRODUCT_IMAGE", "referencedFileURL": … }, and the list of them lands on referencedFile. That is a one-way rule — nothing writes images back into the PIM.

Translations are not flattened to one language. Every locale-scoped value the PIM already holds is published as a GS1 language map, and the key is the bare language: de_DE becomes de. Where two locales share a language — de_DE and de_AT — the connection's own locale wins, because that is the one the operator chose. This happens whether or not a locale is configured: the adapter collects a product's locale-scoped sections into one language map per attribute regardless, the translations are already maintained, and a passport that cannot answer in the reader's language is the first thing a consumer notices. What the locale decides there is only which language wins a collision, and whether locale-specific values are additionally taken over flat. Values UnoPim holds as common are a different case — they arrive as a single text with no language on them, and a localized rule over such a value needs source.options.locale. Without it the connector fails that one record loudly, naming the rule and the setting, rather than filing text under a blank language key; the run itself carries on with the other records.

Measurements are mapped without a unit in the rule, and this is the interesting part. weight becomes netWeight — deliberately not netContent, which means something else — and length, width and height become inPackageDepth, inPackageWidth and inPackageHeight. The unit comes from UnoPim's own measurement attribute, which carries a value such as {"unit":"GRAM","amount":"250.0000","symbol":"g"}, and is resolved against the platform's UN/CEFACT Recommendation 20 table — 721 units across 51 dimensions — first by symbol, then by code, producing {"value":250,"unitCode":"GRM"}. If a unit cannot be determined with certainty, the whole value is withheld and the reason logged: a number with a guessed unit is worse than no number.

The channel becomes the market, where the PHP package is installed. A channel is the axis UnoPim varies price, name and description along, and the package lets a merchant say once per channel which countries that channel sells into; the adapter reads that back over the package's own /openepcis/channel-markets route and maps it onto targetMarket. Without the package the answer is simply empty, which is the ordinary case and not a claim that the product sells nowhere. If the product itself names a market in ddm_target_market, the product wins and the channel value is held back. Beyond that the default mapping carries brand onto brand.brandName, color onto colourCode[].colourCodeValue, size onto sizeCode[].sizeCodeValue, ddm_gpc_code onto gpcCategoryCode and a catalogue price where the owner has enabled price sync.

Identifiers are pointedly absent from the mapping file. The GTIN, the GLN and the batch identifier are read into the sync key by the adapter, not mapped into the payload: they say which record this is, not what it holds. The complete field table — source field, target term, direction, vocabulary, value shape and, from the ontology itself, what the target term means — is generated from the mapping file rather than maintained by hand, so it cannot drift from the rules that actually run.

The panel in UnoPim

The optional PHP package hooks into UnoPim's own unopim.admin.catalog.product.edit.form.before event and renders a panel above the product form. It is not a screen of its own, because identity is not a separate task somebody remembers to do; it is a property of the product they are already editing.

With a GTIN present, the panel shows the key, the batch number when the record names one, the identity status (green when the key is structurally sound, red with the platform's own wording when there is something to fix), the licensee GS1 names for the key, the company prefix — flagged as own prefix where it is yours — the canonical GS1 Digital Link as a clickable link, and a QR code rendered from it. The verdict is fetched server-side while the page is rendered, not by JavaScript in the browser, and almost none of it is stored: a licensee name written into a product last month is a claim about last month.

Figure 3: The panel above the product form — the key, the identity the platform reports, the company prefix and the Digital Link, with the GS1 classification below it. The identity line is fetched while the page renders, so it reads as pending until the platform answers

Without a GTIN, the panel offers two equally weighted routes, and offering both is the whole point. Draw the next GTIN allocates the next article number from your own prefix through the platform; I have a GTIN takes a number the merchant already owns, has the platform check it, and refuses it in the platform's own words if it is wrong. Offering only the draw tells a merchant with a perfectly good number that his number is unwelcome; offering only the field lets the other one invent one. Drawing takes far longer than a verdict does: a verdict comes back in milliseconds, while the free-key pool is answered by sorting an entire base number, measured at around twelve seconds. So drawing has a timeout of its own, ninety seconds by default, rather than the short one the panel judges with. Both routes are plain form posts that reload the page — a <script> injected through UnoPim's render event is never executed by the browser, and the buttons did nothing at all until they stopped needing JavaScript.

A second widget replaces the raw GPC field with a word search, so that a category is chosen by typing coffee rather than by knowing an eight-digit brick code. The browser never holds a platform credential in either case: the browser calls the package, and the package calls the bridge.

What comes back into UnoPim

Very little comes back, and that is a decision rather than an omission.

The full key is written into dpp_gtin. The write-back is offered on every tick rather than only on a publication, which is what restores a key somebody cleared in the PIM. Composed from a prefix and an article reference, it exists nowhere a person can see it — the SKU is five digits and the key is thirteen — and a merchant asked for a product's GTIN has to be able to read it. It is derived, not maintained: written by us, and kept out of the product form so that nobody edits a value the next run will overwrite. dpp_gtin is UnoPim's own passport field for it, with role Gtin.

The second thing written back is ddm_action_required, yes or no. It is the one field a person acts on: is something wrong here that is mine to fix? It comes straight from the verdict's dataProblem flag, and the distinction that flag draws is the reason the whole connector exists. A verdict is more than a boolean — it carries syntax and check digit, the prefix boundary, the registry status, the licensee, a reason, a canonical Digital Link, and the provenance of each individual statement, including whether an answer came from the registry or from cache and when the registry answered, not when we last looked at it. Some of what that verdict can say is about the record; the rest is about us. unverified means the GS1 registry could not be reached, and the merchant's number may be perfectly good. Writing that into the product form would send people to retype correct data, which is precisely the failure this connector is built to prevent — so the identity status is not written back at all, and the panel asks for it live instead.

Figure 4: The composed key written back into dpp_gtin — derived rather than maintained, and kept out of the form so nobody edits it

Both values are patched only when the record does not already hold them, so a steady state costs no writes at all.

That leaves the question of which system owns a field, and the mapping answers it explicitly. Every rule declares a direction: to-catalog (the default — the source owns the field and is never written into), from-catalog (DDM owns it, it is written into the source and skipped when the outgoing document is built) or both. For both, the more recent edit wins, and it is decided by comparing the publish stamp on the catalog record rather than by timestamps: two systems with two clocks and two notions of modified do not compare, and a PIM that touches updated_at on every save would win every race regardless of whether anything changed.

Direction is declared rather than inferred for a reason worth repeating. It used to be derived from the shape of a rule, which quietly made every ordinary rule bidirectional; both systems then claimed the same field, and on each tick the publish overwrote the catalog edit the pull-back had just carried into the source. Nothing failed, and the correction simply disappeared. Rules that cannot run backwards at all — a target that fans out, or a rule with a fixed unit — are rejected when the file is read, naming the rule, because a wrong value written into somebody's PIM is worse than no value.

What arrives in DDM, and on the passport page

In DDM, each published product appears as a master data record under its GTIN, carrying exactly the terms the mapping produced: names and descriptions as language maps, images as referenced files, measurements as typed quantities, the market, the classification. A record that names a batch is published one level down, at the instance path /01/<gtin>/10/<lot>, under its model rather than over it. Serial numbers do not come from a PIM — UnoPim's passport has no role for them, and instances belong in the resolver and in EPCIS.

The last step joins the two systems: UnoPim's own published passport page becomes a target in the linkset. Three per-channel settings have to be switched on first — the passport feature itself (catalog.product_passport.settings.enabled), publication (general.publication.settings.enabled) and public access (general.publication.settings.public_access); without the first, templates install cleanly and are completely invisible, and without the third a publish is refused with 422. The template hangs off the attribute family, not off the product, which is the least obvious point of the whole setup: until a family has a template, the readiness check reports missing_template.

One setting stays deliberately empty: general.publication.settings.base_url. UnoPim is the product page, not the identity. Pointed at a resolver it bends more than the carrier, because the same setting also builds the address of the page itself, and the JSON-LD then names a URL the resolver does not have.

Publishing is a call per SKU (POST /api/v1/rest/passports/publish/{sku}), after which the page lives at /p/{uuid}/{locale}, one version per language. Published versions are immutable: a publish with unchanged content is refused, and a wrong version can only be withdrawn, not corrected. That is right — a passport is a promise.

Withdrawal is the one direction the route does not follow. UnoPim can withdraw a published version, and the connector picks up only entries that are currently published — but a link already registered is not taken away again: an empty list of links is a no-op, so a withdrawn passport stays in the linkset as gs1:pip and points at a page UnoPim answers with 200 and a tombstone rather than with 404. The same holds one level up. A product deleted in the PIM simply stops appearing in the delta poll, and a webhook for a SKU the API no longer knows is logged and the catalog document kept, on the grounds that deleting from the catalog is a deliberate act and not a side effect of a doorbell; a product merely deactivated in UnoPim keeps being published, because its status is carried into the payload but never read as a publication decision. Taking a record down is therefore a manual step today — in DDM, or against the catalog and linkset API — and not something the connector follows on its own.

Figure 5: The published passport page — registered on the anchor as gs1:pip, so a scan of the model's key reaches it

The connector reads the published passports, takes the entries that are actually published and registers the page on the record's anchor as gs1:pip — consumer information about the article, not the passport itself, which is the boundary drawn deliberately. Two details are worth carrying with you: the link type must be written as a full IRI (https://ref.gs1.org/voc/pip), because a curie is not a link type and the entry lands empty, and hreflang carries the bare language (de, en), because a regional subtag is accepted with 200 and the link then discarded.

Worth being plain about what the first sync does not yet give you: an EN 18223 passport. That document is not computed by the resolver but by a service of its own, the DPP API, which derives it from the GS1 master data at the moment it is read. The derivation has one mandatory field the connector does not currently fill, economicOperatorId, and it can only come from a manufacturer organisation on the product record — either as a party Digital Link or as a GLN the deriver can build one from. None of the shipped default mappings sets manufacturer; organisations are synchronised, but as records of their own, and the product does not point at one. Until it does, the derivation refuses the record rather than returning a passport with a gap. Two things have to be added by hand, then: a mapping rule of your own onto manufacturer — the term is in the connector vocabulary, with gs1:Organization as its range — and a DPP API configured to read its master data from the resolver, which is not its built-in default.

The result is what a scan finally reaches. A code on the product resolves at the resolver, the linkset offers the master data our catalog holds and the product page UnoPim publishes, and a serial number nobody ever recorded still resolves, inherits the model's links and lands on the same page — which is the difference between a system that describes articles and one that tracks copies.

dry-run is on by default, and that is not a formality: a connection publishes nothing until you write dry-run=false for it. Behind the catalog sit real registrars, GS1 production among them, and an allocated key cannot be taken back.

Last updated: