We run a small French marketplace for used motorcycle parts. Sellers are scrapyards, and scrapyards have every stock system imaginable: PrestaShop, Odoo, Dolibarr, an Excel file, a nephew with a Python script. So our seller API needed clients in whatever they already use.
Over one week we published the same client on PyPI, npm, JSR, RubyGems, NuGet, Docker Hub and the Snap Store. The code was the easy part. Here is what each registry actually demanded, and what we got wrong.
The API is deliberately tiny
Seven endpoints, one Bearer token, one webhook.
GET /moi account, status, commission
GET /produits your parts
PUT /produits create or update, 200 per call
PATCH /produits/{ref}/stock {"stock": 3}
DELETE /produits/{ref} withdraw (stock 0, never deleted)
GET /commandes?depuis=YYYY-MM-DD paid orders with shipping address
POST /commandes/{id}/expedier carrier + tracking number
Two design decisions shaped every client:
The seller's own SKU is the identifier. PUT /produits with a known reference updates; an unknown one creates. Clients never need to store our IDs.
Batches of 200, and a refused row does not fail the batch. The response carries produits (accepted) and erreurs (refused, with a reason). A thousand rows is five calls, and one bad price doesn't block 999 good parts.
The webhook is signed: X-Pmo-Signature: sha256=<HMAC-SHA256 of the raw body, key = your token>. Every client ships a verifier, and every verifier compares in constant time.
What each registry wanted
PyPI — publish from CI, no token anywhere
PyPI's trusted publishing is the nicest flow of the seven. You register owner/repo, the workflow filename and an environment name once; after that a tagged release publishes with no secret stored anywhere.
Two traps:
-
The workflow filename is part of the identity. We registered
publier-pypi.yml, then renamed the file. Every publish failed withinvalid-publisheruntil the names matched again. - The form for a project that doesn't exist yet is the pending publisher form, on your account page — not on the project page, which doesn't exist.
We also learned that pip on macOS 26 is currently broken for some Python builds (platform.mac_ver() returns an empty string and truststore crashes). Build in CI, not on the laptop.
npm — 2FA is mandatory now
npm publish returned 403 until the account had two-factor auth. A passkey works: npm publish then opens a browser to confirm instead of asking for --otp. No token juggling.
We published two packages: the client, and an n8n community node. n8n lists any npm package with the n8n-community-node-package keyword — the cheapest "integration marketplace" listing we found, with no user-count threshold unlike Zapier.
JSR — write TypeScript, or it won't document you
JSR's "slow types" check refuses to generate docs for an untyped JavaScript API. So the JSR package is a separate TypeScript port, with one useful constraint: no Node built-ins. We replaced node:crypto with crypto.subtle, which made the same file run on Deno, Node 18+, Bun, Cloudflare Workers and in a browser.
async signatureValide(corps: string | Uint8Array, entete: string | null): Promise<boolean> {
if (!entete) return false;
const octets = typeof corps === "string" ? new TextEncoder().encode(corps) : corps;
const cle = await crypto.subtle.importKey("raw", new TextEncoder().encode(this.#jeton),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const empreinte = new Uint8Array(await crypto.subtle.sign("HMAC", cle, octets));
const attendu = "sha256=" + [...empreinte].map(o => o.toString(16).padStart(2, "0")).join("");
if (attendu.length !== entete.length) return false;
let difference = 0;
for (let i = 0; i < attendu.length; i++) difference |= attendu.charCodeAt(i) ^ entete.charCodeAt(i);
return difference === 0;
}
jsr publish refuses to run from a dirty git tree, even when the dirt is in a parent directory unrelated to the package. --allow-dirty is fine when you know why.
RubyGems — zero dependencies, and one missing function
net/http, json, openssl: the gem has no runtime dependency at all. One surprise: OpenSSL.fixed_length_secure_compare only exists from Ruby 2.7, and OpenSSL.secure_compare isn't everywhere either. Four lines of our own constant-time compare removed the version question.
NuGet — the URI class lies to you
Uri.EscapeDataString("REF /1") correctly gives REF%20%2F1. Then new Uri(...) displays the path with the space decoded again, and our test asserted on ToString(). Panic, then a socket listener to see the real request line:
PATCH /api/v1/produits/REF%20%2F1/stock HTTP/1.1
The wire was right all along; only the string representation lied. Test what leaves the machine, not what a getter returns.
Docker Hub — the only one whose links are followed
We checked the HTML of package pages on each registry. PyPI, RubyGems, WordPress.org, Drupal.org, Packagist and GitHub all mark outbound links rel="nofollow". Docker Hub renders README links with rel="noopener noreferrer" — no nofollow — and serves the README server-side. If you care about that sort of thing, it's the one registry where the description page is worth writing properly.
The image itself is trivial: a two-stage build that installs the PyPI package into a venv, then copies the venv into python:3.12-alpine. 101 MB instead of 225 with -slim, because our only dependency is requests, which needs no compiler — the usual reason to avoid Alpine doesn't apply. Runs as uid 10001, token via environment variable, never on the command line where docker ps would show it.
Snap Store — you cannot build on a Mac
snapcraft installs on macOS but cannot produce a snap there. The path that works: push the snapcraft.yaml to a public git repo (GitLab is fine), import it into Launchpad, create a snap recipe, tick amd64 + arm64, tick "automatically upload to store". Launchpad builds both architectures in about thirty minutes and pushes to the stable channel.
Snapcraft.io also renders followed links, and the store listing lets you attach a verified website. We measured seven followed links from our listing page.
The one thing every client shares
Whatever the language, envoyer() takes any iterable, slices it by 200, and returns accepted and refused rows separately. And every verifier works on the raw request body: re-serializing JSON changes bytes, and the signature with them. Both rules were written once in the API and copied into all seven READMEs, because the first support question would otherwise have been "why does my signature not match".
Where they are
- Python:
pip install piecemotooccasion— includes thepmoCLI - JavaScript:
npm i piecemotooccasion - TypeScript / Deno:
jsr add @piecemotooccasion/api - Ruby:
gem install piecemotooccasion - n8n:
n8n-nodes-piecemotooccasion - Docker:
docker run tonydevweb/pmo envoyer catalogue.csv - Linux:
snap install piecemotooccasion
API reference: https://piecemotooccasion.eu/extensions/api
Top comments (0)