Skip to content

Specification · v1

Spaidoo Connector API v1

The public contract between a connector and Spaidoo. Section numbers are the same in English and Spanish: the conformance check and the assistant link to them.

Status: v1 (2026-09-30). The public specification of how any e-commerce platform connects to Spaidoo.


1 What a connector is

Spaidoo gives an online shop a widget (save for later, lists, price-drop alerts, search) and a set of communication actions (mailing, push, posts). The widget works on any site with a single <script> tag. A connector is the piece of software that lives inside a shop platform (a module, an app, a plugin, a headless backend…) and lets Spaidoo do the things a script alone cannot:

CapabilityWhat it adds
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
storefront.saveAnchorsThe connector prints the save buttons in the theme (§7); Spaidoo's admin tells the merchant they come from the connector
identityA customer signed in to the shop keeps their saved products on every device, without a Spaidoo account
customersSpaidoo can ask the shop for the email (and marketing consent) of an identified customer when the merchant needs it
customerEraseDeleting a customer in the shop deletes their Spaidoo data for that shop
wishlistImportLists from a previous wishlist extension are brought into Spaidoo
pushWeb push notifications are served from the shop's own domain
domainVerificationThe shop proves it owns its domain without DNS changes

Rule of the contract: Spaidoo decides what to do from the capabilities a connector declares, never from the name of its platform. Any platform can build a connector; the platform field is informative only.

A connector may implement any subset, as long as catalog.feed is present.


2 Conventions

  • Base URL for connector → Spaidoo calls: https://api-plugin.spaidoo.com/connector/v1
  • JSON in and out, UTF-8. Content-Type: application/json.
  • Times are Unix seconds (integers) unless a field says otherwise.
  • Ids of the shop (customer id, product id) are strings, even if the platform uses numbers.
  • Money is a decimal string with a dot ("19.90") plus an ISO 4217 code.
  • Languages are ISO 639-1 codes (es, ca, en); currencies ISO 4217 (EUR).
  • Forward compatibility: both sides MUST ignore fields they do not know. New optional fields and new capabilities may appear within v1. Anything that breaks an existing connector goes to /connector/v2.
  • Errors from Spaidoo: HTTP status + body { "errorCode": "SOME_CODE", "message": "human text" }. 401/403 mean the API key is not valid any more (the connector must show "disconnected"). 429 carries Retry-After. Any 5xx or network failure means "try later": a connector MUST keep working (the shop must never break) when Spaidoo does not answer.

2.1 The two secrets

SecretWhat it isUsed for
API keyOpaque string issued at pairingAuthorization: Bearer <apiKey> on every connector → Spaidoo call
Identity secret32 random bytes, base64url, issued by SpaidooHMAC signatures: identity assertions (§5) and verifying Spaidoo → shop calls (§6)

Both are secrets of the shop. They MUST be stored server-side and never printed in pages.


3 Pairing

A shop connects with OAuth 2 and PKCE (RFC 6749, RFC 7636), as a registered connector app. The API key never goes through a browser or a URL.

3.1 Register your connector

Register the connector once in the partners' area of developers.spaidoo.com (My connectors) and you get its client_id (sc_…, public). There are two kinds of app:

redirectModeForCallback (redirect_uri)
fixedA connector hosted by you or by the platform (the merchant's admin is not on the shop's domain)One of the redirectUris you register, exact match
shopA connector installed in each shop (a module, a plugin)An https URL on the domain of the shop being connected (or a subdomain of it); nothing to register

serverDomains: the domains where your server routes (urls.customers) may live besides the shop's domains (§4.1).

platform is free text matching ^[a-z0-9-]{2,32}$ (my-platform, my-headless-shop, acme-commerce…). A suspended app connects no new shop; the shops already connected keep working.

