Skip to content

Guide · Connector API v1

Build a connector, step by step

From an empty project to a connector that passes the conformance check. Each step has its goal, a diagram, the minimal code, how to know it works and links to the specification.

Step 0 of 11

What you'll build

Know the pieces of a connector, which are required and which are optional, before writing any code.

A connector lives inside a shop platform (a module, an app, a plugin, a headless backend…) and connects it to Spaidoo. It declares the capabilities it implements, and Spaidoo acts on that declaration, never on the name of the platform (§1).

The whole picture: your connector between the shop, the shopper's browser and SpaidooShopper's browser. Shop platform + your connector. Spaidoo. Shop page with the widget. Push notifications from the shop's domain. Storefront pages storefront.saveAnchors. Identity route identity. Push worker push. Product feed catalog.feed. Customers route customers. Verify route domainVerification. Calls to Spaidoo handshake · catalog.changes customerErase · wishlistImport. Widget loader scriptUrl. Spaidoo sync. Spaidoo API /connector/v1. the page loads the widget. script, SpaidooConfig, save anchors. who is browsing?. registers it. reads it daily. signed call. reads the code. Bearer <apiKey>Shopper's browserShop platform + your connectorSpaidooShop pagewith the widgetPush notificationsfrom the shop's domainStorefront pagesstorefront.saveAnchorsIdentity routeidentityPush workerpushProduct feedcatalog.feedCustomers routecustomersVerify routedomainVerificationCalls to Spaidoohandshake · catalog.changescustomerErase · wishlistImportWidget loaderscriptUrlSpaidoo syncSpaidoo API/connector/v1the page loads the widgetscript, SpaidooConfig,save anchorswho is browsing?registers itreads it dailysigned callreads the codeBearer <apiKey>
Arrows point from who asks to who answers. The capabilities sit where they live: routes the shop serves, what it prints on its pages, and the calls the connector makes to Spaidoo.
CapabilityWhat it gives the shopNeeded?
catalog.feedSpaidoo knows the shop's products: price, stock, images.Required
catalog.changesPrice and stock changes reach Spaidoo in seconds instead of on the next daily read.Optional
identityA signed-in customer keeps their saved products on every device, without a Spaidoo account.Optional
customersSpaidoo can ask for the email and marketing consent of an identified customer when the merchant needs it.Optional, with identity
customerEraseDeleting a customer in the shop deletes their Spaidoo data for that shop.Optional
wishlistImportLists from a previous wishlist extension are brought into Spaidoo.Optional, with identity
pushWeb push notifications are served from the shop's own domain.Optional
domainVerificationThe shop proves it owns its domain without DNS changes.Optional
storefront.saveAnchorsYour theme prints the save buttons; the widget draws them and handles the click.Optional

How long it takes

It depends on your platform, but the work splits cleanly. The minimum is steps 2 to 5 (register, pairing, handshake, product feed) plus the script of step 8: with that, the widget runs on the shop with its catalogue. Every other capability is an independent piece: build it, declare it in the handshake and check it on its own.

Three things help along the way: the specification, which is the contract and always wins; the online conformance check; and, for Spaidoo partners, the assistant.

How you know it works

  • You can name the capabilities you will declare, and you know catalog.feed is the only required one.

Step 1 of 11

Before you start

Have a test shop that Spaidoo can reach, and the accounts you need.

  • A test installation of your platform, reachable from the internet over https. Spaidoo calls the shop (it reads the feed, calls the signed routes, fetches the verify route) and so does the conformance check: localhost is not enough (§8).
  • A staging domain, or a tunnel to a shop running on your machine: ngrok or Cloudflare Tunnel give it a public https host.
  • A Spaidoo partner account with access to the developers area. You register your connector there (step 2), and it opens the assistant (step 11).
  • A Spaidoo account for the test shop. You create it when you pair the shop (step 3), as a merchant would.
A local shop reachable from the internet through a tunnelYour shop, locally http://localhost:8080. Tunnel ngrok · Cloudflare Tunnel. Public host https://<your-host>. Spaidoo API · sync · check. Configure the shop with the public host as its domain: every declared URL must be https on it.. https. forwardsYour shop, locallyhttp://localhost:8080Tunnelngrok · Cloudflare TunnelPublic hosthttps://<your-host>SpaidooAPI · sync · checkConfigure the shop with the public host as its domain: every declared URL must be https on it.httpsforwards
Spaidoo and the conformance check call the shop's public https host; the tunnel forwards those requests to the shop on your machine.
Shell
# Either one, pointing at the shop on your machine (port 8080 here)
ngrok http 8080
cloudflared tunnel --url http://localhost:8080

Configure the shop to use the tunnel's host as its own domain. Every storefront URL the connector declares must be https on one of the shop's domains (§4.1), and so must the callback of a connector installed in each shop (§3.1).

Keep the two secrets in mind from the start: the API key and the identity secret are secrets of the shop, stored server-side and never printed in pages (§2.1).

How you know it works

  • The shop opens through its public https URL from another network (your phone on mobile data, say).

Step 2 of 11

Register your connector

Your connector has a client_id, the one every shop connects with.

