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:
| Capability | What it adds |
|---|---|
catalog.feed | Spaidoo knows the shop's products (price, stock, images) — required |
catalog.changes | Price and stock changes reach Spaidoo in seconds instead of on the next daily read |
storefront.saveAnchors | The connector prints the save buttons in the theme (§7); Spaidoo's admin tells the merchant they come from the connector |
identity | A customer signed in to the shop keeps their saved products on every device, without a Spaidoo account |
customers | Spaidoo can ask the shop for the email (and marketing consent) of an identified customer when the merchant needs it |
customerErase | Deleting a customer in the shop deletes their Spaidoo data for that shop |
wishlistImport | Lists from a previous wishlist extension are brought into Spaidoo |
push | Web push notifications are served from the shop's own domain |
domainVerification | The 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/403mean the API key is not valid any more (the connector must show "disconnected").429carriesRetry-After. Any5xxor 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
| Secret | What it is | Used for |
|---|---|---|
| API key | Opaque string issued at pairing | Authorization: Bearer <apiKey> on every connector → Spaidoo call |
| Identity secret | 32 random bytes, base64url, issued by Spaidoo | HMAC 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:
redirectMode | For | Callback (redirect_uri) |
|---|---|---|
fixed | A 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 |
shop | A 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
- Your server generates a random
stateand acode_verifier(43–128 characters of[A-Za-z0-9-._~]), keeps both, and computescode_challenge = base64url(SHA-256(code_verifier))without padding. - 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>domainis required;name,email,mode(the tab that opens first),verify(only withdomainVerification) andlangare optional. - Spaidoo checks the
client_idand theredirect_uri. If either is wrong it shows the error and never redirects. - 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).
- 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. - Your server checks
stateand trades the code within 60 seconds (a code is good for one exchange):
POST /pairing/token — the only call without an API key:
{ "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.
- Store the key server-side and call
POST /handshake(§4.1). Theredirect_uripage then tells your admin page (postMessagetowindow.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.
{
"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, andverify, 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 theserverDomainsof 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 thestoreUrldomain (§4.9).loginMUST 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:
{
"shopId": "7f3c…",
"contractVersion": 1,
"capabilities": { "catalog.changes": true, "identity": true, "…": "…" },
"identitySecret": "b64url…"
}capabilitiesare 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;customersandwishlistImportare refused withoutidentity.identitySecretis 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
/handshakeand/statusresponses until the connector confirms it. POST /identity-secret/ack{ "fingerprint": "<sha256 hex of the secret>" }→200 { "acknowledged": true }(falseif 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).
{
"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
{ "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:
| Field | Required | Notes |
|---|---|---|
g:id | ✅ | Variant id |
g:item_group_id | if variants | The 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_price | Same 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
{
"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 }
]
}idis the product id of the feed (item_group_id, oridwithout variants).product.updatedcarries 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, bysku(theg:idof the feed), with amounts in the currency ofprice. 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).atis 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").priceandsalePricemust 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.atin 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_MISSINGif the handshake did not declarecatalog.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/readafter a bulk import.
Response 202 (changes are applied asynchronously, within seconds):
{ "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
{
"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 aturls.verifyastext/plain, nothing else in the body.urls.verifymust be on the domain being proven (or itswww.) and must not contain the code. Spaidoo does not follow redirects.POST /domain-verification/check→{ "verified": true }. Spaidoo fetchedurls.verifyand 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:
| Call | Body |
|---|---|
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>
| Answer | When |
|---|---|
200 { "assertion": "<assertion>" } | A customer is signed in |
200 { "anonymous": true } | Nobody is signed in (guests count as nobody) |
410 | The 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 + "." + signatureJSON:
{ "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.jtiis single use.anonIdechoes?anon=if it is a UUID, elsenull.emailMUST 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:
| Header | Value |
|---|---|
X-Spaidoo-Timestamp | Unix seconds |
X-Spaidoo-Nonce | 32 random hex chars |
X-Spaidoo-Signature | base64url( HMAC-SHA256( identity secret, timestamp + "." + nonce + "." + rawBody ) ) |
The connector MUST:
- reject a timestamp more than 300 s away from its clock (
401); - compare the signature in constant time over the raw body (
401); - SHOULD remember nonces for 600 s and reject a repeated one (
401).
6.2 POST <urls.customers> — customers
{ "customerIds": ["123", "124"] }Up to 500 ids. Answer 200:
{ "customers": [ { "customerId": "123", "email": "ana@example.com", "marketingConsent": true } ] }- Omit customers that no longer exist, guests and deleted ones.
marketingConsent:truethe customer accepted the shop's marketing emails (newsletter opt-in),falsethey did not,nullthe 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.
410if 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):
<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:
| Key | Value |
|---|---|
currency | ISO code of the currency the visitor is browsing in. The widget shows prices in it (from spaidoo:price) |
shopSession | Session hint (§5.3) |
preview | true 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.
<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.jswithpushScope/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 notimportScriptsit 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
| Date | Change |
|---|---|
| 2026-09-30 | First draft |
| 2026-09-30 | Pairing 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) |