3.2 The flow

  1. Your server generates a random state and a code_verifier (43–128 characters of [A-Za-z0-9-._~]), keeps both, and computes code_challenge = base64url(SHA-256(code_verifier)) without padding.
  2. Your admin page opens a popup (window.open, about 460×780, from the merchant's click) at:
    https://admin.spaidoo.com/connect?client_id=sc_…&redirect_uri=…&state=…
      &code_challenge=…&code_challenge_method=S256
      &domain=shop.example.com&name=<shop name>&email=<merchant email>
      &mode=signup|login&verify=<urls.verify>&lang=<iso>
    domain is required; name, email, mode (the tab that opens first), verify (only with domainVerification) and lang are optional.
  3. Spaidoo checks the client_id and the redirect_uri. If either is wrong it shows the error and never redirects.
  4. The merchant signs up or signs in and accepts the terms. A shop that already exists in Spaidoo's directory is only handed over once its domain is proven (§3.3).
  5. Spaidoo sends the popup to redirect_uri?code=…&state=…. If the merchant cancels: redirect_uri?error=access_denied&state=…; a malformed request: error=invalid_request.
  6. Your server checks state and trades the code within 60 seconds (a code is good for one exchange):

POST /pairing/token — the only call without an API key:

JSON
{ "client_id": "sc_…", "code": "…", "code_verifier": "…", "redirect_uri": "<the same one>" }

→ 200 { "apiKey": "…", "shopId": "…" }. Errors: 400 OAUTH_INVALID_GRANT (unknown, expired or used code, or client_id, redirect_uri or code_verifier do not match), 403 CONNECTOR_APP_SUSPENDED.

  1. Store the key server-side and call POST /handshake (§4.1). The redirect_uri page then tells your admin page (postMessage to window.opener, your own origin only, never the key) and closes the popup; your admin page shows the shop connected.

3.3 A shop of Spaidoo's directory

When the domain already belongs to a shop in Spaidoo's directory and the request carries verify, Spaidoo first sends the popup to:

redirect_uri?state=…&verification_code=spaidoo-verify-…&resume=https://admin.spaidoo.com/connect?…

Your callback checks state, stores the code so urls.verify serves it (§4.9), keeps the code_verifier, and sends the popup to resume exactly as it came. Spaidoo reads the code at urls.verify and carries on; the code arrives later on the same callback, with the same state. Without verify, the merchant proves the domain by hand with a file (§4.9).

A new shop connects straight away; its connector proves the domain afterwards, with its key, before publishing (§4.9).


4 Connector → Spaidoo

All calls: Authorization: Bearer <apiKey>.

4.1 POST /handshake — declare what the connector can do

Call it after pairing, after every connector update, and whenever an URL or capability changes. It is idempotent.

JSON
{
  "contractVersion": 1,
  "platform": { "id": "my-platform", "version": "3.2.0" },
  "connector": { "version": "2.0.0" },
  "storeUrl": "https://shop.example.com",
  "capabilities": {
    "catalog.feed": true,
    "catalog.changes": true,
    "identity": true,
    "customers": true,
    "customerErase": true,
    "wishlistImport": false,
    "push": true,
    "domainVerification": true,
    "storefront.saveAnchors": true
  },
  "urls": {
    "identity": "https://shop.example.com/spaidoo/identity",
    "login": "https://shop.example.com/login?back={back}",
    "customers": "https://shop.example.com/spaidoo/customers",
    "verify": "https://shop.example.com/spaidoo/verify",
    "pushWorker": "https://shop.example.com/spaidoo/push-sw.js"
  },
  "storefront": {
    "pushScope": "/spaidoo-push/",
    "domains": ["shop.example.com", "my-shop.platform.example"],
    "excludePaths": ["^/checkout", "^/cart"]
  },
  "identitySecretFingerprint": "<sha256 hex of the identity secret it holds, or null>"
}
  • Every URL MUST be https. Storefront URLs (identity, login, pushWorker), which the widget calls from the customer's page, and verify, which proves the domain, MUST be on one of the shop's domains. Server URLs (customers), which Spaidoo calls signed, MAY also be on one of the serverDomains of the app the shop connected with (§3.1). An URL is required only for a declared capability that needs it.
  • storefront.domains: other domains the storefront is served on (a platform subdomain and the shop's own domain, say), bare host names. The widget works on all of them; publishing still needs the proof of the storeUrl domain (§4.9).
  • login MUST contain the literal, unencoded marker {back}; the widget replaces it with the page to return to.
  • storefront.excludePaths: regular expressions (case-insensitive) over the path + query where the widget must never run (checkout, payment). The connector SHOULD also not print the script there (§7); this list is the safety net.
  • identitySecretFingerprint: lets a connector that lost its secret (reinstalled, restored from a backup) get a new one on its own. null = it holds none.

Response 200:

JSON
{
  "shopId": "7f3c…",
  "contractVersion": 1,
  "capabilities": { "catalog.changes": true, "identity": true, "…": "…" },
  "identitySecret": "b64url…"
}
  • capabilities are the ones Spaidoo accepted. The connector must behave according to this list. A capability is refused (left out, not an error) when Spaidoo has it switched off; customers and wishlistImport are refused without identity.
  • identitySecret is present when the connector must store a new one (§4.2).

Errors: 400 CONNECTOR_CATALOG_REQUIRED (no catalog.feed), 400 CONNECTOR_URL_INVALID with field (a declared capability's URL missing, not https on an allowed domain, or login without the unencoded {back}), 400 CONNECTOR_EXCLUDE_PATH_INVALID.

4.2 Identity secret delivery and rotation

  • Spaidoo issues the secret at handshake time and returns it in /handshake and /status responses until the connector confirms it.
  • POST /identity-secret/ack { "fingerprint": "<sha256 hex of the secret>" } → 200 { "acknowledged": true } (false if it is not the current secret). The connector MUST ack every secret it receives, after storing it.
  • POST /identity-secret/rotate → 200 { "identitySecret": "…" }: the merchant asks for a new one.
  • Grace period: after a rotation, Spaidoo keeps accepting assertions signed with the previous secret and keeps signing its own calls with the previous secret until the new one is acked, and for 10 minutes after the ack. A connector that loses a response keeps working.

4.3 GET /status — heartbeat and state

Call it when the merchant opens the connector's panel (and at most every few minutes otherwise).

JSON
{
  "shopId": "7f3c…",
  "identitySecret": "b64url…",
  "store": {
    "name": "Mi tienda", "plan": "free", "status": "active", "verified": true,
    "scriptUrl": "https://widget.spaidoo.com/loaders/7f3c….js",
    "adminUrl": "https://admin.spaidoo.com",
    "widget": { "mode": "live" },
    "stats": { "…": "…" },
    "feeds": [ { "language": "es", "url": "…", "lastReadAt": 1759219200, "products": 412, "status": "ok" } ]
  }
}

scriptUrl is what the connector prints on the storefront (§7). It may change: always use the latest one.

connector is what Spaidoo holds from the last handshake (contractVersion, platform, version, accepted capabilities, urls, storefront), or null before one. Never the secret.

4.4 POST /uninstall — {} → 204

The connector is being removed. Spaidoo stops reading the catalog, hides the products and turns the loader off. The data stays so a reinstall picks up where it was.

4.5 Catalog — catalog.feed (required)

PUT /catalog/feeds

JSON
{ "feeds": [ { "language": "es", "url": "https://shop.example.com/spaidoo/feed?lang=es" } ] }

The complete list: a language missing from it is removed. Spaidoo reads every feed about once a day.

POST /catalog/feeds/read → 202 { "queued": true } — "read now" (e.g. a button in the panel). 429 { "queued": false, "retryInSeconds": n } with Retry-After if asked again too soon (3 minutes).

The feed is a Google Merchant product feed (RSS 2.0 or Atom, g: namespace optional) with one <item>/<entry> per variant:

FieldRequiredNotes
g:id✅Variant id
g:item_group_idif variantsThe product id for Spaidoo (one row per product). Without it, g:id is the product id
g:title, g:link✅Localized; link is the canonical product URL
g:price✅"19.90 EUR": the shop's base currency
g:sale_priceSame currency as g:price
g:availability✅in_stock · out_of_stock · preorder · backorder
g:image_link, g:additional_image_link
g:description, g:brand, g:product_type (A > B > C), g:color, g:size, g:mpn
spaidoo:price currency="USD"Namespace https://spaidoo.com/ns/feed/1. Exact price in every other currency the shop sells in (repeatable)
spaidoo:sale_price currency="USD"

The product id is the one used everywhere else in this contract (data-sp-id, catalog.changes, wishlist import).

While a large feed is being generated, answer 202 (or 503/429) with Retry-After: <seconds>: Spaidoo comes back then and does not treat it as an empty catalog. Any other error keeps the previous catalog.

4.6 Catalog changes — catalog.changes

When a product changes in the shop, the connector tells Spaidoo right away. Price-drop alerts leave within minutes instead of on the next daily read. The daily feed read stays as the reconciliation.

POST /catalog/changes

JSON
{
  "changes": [
    {
      "type": "product.updated",
      "id": "123",
      "at": 1759219200,
      "price": { "amount": "24.90", "currency": "EUR" },
      "salePrice": { "amount": "19.90", "currency": "EUR" },
      "prices": [ { "currency": "USD", "price": "27.90", "salePrice": "21.90" } ],
      "availability": "in_stock",
      "variants": [
        { "sku": "123-1", "price": "24.90", "salePrice": "19.90", "availability": "in_stock" },
        { "sku": "123-2", "price": "24.90", "salePrice": null, "availability": "out_of_stock" }
      ]
    },
    { "type": "product.deleted", "id": "456", "at": 1759219230 }
  ]
}
  • id is the product id of the feed (item_group_id, or id without variants).
  • product.updated carries only what can change without touching the catalog structure: price, salePrice (null = no sale), prices, availability, variants. Every field is optional; a present field replaces the stored value.
  • variants: for a product with variants, the variants that changed, by sku (the g:id of the feed), with amounts in the currency of price. Price drops are measured on the cheapest variant, as with the feed. Variants not sent keep their values.
  • product.deleted: the product is no longer sold (deleted or disabled).
  • at is when the change happened in the shop. Spaidoo ignores a change older than the one it already applied for that product, so retries and out-of-order delivery are safe.
  • Amounts are decimal strings with at most 4 decimals ("24.90"). price and salePrice must be in the same currency; the variants' amounts are in that currency too. Changes in a currency other than a language's base currency leave that language's prices alone.
  • availability: in_stock · out_of_stock · preorder · backorder.
  • at in the future is taken as now.
  • Up to 100 changes per request and 60 requests a minute per shop (429 + Retry-After). The connector SHOULD coalesce bursts (a bulk price edit of 2 000 products is 20 requests, not 2 000).
  • 409 CONNECTOR_CAPABILITY_MISSING if the handshake did not declare catalog.changes.
  • For 24 hours after a change, the feed does not overwrite that product's prices and stock (a feed built before the change would undo it); it still updates the rest.
  • New products, and changes to title, link, images or categories, arrive with the next feed read. A connector may call POST /catalog/feeds/read after a bulk import.

Response 202 (changes are applied asynchronously, within seconds):

JSON
{ "accepted": 1, "unknown": ["456"] }

unknown lists ids Spaidoo does not have yet; it schedules a feed read. Delivery is best effort: on failure, retry with backoff for up to 1 hour, then drop — the daily feed read corrects everything.

4.7 POST /customers/erase — customerErase

{ "customerId": "123" } → 204. The shop deleted this customer (or received a GDPR erasure request). Spaidoo deletes the customer's list anchored to this shop and unlinks any Spaidoo account from it. Guests (customers without an account) need not be sent.

4.8 POST /wishlists/import — wishlistImport

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

Up to 50 customers per call, 100 lists per customer, 1 000 products per list. Products are product ids of the feed. Idempotent: importing the same list twice does not duplicate it.

4.9 Domain verification — domainVerification

  • POST /domain-verification → { "verified": false, "verificationCode": "spaidoo-verify-…", "domain": "shop.example.com" }. The connector stores the code and serves it at urls.verify as text/plain, nothing else in the body.
  • urls.verify must be on the domain being proven (or its www.) and must not contain the code. Spaidoo does not follow redirects.
  • POST /domain-verification/check → { "verified": true }. Spaidoo fetched urls.verify and found the code.
  • Without the capability, the merchant can still verify by uploading the code as https://<domain>/spaidoo-verify.txt.

4.10 Widget settings from the connector's panel (optional)

A connector may let the merchant change a few widget settings without leaving the back-office. These are the only writes a connector can make:

CallBody
POST /widget/state{ "mode": "off" | "preview" | "live" } — 403 DOMAIN_NOT_VERIFIED for live on an unverified domain
POST /widget/placement{ "desktop"?: {…}, "mobile"?: {…} } each { position?: "bottom-left"|"bottom-right", orientation?: "vertical"|"horizontal", showButton?: bool } — 409 if the merchant set a custom placement in Spaidoo
POST /widget/appearance{ accent?, iconColor?|null, icon?: "bookmark"|"heart", productCircle?, satelliteColor?, satelliteIconColor? } (colors #rrggbb)
POST /widget/search{ showSearchButton?: bool, chatAI?: bool }

4.11 POST /sso — open Spaidoo's admin already signed in

{ "intent": "panel" } → { "url": "…" } (single use, 5 minutes). Intents: panel, widget-setup, spaidoo-search, providers, email, posts, carousels, push, automations. Unknown intents fall back to panel.


5 Identity — identity

Goal: a customer signed in to the shop sees the same saved products on every device, and can link them to 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 Spaidoo's behalf.

5.1 The identity route (urls.identity)

The widget calls it from the storefront, same origin, with the shop's cookies:

GET <urls.identity>?anon=<uuid>

AnswerWhen
200 { "assertion": "<assertion>" }A customer is signed in
200 { "anonymous": true }Nobody is signed in (guests count as nobody)
410The connector is disconnected

Headers: Cache-Control: no-store, private and Vary: Cookie. It MUST never be cached by a page cache or CDN.

5.2 The assertion

payload   = base64url( JSON ), no padding
signature = base64url( HMAC-SHA256( key = identity secret as UTF-8 text, data = payload ) )
assertion = payload + "." + signature

JSON:

JSON
{ "v": 1, "shopId": "7f3c…", "customerId": "123", "email": "", "anonId": "<the anon query param or null>", "iat": 1759219200, "exp": 1759219260, "jti": "<32 hex chars, random>" }
  • exp - iat ≤ 60 s. Spaidoo tolerates 30 s of clock skew.
  • jti is single use.
  • anonId echoes ?anon= if it is a UUID, else null.
  • email MUST be "": Spaidoo does not keep shop customers' emails (see §6.2).

5.3 The session hint (optional, recommended)

So that the widget notices a login or logout without asking on every page, the connector prints in SpaidooConfig.shopSession (§7):

"" when nobody is signed in, else the first 16 hex chars of HMAC-SHA256(identity secret, "session:" + customerId).

It is a hint, not proof: the widget only uses it to decide when to call the identity route again.

5.4 Login

When the customer wants to keep their list and is not signed in, the widget sends them to urls.login with {back} replaced by the URL-encoded page they were on. After login, the shop MUST redirect back to it (same domain only).


6 Spaidoo → shop (signed calls)

6.1 Signature

Every call Spaidoo makes to a connector URL (except the public verify and feed) carries:

HeaderValue
X-Spaidoo-TimestampUnix seconds
X-Spaidoo-Nonce32 random hex chars
X-Spaidoo-Signaturebase64url( HMAC-SHA256( identity secret, timestamp + "." + nonce + "." + rawBody ) )

The connector MUST:

  1. reject a timestamp more than 300 s away from its clock (401);
  2. compare the signature in constant time over the raw body (401);
  3. SHOULD remember nonces for 600 s and reject a repeated one (401).

6.2 POST <urls.customers> — customers

JSON
{ "customerIds": ["123", "124"] }

Up to 500 ids. Answer 200:

JSON
{ "customers": [ { "customerId": "123", "email": "ana@example.com", "marketingConsent": true } ] }
  • Omit customers that no longer exist, guests and deleted ones.
  • marketingConsent: true the customer accepted the shop's marketing emails (newsletter opt-in), false they did not, null the platform does not know.
  • Spaidoo uses the answer at that moment (an export, a campaign audience, a sync to the merchant's email tool) and does not store the emails.
  • Answer within 5 s. 410 if disconnected.

What Spaidoo does with marketingConsent when the merchant syncs to an email tool (Klaviyo, Mailchimp…): true → subscribed to email marketing; false/null → profile without marketing subscription (transactional flows only).


7 Storefront

What the connector prints on the shop's pages (every page except the excluded ones):

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>

window.SpaidooConfig — every key optional:

KeyValue
currencyISO code of the currency the visitor is browsing in. The widget shows prices in it (from spaidoo:price)
shopSessionSession hint (§5.3)
previewtrue only for the shop's staff while the widget is in preview mode
push{ swUrl, swScope } when push is declared
  • Never print the script on checkout or payment pages.
  • A full-page cache must keep one copy per currency (as it already does for prices).

Save buttons (optional): where the theme should show Spaidoo's save icon, print an empty anchor. The widget draws the icon, handles the click and the state.

HTML
<span data-spaidoo-save
      data-sp-context="product"            <!-- "product" (product page) or "list" (cards) -->
      data-sp-id="123"                     <!-- product id of the feed -->
      data-sp-url="https://shop.example.com/p/123"
      data-sp-name="Blue shirt"            <!-- optional -->
      data-sp-image="https://…/123.jpg"></span>  <!-- optional -->

Style it through the CSS custom properties --sp-save-*; the widget adds no layout of its own.

Library link (optional): any element with data-spaidoo-library opens the customer's list (e.g. an entry in the account menu).

7.1 Push service worker — push

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

  • Content-Type: application/javascript, Cache-Control: no-cache.
  • Service-Worker-Allowed: <pushScope> when the scope is outside the worker's own directory. A worker served inside its scope (/apps/spaidoo/push-sw.js with pushScope /apps/spaidoo/, behind a platform's app proxy, say) needs no header.
  • Body: 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 }.

8 Conformance

A connector is Spaidoo-compatible v1 when the online conformance check passes with no failures for every capability it declares.

Connect a test installation of your platform with your connector (any shop reachable from the internet: a staging domain, or a tunnel such as ngrok or Cloudflare Tunnel for a local one), then run the check from the developers' page with that shop's API key. Spaidoo already holds the handshake and the identity secret, so no secret is ever pasted anywhere.

The same check is available to your own tooling:

  • POST /connector/v1/conformance { "shop"?: "<product page>", "checkout"?: "<cart page>", "cookie"?: "<Cookie header of a signed-in test customer>", "customer"?: "<customer id>" } → 202 { "runId": "…" }. The pages must be on the shop's domains. Ten runs an hour per shop (429 + Retry-After).
  • GET /connector/v1/conformance/{runId} → { "status": "running" }, then { "status": "done", "conformant": true|false, "counts": {…}, "results": [ { "capability", "title", "status": "pass"|"fail"|"warn"|"skip", "detail"?, "spec"? } ] }. Kept one hour.

It checks the handshake, the feeds and their items, the identity route and its assertions, the signed customers route (nonce, signature, raw body, age, limits), the verify route, the push worker, and the storefront (loader, SpaidooConfig, save anchors, nothing on the checkout). The calls from the connector to Spaidoo (catalog.changes, customerErase, wishlistImport) are listed with how to check them by hand.

9 Changes

DateChange
2026-09-30First draft
2026-09-30Pairing with OAuth 2 + PKCE and registered connector apps (§3); server URLs on the app's domains and storefront.domains (§4.1); push worker inside its scope (§7.1)