Shops connect with OAuth 2 and PKCE, as a registered app (§3). Register your connector once, in My connectors, and keep its client_id: it is public, it goes in the URL that starts the connection.

TypeWhenCallback
Installed in each shop (shop)Your connector runs on each shop's own server: a module, a plugin.An https URL on the domain of the shop being connected. Nothing to register.
Hosted (fixed)Your connector runs on your servers or the platform's, and the merchant's admin is not on the shop's domain.The exact URLs you register.
  • Server domains: where your server routes (urls.customers) live, if not on the shop's domain. Storefront routes always go on the shop's domains (§4.1).
  • The platform of the app (my-platform) is free text, informative only.

How you know it works

Step 3 of 11

Pair the shop

The merchant connects the shop from your admin and your server receives its API key.

Pairing is an OAuth 2 flow with PKCE, in a popup: your admin opens Spaidoo in a popup, Spaidoo sends the popup back to your callback with a one-time code, and your server trades it for the API key. The popup only tells your admin page it is done, and closes. The key never goes through the browser (§3.2).

Pairing: OAuth 2 with PKCE, the key never in the browser1. Shop server: state + code_verifier, kept server-side 2. Shop server → Spaidoo admin.spaidoo.com/connect: popup to /connect ?client_id&redirect_uri&state &code_challenge&domain 3. Spaidoo admin.spaidoo.com/connect: The merchant signs up or logs in and accepts the terms 4. Spaidoo admin.spaidoo.com/connect → Shop server: redirect_uri?code&state 5. Shop server → Spaidoo API: POST /pairing/token client_id, code, code_verifier 6. Spaidoo API → Shop server: { apiKey, shopId } 7. Shop server: Store the API key, server-side 8. Shop server → Spaidoo API: POST /handshake (next step)Shop serverSpaidooadmin.spaidoo.com/connectSpaidoo API1state + code_verifier,kept server-sidepopup to /connect?client_id&redirect_uri&state&code_challenge&domain23The merchant signs up orlogs in and accepts the termsredirect_uri?code&state4POST /pairing/tokenclient_id, code, code_verifier5{ apiKey, shopId }67Store the API key,server-sidePOST /handshake(next step)8
The code is good for one exchange, for 60 seconds, and only with the code_verifier that started the flow.
  1. Your server generates a random state and a code_verifier, keeps both (tied to the merchant's session), and computes code_challenge = base64url(SHA-256(code_verifier)).
  2. Your admin page opens a popup (window.open, on the merchant's click) at https://admin.spaidoo.com/connect with client_id, redirect_uri, state, code_challenge, code_challenge_method=S256, the shop's domain and, optionally, name, email, mode and verify (your urls.verify, only with domainVerification).
  3. The merchant signs up or logs in to Spaidoo and accepts the terms.
  4. Spaidoo sends the popup to your redirect_uri with code and state (or error=access_denied if the merchant cancels). Check the state.
  5. Your server calls POST /pairing/token with client_id, code, code_verifier and the same redirect_uri, within 60 seconds, and gets { apiKey, shopId }.
  6. Store the key and call POST /handshake (next step). The callback page tells your admin page with postMessage (your own origin only, never the key) and closes the popup.
JavaScript
import { createHash, randomBytes } from "node:crypto";

const CLIENT_ID = "sc_…"; // from My connectors
const REDIRECT_URI = "https://shop.example.com/admin/spaidoo/callback";

// In your admin page: the merchant's click opens the popup.
//   const popup = window.open("/admin/spaidoo/connect", "spaidoo", "width=460,height=780");
//   window.addEventListener("message", (e) => {
//     if (e.origin !== location.origin || e.source !== popup) return;
//     if (e.data?.type === "spaidoo-connected" && e.data.connected) location.reload();
//   });

// 1. The popup starts here and goes on to Spaidoo.
app.get("/admin/spaidoo/connect", (req, res) => {
  const state = randomBytes(16).toString("hex");
  const verifier = randomBytes(48).toString("base64url");
  req.session.spaidoo = { state, verifier };
  const challenge = createHash("sha256").update(verifier).digest("base64url");
  res.redirect(
    "https://admin.spaidoo.com/connect?" +
      new URLSearchParams({
        client_id: CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        state,
        code_challenge: challenge,
        code_challenge_method: "S256",
        domain: "shop.example.com",
        name: "My shop",
        email: "merchant@example.com",
        verify: "https://shop.example.com/spaidoo/verify", // only with domainVerification
      }),
  );
});

// 2. The popup comes back from Spaidoo.
app.get("/admin/spaidoo/callback", async (req, res) => {
  const pending = req.session.spaidoo;
  if (!pending || req.query.state !== pending.state) return res.status(400).send("Start again");

  // A shop of Spaidoo's directory proves its domain first (§3.3).
  if (req.query.verification_code) {
    store.set("verificationCode", req.query.verification_code); // served at urls.verify
    return res.redirect(req.query.resume); // back to Spaidoo, same state
  }

  delete req.session.spaidoo;
  let connected = false;
  if (!req.query.error) {
    const answer = await fetch("https://api-plugin.spaidoo.com/connector/v1/pairing/token", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        client_id: CLIENT_ID,
        code: req.query.code,
        code_verifier: pending.verifier,
        redirect_uri: REDIRECT_URI,
      }),
    }).then((r) => r.json());
    store.set("apiKey", answer.apiKey); // server-side, never in a page
    await handshake(); // next step
    connected = true;
  }

  // 3. Tell the admin page (same origin, never the key) and close the popup.
  res.send(`<script>
    window.opener?.postMessage({ type: "spaidoo-connected", connected: ${connected} }, location.origin);
    window.close();
  </script>`);
});

A shop already in Spaidoo's directory

If the domain already belongs to a shop in Spaidoo's directory, it is only handed over once the domain is proven. When you pass verify, Spaidoo first comes back to your callback with verification_code and resume: store the code so urls.verify serves it (step 9), keep the code_verifier, and send the popup to resume. The code comes later, with the same state (§3.3).

What to store, and where

  • The API key, server-side (your platform's configuration or database), never in a page. It goes as Authorization: Bearer <apiKey> on every call to https://api-plugin.spaidoo.com/connector/v1 (§2, §2.1).
  • A 401 or 403 from Spaidoo means the key is no longer valid: show the shop as disconnected. A 5xx or a network failure only means "try later": the shop must keep working when Spaidoo does not answer (§2).

How you know it works

  • Your server holds a key and GET /status with it answers 200 with the shop's store (§4.3).

Step 4 of 11

The handshake

Tell Spaidoo what your connector can do and where, and keep what it accepts.

POST /handshake declares your capabilities, the URLs of your routes and the storefront settings. It is idempotent (§4.1).

The handshake: declare, keep what is accepted, acknowledge the secret1. Shop server → Spaidoo API: POST /handshake capabilities, urls, storefront 2. Spaidoo API → Shop server: 200 shopId, accepted capabilities, identitySecret (when due) 3. Shop server: Store the shop id, the accepted capabilities and the secret 4. Shop server → Spaidoo API: POST /identity-secret/ack { fingerprint } 5. Spaidoo API → Shop server: { acknowledged: true } 6. Shop server → Spaidoo API: GET /status 7. Spaidoo API → Shop server: connector: your declaration, no identitySecret once ackedShop serverSpaidoo APIPOST /handshakecapabilities, urls, storefront1200 shopId, accepted capabilities,identitySecret (when due)23Store the shop id, theaccepted capabilitiesand the secretPOST /identity-secret/ack{ fingerprint }4{ acknowledged: true }5GET /status6connector: your declaration,no identitySecret once acked7
Spaidoo answers with the capabilities it accepted and, when due, the identity secret, which it keeps returning until you acknowledge it.

Start small. This body declares only the required capability; switch the others on as you build them:

JSON
{
  "contractVersion": 1,
  "platform": { "id": "my-platform", "version": "3.2.0" },
  "connector": { "version": "1.0.0" },
  "storeUrl": "https://shop.example.com",
  "capabilities": {
    "catalog.feed": true,
    "catalog.changes": false,
    "identity": false,
    "customers": false,
    "customerErase": false,
    "wishlistImport": false,
    "push": false,
    "domainVerification": false,
    "storefront.saveAnchors": false
  },
  "urls": {},
  "storefront": { "excludePaths": ["^/checkout", "^/cart"] },
  "identitySecretFingerprint": null
}
  • Declare only what you implement. A URL is required only for a declared capability that needs it. Every URL must be https: storefront ones on one of the shop's domains, server ones (customers) there or on your app's server domains. storefront.domains adds other domains the storefront is served on (§4.1).
  • login must contain the literal, unencoded marker {back} (step 6).
  • storefront.excludePaths: case-insensitive regular expressions over the path + query where the widget must never run (checkout, payment).
  • identitySecretFingerprint: the SHA-256 (hex) of the identity secret you hold, or null when you hold none. That is how a reinstalled connector gets a new secret on its own.

Spaidoo answers with the shop id, the capabilities it accepted and, when you must store a new one, the identity secret:

JSON
{
  "shopId": "7f3c…",
  "contractVersion": 1,
  "capabilities": { "catalog.feed": true },
  "identitySecret": "b64url…"
}
JavaScript
import { createHash } from "node:crypto";

const API = "https://api-plugin.spaidoo.com/connector/v1";

async function spaidoo(method, path, body) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${store.get("apiKey")}`, "Content-Type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  if (res.status === 401 || res.status === 403) store.set("connected", false); // show "disconnected"
  if (!res.ok) throw new Error(`Spaidoo ${res.status}`); // 5xx, network: try later, never break the shop
  return res.status === 204 ? null : res.json();
}

const answer = await spaidoo("POST", "/handshake", handshakeBody());
store.set("shopId", answer.shopId);
// Act on what Spaidoo accepted, not on what you declared.
store.set("capabilities", answer.capabilities);

if (answer.identitySecret) {
  store.set("identitySecret", answer.identitySecret); // server-side only, never printed
  const fingerprint = createHash("sha256").update(answer.identitySecret).digest("hex");
  await spaidoo("POST", "/identity-secret/ack", { fingerprint });
}
  • Behave according to the accepted list. A capability is refused (left out, not an error) when Spaidoo has it switched off; customers and wishlistImport are refused without identity.
  • Acknowledge every secret you receive, after storing it: POST /identity-secret/ack with its fingerprint. Until then Spaidoo keeps returning it in /handshake and /status (§4.2).
  • After a rotation, Spaidoo keeps accepting the previous secret until the new one is acknowledged, and for 10 minutes after: a lost response does not break the shop.

When to handshake again

  • After pairing.
  • After every update of your connector.
  • Whenever a URL or a capability changes.

Errors to handle: 400 CONNECTOR_CATALOG_REQUIRED (no catalog.feed), 400 CONNECTOR_URL_INVALID with the field at fault, and 400 CONNECTOR_EXCLUDE_PATH_INVALID.

How you know it works

  • POST /handshake answers 200 with your capabilities in capabilities.
  • GET /status shows your declaration in connector, and no longer carries identitySecret once you have acknowledged it.
  • The conformance check (step 10) checks the handshake.

Step 5 of 11

The catalogue

Spaidoo knows the shop's products: from a feed it reads every day, then from the changes you push.

The feed: catalog.feed (required)

A Google Merchant product feed (RSS 2.0 or Atom) with one item per variant. Serve one per language and register the complete list: a language missing from it is removed (§4.5).

HTTP
PUT /connector/v1/catalog/feeds
Authorization: Bearer <apiKey>
Content-Type: application/json

{ "feeds": [ { "language": "en", "url": "https://shop.example.com/spaidoo/feed?lang=en" } ] }
XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">
  <channel>
    <title>My shop</title>
    <link>https://shop.example.com</link>
    <description>My shop's products</description>
    <item>
      <g:id>123-1</g:id>
      <g:item_group_id>123</g:item_group_id>
      <g:title>Blue shirt</g:title>
      <g:link>https://shop.example.com/p/123</g:link>
      <g:price>24.90 EUR</g:price>
      <g:sale_price>19.90 EUR</g:sale_price>
      <g:availability>in_stock</g:availability>
      <g:image_link>https://shop.example.com/img/123.jpg</g:image_link>
    </item>
  </channel>
</rss>
  • g:item_group_id is the product id for Spaidoo (one row per product); without it, g:id is. That product id is used everywhere else: data-sp-id, catalog.changes, wishlist import.
  • Required: g:id, g:title, g:link (the canonical product URL), g:price in the shop's base currency and g:availability (in_stock · out_of_stock · preorder · backorder).
  • Prices in other currencies go in spaidoo:price currency="USD" (namespace https://spaidoo.com/ns/feed/1).
The feed: registered once, read about once a day1. Shop server → Spaidoo API: PUT /catalog/feeds one feed URL per language 2. Spaidoo sync → Shop server: GET <feed url> about once a day 3. Shop server → Spaidoo sync: 202 + Retry-After still building 4. Spaidoo sync: Comes back after Retry-After; keeps the catalogue 5. Spaidoo sync → Shop server: GET <feed url> 6. Shop server → Spaidoo sync: 200 RSS / Atom, one item per variantShop serverSpaidoo APISpaidoo syncPUT /catalog/feedsone feed URL per language1GET <feed url>about once a day2202 + Retry-Afterstill building34Comes back afterRetry-After; keepsthe catalogueGET <feed url>5200 RSS / Atom,one item per variant6
While a large feed is being built, a 202 with Retry-After tells Spaidoo to come back later instead of reading an empty catalogue.

A large feed takes time to build. While it is being generated, answer 202 (or 503/429) with Retry-After: Spaidoo comes back then and does not take it as an empty catalogue. Any other error keeps the previous catalogue.

HTTP
HTTP/1.1 202 Accepted
Retry-After: 120

POST /catalog/feeds/read asks for a read now (a button in your panel, say). Asked again within 3 minutes, it answers 429 with Retry-After.

Catalogue changes: catalog.changes

When a price or the stock changes in the shop, tell Spaidoo right away: price-drop alerts leave within minutes instead of on the next daily read, which stays as the reconciliation (§4.6).

Catalogue changes: noted during the request, sent at its end1. Shop back office → Shop server: The merchant saves prices or stock 2. Shop server: Note the product ids, answer the merchant 3. Shop server → Spaidoo API: at the end of the request POST /catalog/changes 4. Spaidoo API → Shop server: 202 { accepted, unknown } 5. Spaidoo API: Unknown ids: schedules a feed readShop back officeShop serverSpaidoo APIThe merchant savesprices or stock12Note the product ids,answer the merchantat the end of the requestPOST /catalog/changes3202 { accepted, unknown }45Unknown ids:schedules a feed read
The merchant never waits on Spaidoo. The daily feed read stays as the reconciliation.
JSON
{
  "changes": [
    {
      "type": "product.updated",
      "id": "123",
      "at": 1759219200,
      "price": { "amount": "24.90", "currency": "EUR" },
      "salePrice": { "amount": "19.90", "currency": "EUR" },
      "availability": "in_stock"
    },
    { "type": "product.deleted", "id": "456", "at": 1759219230 }
  ]
}
  • product.updated carries only price, salePrice (null = no sale), prices in other currencies, availability and the variants that changed, by sku. Every field is optional; a present one replaces the stored value.
  • product.deleted: the product is no longer sold (deleted or disabled).
  • at is when the change happened. Spaidoo ignores a change older than the one it already applied, so retries and out-of-order delivery are safe.
  • Up to 100 changes per request and 60 requests a minute. Coalesce bursts: a bulk edit of 2 000 products is 20 requests, not 2 000.
  • New products, and changes to title, link, images or categories, arrive with the next feed read.

Note the product ids while the merchant's request runs and send them at its end, once the response has gone out, so the merchant never waits on Spaidoo:

JavaScript
// While the merchant's request runs: only note which products changed.
const pending = new Map(); // product id → unix time of the change

onProductSaved((product) => pending.set(String(product.id), Math.floor(Date.now() / 1000)));

// Once the response has gone out: up to 100 changes per call.
onRequestEnd(async () => {
  if (!store.get("capabilities")["catalog.changes"]) return;
  const changes = [...pending].map(([id, at]) => changeOf(id, at)); // product.updated or product.deleted
  for (let i = 0; i < changes.length; i += 100) {
    await spaidoo("POST", "/catalog/changes", { changes: changes.slice(i, i + 100) });
  }
});

Spaidoo answers 202 { accepted, unknown }: unknown lists ids it does not have yet, and it schedules a feed read. On failure, retry with backoff for up to 1 hour, then drop: the daily feed read corrects everything.

How you know it works

  • GET /status lists your feeds in store.feeds, with lastReadAt, products and status.
  • The conformance check reads your feeds and their items.
  • POST /catalog/changes answers 202 with an empty unknown for products Spaidoo knows. It is a call to Spaidoo: the check lists it with how to check it by hand (§8).

Step 6 of 11

Customer identity

A customer signed in to the shop keeps their saved products on every device, without a Spaidoo account.

While the shop has a connector with identity, the shop's session rules: Spaidoo never asks the customer to log in to the shop on its behalf (§5).

Identity: the shop says who is browsing, signed with the identity secret1. Shopper's browser → Shop server: GET <urls.identity>?anon=<uuid> same origin, the shop's cookies 2. Shop server → Shopper's browser: 200 { assertion } signed in 200 { anonymous: true } nobody 3. Shopper's browser → Spaidoo API: The widget hands the assertion to Spaidoo 4. Spaidoo API: Checks the signature, exp and jti 5. Shopper's browser: Nobody signed in, and the customer wants to keep the list 6. Shopper's browser → Shop server: GET <urls.login> {back} = the current page 7. Shop server → Shopper's browser: After login: redirect back to that pageShopper's browserShop serverSpaidoo APIGET <urls.identity>?anon=<uuid>same origin, the shop's cookies1200 { assertion } signed in200 { anonymous: true } nobody2The widget hands theassertion to Spaidoo34Checks the signature,exp and jti5Nobody signed in, andthe customer wants tokeep the listGET <urls.login>{back} = the current page6After login: redirectback to that page7
The identity route is called from the storefront with the shop's own cookies. When nobody is signed in and the customer wants to keep their list, the widget sends them to the shop's login.

The identity route

The widget calls GET <urls.identity>?anon=<uuid> from the storefront: same origin, with the shop's cookies. Answer 200 { "assertion": "…" } when a customer is signed in, 200 { "anonymous": true } when nobody is (guests count as nobody), and 410 when the connector is disconnected (§5.1).

Send Cache-Control: no-store, private and Vary: Cookie. The route must never be cached by a page cache or a CDN.

The assertion

A JSON payload and its HMAC-SHA256 with the identity secret (as UTF-8 text), both base64url without padding, joined by a dot (§5.2):

JavaScript
import { createHmac, randomBytes } from "node:crypto";

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const b64url = (text) => Buffer.from(text).toString("base64url"); // no padding

function assertion(secret, shopId, customerId, anon) {
  const iat = Math.floor(Date.now() / 1000);
  const payload = b64url(JSON.stringify({
    v: 1,
    shopId,
    customerId: String(customerId),
    email: "", // always empty: Spaidoo asks the customers route when it needs one
    anonId: UUID.test(anon ?? "") ? anon : null,
    iat,
    exp: iat + 60,
    jti: randomBytes(16).toString("hex"),
  }));
  const signature = createHmac("sha256", secret).update(payload).digest("base64url");
  return `${payload}.${signature}`;
}

// GET <urls.identity>?anon=<uuid> — same origin, with the shop's cookies.
app.get("/spaidoo/identity", (req, res) => {
  res.set({ "Cache-Control": "no-store, private", Vary: "Cookie" });
  if (!store.get("connected")) return res.sendStatus(410);
  const customer = signedInCustomer(req); // a guest counts as nobody
  if (!customer) return res.json({ anonymous: true });
  res.json({ assertion: assertion(store.get("identitySecret"), store.get("shopId"), customer.id, req.query.anon) });
});
  • exp - iat is 60 s at most; Spaidoo tolerates 30 s of clock skew. jti is single use.
  • anonId echoes ?anon= when it is a UUID, else null.
  • email is always "": Spaidoo does not keep the shop customers' emails (step 7).

The session hint (optional, recommended)

So that the widget notices a login or a logout without asking on every page, print SpaidooConfig.shopSession (step 8): "" when nobody is signed in, else the first 16 hex characters of HMAC-SHA256(identity secret, "session:" + customerId). It is a hint, not a proof (§5.3).

JavaScript
const shopSession = customer
  ? createHmac("sha256", secret).update("session:" + customer.id).digest("hex").slice(0, 16)
  : "";

The login URL

Declare urls.login with the literal {back} marker. When a customer who is not signed in wants to keep their list, the widget sends them there with {back} replaced by the URL-encoded page they were on. After login, the shop must redirect back to it, same domain only (§5.4).

How you know it works

  • curl the identity route without cookies: {"anonymous": true} and the two cache headers.
  • With the Cookie header of a signed-in test customer: an assertion whose payload (base64url-decode the part before the dot) carries your shopId, the customerId and "email": "".
  • The conformance check tests the route and its assertions when you give it that Cookie header.

Step 7 of 11

The customers route

Spaidoo can ask the shop for the email and marketing consent of identified customers, and nobody else can.

Spaidoo does not store the shop customers' emails. When the merchant needs them (an export, a campaign audience, a sync to their email tool), Spaidoo asks your urls.customers, uses the answer at that moment and does not store it (§6.2).

The customers route: Spaidoo asks, signed, when the merchant needs emails1. Spaidoo API: The merchant needs emails: export, audience, email sync 2. Spaidoo API → Shop server: POST <urls.customers> X-Spaidoo-Timestamp · Nonce · Signature { customerIds: [...] } 3. Shop server: Timestamp, signature (raw body), nonce 4. Shop server → Spaidoo API: 200 { customers: [...] } within 5 s 5. Spaidoo API: Uses them now, does not store themSpaidoo APIShop server1The merchant needs emails:export, audience, email syncPOST <urls.customers>X-Spaidoo-Timestamp · Nonce · Signature{ customerIds: [...] }23Timestamp, signature(raw body), nonce200 { customers: [...] }within 5 s45Uses them now,does not store them
Spaidoo uses the answer at that moment and does not store the emails.

Verify every call

Every call Spaidoo makes to a connector URL (except the public verify and feed) carries X-Spaidoo-Timestamp, X-Spaidoo-Nonce and X-Spaidoo-Signature: the base64url HMAC-SHA256 of timestamp + "." + nonce + "." + rawBody with the identity secret (§6.1).

Verifying a signed call from SpaidooA signed call arrives. Timestamp within 300 s of our clock?. Signature of the raw body equal, in constant time?. Nonce not seen in the last 600 s?. Answer { customers }. 401. yes. no. yes. no. yes. noA signed callarrivesTimestamp within300 s of ourclock?Signature of theraw body equal, inconstant time?Nonce not seenin the last600 s?Answer{ customers }401yesnoyesnoyesno
Any failed check answers 401 before the body is even read as JSON.
JavaScript
import { createHmac, timingSafeEqual } from "node:crypto";

// The RAW body: the signature covers the bytes Spaidoo sent, not re-serialised JSON.
app.post("/spaidoo/customers", express.raw({ type: "*/*" }), async (req, res) => {
  const secret = store.get("identitySecret");
  if (!store.get("connected") || !secret) return res.sendStatus(410);

  const timestamp = req.get("X-Spaidoo-Timestamp") ?? "";
  const nonce = req.get("X-Spaidoo-Nonce") ?? "";
  const signature = Buffer.from(req.get("X-Spaidoo-Signature") ?? "");

  // 1. A timestamp more than 300 s away from our clock.
  const now = Math.floor(Date.now() / 1000);
  if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > 300) return res.sendStatus(401);

  // 2. The signature, compared in constant time.
  const expected = Buffer.from(
    createHmac("sha256", secret).update(`${timestamp}.${nonce}.`).update(req.body).digest("base64url"),
  );
  if (signature.length !== expected.length || !timingSafeEqual(signature, expected)) return res.sendStatus(401);

  // 3. A nonce seen in the last 600 s is a replay.
  if (!(await nonces.addIfNew(nonce, 600))) return res.sendStatus(401);

  const { customerIds } = JSON.parse(req.body.toString("utf8"));
  const customers = await findAccounts(customerIds); // no guests, no deleted ones
  res.json({
    customers: customers.map((c) => ({
      customerId: String(c.id),
      email: c.email,
      marketingConsent: c.newsletter ?? null, // null: the platform does not know
    })),
  });
});
  • Sign over the raw body, exactly as received: parsing and re-serialising the JSON changes the bytes.
  • Compare the signatures in constant time.
  • Remember nonces for 600 s and reject a repeated one, so a captured request cannot be replayed.

The answer

JSON
{ "customers": [ { "customerId": "123", "email": "ana@example.com", "marketingConsent": true } ] }
  • Up to 500 ids per call. Leave out customers that no longer exist, guests and deleted ones.
  • marketingConsent: true the customer accepted the shop's marketing emails, false they did not, null the platform does not know.
  • Answer within 5 s; 410 if the connector is disconnected.

How you know it works

  • An unsigned POST to the route answers 401.
  • Given a customer id of the test shop, the conformance check tests the nonce, the signature, the raw body, the age of the timestamp and the limits.

Step 8 of 11

The storefront

Print the widget on the shop's pages, never on the checkout, and serve the push worker.

The storefront: what the shop prints and serves to the shopper's browser1. Shopper's browser → Shop server: GET a product page 2. Shop server: Checkout or payment? Print nothing there 3. Shop server → Shopper's browser: HTML with SpaidooConfig, the loader, save anchors 4. Shopper's browser → Spaidoo widget: loads the widget scriptUrl 5. Shopper's browser: Draws the save icons in the anchors 6. Shopper's browser → Shop server: GET <urls.pushWorker> registers the push worker 7. Shop server → Shopper's browser: Service-Worker-Allowed: <pushScope>Shopper's browserShop serverSpaidoo widgetGET a product page12Checkout or payment?Print nothing thereHTML with SpaidooConfig,the loader, save anchors3loads the widgetscriptUrl45Draws the save iconsin the anchorsGET <urls.pushWorker>registers the push worker6Service-Worker-Allowed:<pushScope>7
Checkout and payment pages get nothing from Spaidoo; excludePaths is the safety net.

On every page except the excluded ones, print the configuration and the loader. scriptUrl comes from GET /status and may change: always use the latest one (§4.3, §7).

HTML
<script data-spaidoo-config='{"currency":"EUR","shopSession":"","push":{"swUrl":"https://shop.example.com/spaidoo/push-sw.js","swScope":"/spaidoo-push/"}}'>
  (function (s) { window.SpaidooConfig = Object.assign(window.SpaidooConfig || {}, JSON.parse(s.getAttribute('data-spaidoo-config'))); })(document.currentScript);
</script>
<script src="{scriptUrl}" async></script>
  • currency: the ISO code of the currency the visitor is browsing in; the widget shows prices in it (from spaidoo:price).
  • shopSession: the session hint of step 6.
  • preview: true only for the shop's staff while the widget is in preview mode.
  • push: { swUrl, swScope } when push is declared.
  • A full-page cache must keep one copy per currency, as it already does for prices.

Never print the script on checkout or payment pages. List them in storefront.excludePaths in the handshake too: it is the safety net when a page slips through.

Save buttons (optional)

Where the theme should show Spaidoo's save icon, print an empty anchor with the feed's product id: the widget draws the icon and handles the click and the state. Style it with the --sp-save-* CSS custom properties, and declare storefront.saveAnchors. Any element with data-spaidoo-library opens the customer's list.

HTML
<span data-spaidoo-save
      data-sp-context="product"
      data-sp-id="123"
      data-sp-url="https://shop.example.com/p/123"
      data-sp-name="Blue shirt"
      data-sp-image="https://shop.example.com/img/123.jpg"></span>

data-sp-context is "product" on a product page and "list" on product cards; data-sp-name and data-sp-image are optional.

The push worker: push

Web push is registered on the shop's own domain, so the connector serves Spaidoo's service worker at urls.pushWorker (§7.1):

HTTP
GET /spaidoo/push-sw.js

HTTP/1.1 200 OK
Content-Type: application/javascript
Service-Worker-Allowed: /spaidoo-push/
Cache-Control: no-cache

<the reference worker, copied verbatim from https://cdn.spaidoo.com/connector/v1/push-sw.js>
  • The body is the reference worker published at https://cdn.spaidoo.com/connector/v1/push-sw.js: copy it verbatim; do not importScripts it from another origin.
  • It receives data-only messages { title, body, link, icon, image, badge, shopId }.
  • Service-Worker-Allowed is only needed when pushScope is outside the worker's own directory. A worker served inside its scope (behind a platform's app proxy, say: /apps/spaidoo/push-sw.js with pushScope /apps/spaidoo/) goes without it.

How you know it works

  • The source of a product page shows data-spaidoo-config and the loader; the source of the cart and the checkout shows neither.
  • curl -I <urls.pushWorker> shows the headers.
  • Given a product page and the cart or checkout URL, the conformance check tests the loader, SpaidooConfig, the save anchors, that nothing is printed on the checkout, and the push worker.

Step 9 of 11

Domain verification, erase and wishlist import

Three short capabilities: prove the domain, forward deletions, bring old wishlists.

Domain verification: domainVerification

The shop proves it owns its domain without DNS changes. Ask Spaidoo for a code, serve it at urls.verify as text/plain with nothing else in the body, then ask Spaidoo to check (§4.9).

Domain verification: Spaidoo reads the code back from the shop1. Shop server → Spaidoo API: POST /domain-verification 2. Spaidoo API → Shop server: { verificationCode, domain } 3. Shop server: Store the code, serve it at urls.verify as text/plain 4. Shop server → Spaidoo API: POST /domain-verification/check 5. Spaidoo API → Shop server: GET <urls.verify> 6. Shop server → Spaidoo API: spaidoo-verify-… 7. Spaidoo API → Shop server: { verified: true }Shop serverSpaidoo APIPOST /domain-verification1{ verificationCode, domain }23Store the code, serveit at urls.verifyas text/plainPOST /domain-verification/check4GET <urls.verify>5spaidoo-verify-…6{ verified: true }7
The verify route answers the code as plain text and nothing else; Spaidoo does not follow redirects.
HTTP
POST /connector/v1/domain-verification
→ { "verified": false, "verificationCode": "spaidoo-verify-…", "domain": "shop.example.com" }

GET https://shop.example.com/spaidoo/verify
→ 200, Content-Type: text/plain
spaidoo-verify-…

POST /connector/v1/domain-verification/check
→ { "verified": true }
  • urls.verify must be on the domain being proven (or its www.) and must not contain the code. Spaidoo does not follow redirects.
  • For a domain already in Spaidoo's directory, the proof comes before the key (step 3): the route must answer while the shop is being paired (§3.3).
  • Without the capability, the merchant can still upload the code as https://<domain>/spaidoo-verify.txt.

Customer erase: customerErase

When the shop deletes a customer (or receives a GDPR erasure request), call POST /customers/erase. Spaidoo deletes the customer's list anchored to this shop and unlinks any Spaidoo account from it. Guests need not be sent (§4.7).

HTTP
POST /connector/v1/customers/erase
{ "customerId": "123" }
→ 204

Wishlist import: wishlistImport

POST /wishlists/import brings the lists of a previous wishlist extension: up to 50 customers per call, 100 lists per customer and 1 000 products per list, with the feed's product ids. Importing the same list twice does not duplicate it. It needs identity (§4.8).

JSON
{
  "customers": [
    { "customerId": "123", "lists": [
      { "id": "1", "name": "My wishlist", "isDefault": true, "products": ["123", "789"] }
    ] }
  ]
}

How you know it works

  • POST /domain-verification/check answers { "verified": true }, and the conformance check tests the verify route.
  • POST /customers/erase answers 204. Erase and import are calls to Spaidoo: the check lists them with how to check them by hand (§8).

Step 10 of 11

Check it

Run the online conformance check until it passes with no failures for every capability you declare.

A connector is Spaidoo-compatible v1 when the online conformance check passes with no failures for every capability it declares. Spaidoo already holds your handshake and your identity secret: you only paste the test shop's API key, never a secret (§8).

The conformance loopTest shop paired with your connector. Run the check with its API key. Read the report pass · fail · warn · skip. No failures: Spaidoo-compatible v1. Fix what fails (each line links the spec). none. failures. run againTest shop pairedwith your connectorRun the checkwith its API keyRead the reportpass · fail · warn · skipNo failures:Spaidoo-compatible v1Fix what fails(each line links the spec)nonefailuresrun again
Run, read, fix, run again, until no capability you declare has a failure.
  1. Open the conformance check and paste the API key of the test shop.
  2. Fill the optional fields to test more: a product page URL (loader, SpaidooConfig, save anchors), the cart or checkout URL (nothing printed there), the Cookie header of a signed-in test customer (identity) and a customer id (customers route).
  3. Run it: it takes a couple of minutes.
  4. Read the report, fix what fails and run it again. There are ten runs an hour per shop.
Screenshot of a real conformance report of a local test shop: not conformant, with the counts of passed, failed, warning and skipped checks, and the first checks of the handshake group, each linked to its spec section.
The top of a real report of the conformance check, run against a local test shop. Every line links to its section of the specification.

Reading the report

MarkWhat it means
PassThe check passed.
FailThe connector does not do what the specification asks. The line links to its section: fix it and run again. One failure is enough to be not conformant.
WarningNot a failure: conformance does not depend on it, but read it.
SkippedNot checked in this run. Fill the optional fields so that everything you declare gets checked.

The calls your connector makes to Spaidoo (catalog.changes, customerErase, wishlistImport) cannot be tested from outside: the report lists them with how to check them by hand.

The same check is open to your own tooling: POST /connector/v1/conformance starts a run (202 { runId }) and GET /connector/v1/conformance/{runId} returns the report, kept for an hour.

How you know it works

  • The report says Conformant with Connector API v1.

Reference

Specification

Step 11 of 11

Stuck?

Ask the assistant: it answers from the specification.

Spaidoo partners with access to the developers area can chat with the Connector API assistant. Sign in with your Spaidoo partner account: Spaidoo grants the access. Its answers link to the specification's sections, and your conversations are kept.

Screenshot of the Connector API assistant: the list of conversations on the left and a conversation with an attached conformance report and an answer that links to spec sections.
The assistant, with a conformance report attached to the question (example conversation).

From a report of the conformance check, Ask the assistant about this report opens a new conversation with the report attached and a question ready: why do these checks fail and how do I fix them?

No access yet? Everything the assistant knows is in the specification, which the Reference box of every step links to.

How you know it works

  • The assistant's answers cite the specification's sections as §x.y links.

Reference

Specification