Runbooks & opérationsMarketplace integrations — credentials & activation

Marketplace integrations — credentials & activation

Reference for the partner integrations the marketplace surface depends on. Every integration here is gated on env vars: the codebase ships with safe fallbacks (503 + clear error message) when a credential is missing, so…

Reference for the partner integrations the marketplace surface depends on. Every integration here is gated on env vars: the codebase ships with safe fallbacks (503 + clear error message) when a credential is missing, so the broader app keeps running.

1. Shopify revenue verification

Used by: /sell/dashboard/[id] → "Connect Shopify" form → hourly sync of MRR / last-30d / annualised revenue → "Verified via Shopify" badge on the public listing.

Flow: Custom App credentials, not public-app OAuth. Each seller creates their own Shopify Custom App, configures scopes there, and pastes the resulting clientId + shpss_… clientSecret into the dashboard. We exchange via the client_credentials grant on every refresh: no long-lived access token is stored, only the seller credentials (encrypted AES-256-GCM via src/lib/security/crypto.ts).

Required seller setup (per listing)

The seller does this once in their own Shopify Dev Dashboard, no BoostEcom partner/public app is involved:

  1. Open https://dev.shopify.com/dashboard while signed into the merchant organization that owns the store.
  2. Apps → Create app → name "BoostEcom Verified Revenue".
  3. Create/release an app version with the required Admin API scopes:
    • read_orders
    • read_products
  4. Install that app on the store from the Dev Dashboard.
  5. Open the app's Settings / Credentials.
  6. Copy Client ID and Client secret (shpss_…).
  7. Paste them in /sell/dashboard/[listingId] → "Connect Shopify".

Current Shopify contract: https://shopify.dev/docs/apps/build/dev-dashboard/create-apps-using-dev-dashboard

Platform-side env

No platform-level Shopify credentials are needed for the verification flow or for the principal merchant-owned store-connect flow. The regular /api/integrations/shopify/custom-app route receives the merchant app's clientId + clientSecret, encrypts the secret on the IntegrationConnection, and exchanges those credentials for the 24-hour token.

Changing the app's scopes needs no reconnect (integrations/2774). Every refresh of that 24-hour token is a fresh client_credentials exchange, and Shopify's scope answer is persisted into IntegrationConnection.metadata.scopes. A scope the merchant adds in the Shopify admin reaches every BoostEcom gate (MCP consent screen, MCP tool registration, branch pre-flight, canvas catalogue) at the next refresh, so within a day. A connection whose grant was never recorded ([]) is read as UNKNOWN: nothing is refused on our side, and Shopify's own 403 reaches the caller verbatim. A refresh Shopify refuses (revoked app, rotated secret) logs [shopify/token] refresh_rejected once and leaves the stored grant untouched: the store reads token_unavailable until the merchant reconnects. The one reader of all this is src/features/shopify/sdk/connection.ts.

SHOPIFY_CLIENT_ID + SHOPIFY_CLIENT_SECRET belong to the separate platform/public-app OAuth surface and compliance/webhook contracts; they are not the credentials used to mint a merchant-owned Custom App token.

# .env / Vercel env
# SHOPIFY_API_VERSION: leave it unset unless you deliberately pin a version.
# The effective version is decided by src/features/shopify/sdk/version.ts
# (fallback "2026-07"); src/env/server.ts defaults the key to "" precisely so
# that fallback can win. This page used to advise `SHOPIFY_API_VERSION=2025-10
# # already defaulted in src/env/server.ts`: both halves were wrong, and
# 2025-10 stopped being supported by Shopify on 1 October 2026.
NEXT_PUBLIC_APP_URL=https://www.boostecom.app

Activation checks

# Local sanity check — POST shouldn't 401 on a non-owner, just 404:
curl -X POST https://www.boostecom.app/api/sell/listings/<some-listing-id>/verify/shopify   -H 'content-type: application/json'   -d '{"shopDomain":"acme.myshopify.com","clientId":"x","clientSecret":"shpss_x"}'
# → 401 (sign-in required) or 404 (listing not owned).

Webhook / refresh schedule

vercel.json includes the cron:

{
  "crons": [
    { "path": "/api/cron/sync-verified-revenue", "schedule": "0 * * * *" }
  ]
}

Sellers can also force a refresh on demand from the dashboard (rate-limited 20/hour/user).

2. Stripe revenue verification

Used by: same dashboard surface → "Connect Stripe" button → seller pastes a restricted API key with read scope on subscriptions + charges + account.

Required Stripe setup (per seller, no platform credentials)

  1. Seller goes to Stripe → Developers → API keys → Restricted keys.
  2. Click "Create restricted key" → name "BoostEcom revenue read".
  3. Permissions (read-only):
    • Charges → Read
    • Subscriptions → Read
    • Account → Read (to validate the key + display the account id)
  4. Copy the rk_live_… (or rk_test_…) value into the dashboard form.

The key is encrypted at rest (AES-256-GCM, src/lib/security/crypto.ts). No BoostEcom-side Stripe Connect platform: seller's Stripe stays untouched.

Activation checks

Stripe doesn't need any platform env. The key validation hits /v1/account on submission; if Stripe rejects the key, we surface the error message inline.

3. DocuSign signature workflow (deferred)

Status: no e-signature provider is wired, but the surrounding pipeline exists since V5.1 and this section used to ignore it. Two paths coexist:

  • Legacy self-declaration: /sell/deals/[id]/{nda,loi,apa} renders the template + a "Mark signed" button calling markLegalDocSigned() (src/services/marketplace/seller.ts), which stamps the deal directly.
  • V5.1 audit trail: LegalSignature rows created by requestSignature() (src/services/marketplace/legal-signatures.ts), requested through POST /api/vendor/marketplace/deals/[id]/request-signature and signed through POST /api/marketplace/signatures/[id]. The SignatureProvider enum already carries DOCUSIGN / HELLOSIGN / BOOSTECOM, but only BOOSTECOM (internal click-through + sha256 audit hash) is implemented: asking for DOCUSIGN or HELLOSIGN returns hostedUrl: null and logs "scaffold only". No DOCUSIGN_* key is declared in src/env/server.ts.

To upgrade to real e-signature:

Required DocuSign setup

  1. Create a DocuSign developer account → upgrade to JWT auth.
  2. Create an integration key, an API user, a private key (RSA).
  3. Required env:
DOCUSIGN_INTEGRATION_KEY=…
DOCUSIGN_API_USER_ID=…
DOCUSIGN_ACCOUNT_ID=…
DOCUSIGN_PRIVATE_KEY=…   # PEM, multi-line
DOCUSIGN_BASE_URL=https://demo.docusign.net/restapi   # or eu.docusign.net for prod

Code path to wire when credentials land

  • Fill in the DOCUSIGN / HELLOSIGN branch of requestSignature() in src/services/marketplace/legal-signatures.ts (today a console.warn returning hostedUrl: null): a createEnvelope() call that uploads the rendered markdown as a PDF (via @react-pdf/renderer server-side), sets seller + buyer as signers, and returns the hosted URL.
  • Add POST /api/webhooks/docusign to receive envelope-completed events and call signSignature(), which already stamps ndaSignedAt / loiSignedAt / apaSignedAt on the deal once every party has signed.
  • The existing UI surface stays, the button label just changes from "Mark signed" to "Send for signature".

4. Escrow.com escrow handling (deferred)

Status: NOT integrated. The deal pipeline UI shows the UNDER_OFFER → … → ESCROW_FUNDED → TRANSFERRING → CLOSED stages but the seller advances them manually (button on /sell/deals/[id]). To wire real escrow:

Required Escrow.com setup

  1. Sign up as merchant at escrow.com: fee accounts.
  2. Apply for API access (1-2 business days approval).
  3. Generate API key + paired webhook secret.
  4. Required env:
ESCROW_API_KEY=…
ESCROW_API_BASE=https://api.escrow.com/2017-09-01
ESCROW_WEBHOOK_SECRET=…
ESCROW_BOOSTECOM_FEE_PCT=3

Code path to wire when credentials land

  • New service src/services/marketplace/escrow.ts exposing createTransaction, getTransactionStatus, releaseFunds.
  • Call createTransaction when advanceDealStage flips a deal to APA_SIGNED: store the returned escrowId on the DealThread.
  • New POST /api/webhooks/escrow route handling:
    • transaction.created → no-op (we already wrote it)
    • transaction.funded → set escrowFundedAt, advance to TRANSFERRING
    • transaction.released → set escrowReleasedAt, flip listing to SOLD
  • Update /sell/deals/[id] checklist to surface the Escrow.com link directly so both parties can confirm transfers there.

5. Shopify webhook receiver — uninstall and retention

Not a marketplace credential, but the same operational surface: what the platform does when the merchant's side of a Shopify integration goes away, and what it keeps afterwards. /api/webhooks/shopify/events is the receiver.

app/uninstalled takes the connection out of service

The topic was tracked, archived and snapshotted, and the IntegrationConnection row stayed active. That column is what everything else reads, so after an uninstall the MCP relay answered a bare 503 "Shopify token unavailable — reconnect the Custom App" on every call, the dashboard tile still said "connected", the crons kept scheduling Admin API reads that could only fail, and verify-hmac kept holding a Custom App secret for an app that no longer exists.

The receiver now writes, before responding, for every row matching (storeId, provider: "shopify", shopDomain):

ChampValeurPourquoi
statusrevokedseul mot que l'enum Postgres ConnectionStatus accepte — disconnected, ecrit par le DELETE de custom-app, n'y figure pas
accessTokennullle token d'une app desinstallee est mort ; le garder n'offre qu'une boucle de retry
metadata.clientSecretEncryptedsupprimec'est la cle de signature de webhooks qui ne peuvent plus arriver
metadata.uninstalledAthorodatagela trace lisible par un operateur

Le filtre porte aussi sur shopDomain : une boutique repointee sur un autre shop ne doit pas perdre sa connexion vivante parce que l'ANCIEN shop a fini par livrer sa desinstallation. L'echec de cette ecriture ne change jamais le 200 rendu a Shopify — un retry ne repare pas une panne de base, et la ligne AuditLog ecrite juste avant garde la desinstallation visible.

Et il fallait encore que la livraison arrive. Jusqu'a integrations/0311, app/uninstalled n'etait abonne par aucun chemin de connexion : features/shopify/webhooks/register.ts demandait 13 topics (ceux que l'ingesteur normalise) pendant que le recepteur en traitait 24, et api/integrations/shopify/custom-app/route.ts dit de lui-meme qu'il est « the ONLY connect path the onboarding wizard uses ». Tout ce que decrit cette section etait donc du code injoignable pour une boutique branchee par l'assistant : le magasin se desinstallait, la ligne restait active, et personne ne l'apprenait. Les topics viennent maintenant d'un catalogue unique, features/shopify/webhooks/topics.ts, ou chaque entree porte sa raison ; les quatre listes qui devaient s'accorder a la main ont disparu. checkouts/* reste volontairement non abonne (ligne d'audit + SSE seulement, pour un volume qui ecrase celui des commandes) et l'entree le dit.

La verification HMAC des trois routes RGPD n'est pas celle des autres

redact, data-request et customer-redact passent par verifyShopifyPlatformHmac (features/shopify/webhooks/hmac.ts), qui n'accepte pas de shopDomain, et non par le verifyShopifyWebhookHmac multi-secret qu'utilise /api/webhooks/shopify/events. Ce n'est pas un oubli : ces trois routes resolvent la boutique a effacer d'apres le CORPS, tandis que le verificateur multi-secret choisit le secret a essayer d'apres X-Shopify-Shop-Domain, un en-tete que l'appelant ecrit. Un marchand proprietaire de son Custom App pourrait signer un corps nommant la boutique d'un concurrent, passer la verification avec son propre secret, et nous faire effacer le client d'un tiers. Les topics de conformite sont configures sur l'app Partner de la plateforme : SHOPIFY_CLIENT_SECRET est le seul secret correct ici, et la fonction n'a pas de parametre par ou en admettre un autre.

ShopifyWebhookEvent : retention 7 jours

Une ligne par LIVRAISON, et un seul lecteur : le P2002 qui repond « duplicate » quand Shopify re-envoie. Shopify retente une livraison echouee pendant 48 heures, donc une ligne plus vieille que ca ne peut plus rien dedupliquer : c'est du stockage pur, plus un index a maintenir, sur une table qu'une boutique active remplit a des milliers de lignes par jour (orders/updated, inventory_levels/update).

Le balayage vit dans le cron prune-audit-log (quotidien, 03:00 UTC), par lots de 5 000, sur l'index @@index([createdAt]) que le modele portait deja sans consommateur. Sept jours = la fenetre de retry, plus une marge large pour une redelivery qu'un operateur demande a Shopify de rejouer. Le compte efface est rendu dans la reponse du cron (shopifyWebhookEventsPruned).

6. Donnee client d'un marchand — pseudonyme, effacement, migration

Meme surface operationnelle que la section 5, cote donnees : ce que la plateforme retient des ACHETEURS d'un marchand, sous quelle identite, et ce qui part quand on efface.

Le pseudonyme client est desormais scope par boutique

hashCustomerEmail(storeId, email) (src/features/shopify/ingester/lib/email-hash.ts) derive un HMAC par store, prefixe s1:. Avant, il ne prenait que l'e-mail : le meme acheteur produisait la MEME valeur chez tous les marchands, dans ShopifyCustomer.emailHash, ShopifyOrder.customerHash et AttributionTouch.sessionId. Une jointure sur cette colonne repondait « cette personne achete chez A et chez B » — un chainage entre deux responsables de traitement distincts que ni l'un ni l'autre n'a demande.

La valeur scopee est derivee de l'ancienne (HMAC(sous-cle(store), "customer:v1:" + ancienne)) et non de l'adresse : aucune adresse n'est stockee, un HMAC ne s'inverse pas, donc c'est la seule construction qui rend les lignes deja ecrites migrables sans les relire chez Shopify.

Runbook de migration (une fois par deploiement)

A lancer juste apres le deploy. Entre le deploy et la migration, les lignes commerce nouvellement ecrites portent le digest scope pendant que les lignes anciennes portent le digest global : la barriere d'effacement lit les DEUX pendant cette fenetre (legacyGlobalCustomerHash), mais les jointures de reporting, elles, voient deux clients la ou il y en a un.

# 1. Etat des lieux, aucune ecriture. `complete: true` = plus rien a migrer.
curl -s -H "cookie: <session admin>" \
  https://boostecom.app/api/integrations/shopify/rehash-identities | jq

# 2. Appliquer. Idempotent et reprenable : un timeout se relance tel quel.
curl -s -X POST -H "cookie: <session admin>" \
  https://boostecom.app/api/integrations/shopify/rehash-identities | jq

# 3. Verifier : le dry-run doit rendre complete: true.

Corps optionnel du POST : {"storeIds": ["..."], "pageSize": 200} pour traiter une boutique a la fois.

Quatre colonnes bougent ensemble, par store et par transaction : ShopifyCustomer.emailHash, ShopifyOrder.customerHash, AttributionTouch.sessionId, ShopifyCustomerRedaction.emailHash (+ son subjectKey quand il embarque le digest). ShopifyComplianceRequest.emailHash n'est PAS migre : cette ligne est indexee par domaine de boutique et peut couvrir plusieurs stores, donc elle n'a pas de store a qui se scoper ; les nouvelles lignes portent un digest scope par shop.

Effacement : ce que le cascade Postgres n'atteint pas

Un store possede trois namespaces Upstash Vector — <storeId>, messages:<storeId>, facts:store:<storeId> — hors base, donc hors cascade. Et services/knowledge/indexer.ts ecrit Customer: <e-mail en clair> dans le texte indexe de chaque commande : c'est le seul endroit de la plateforme ou une adresse d'acheteur est persistee en clair.

forgetStoreNamespaces(storeId) les vide, et il est appele par les deux chemins qui doivent l'appeler :

CheminQuandSi ca echoue
DELETE /api/stores/[storeId]avant le cascade503, le store n'est PAS supprime — apres le cascade il n'y a plus rien pour piloter un retry
POST /api/webhooks/shopify/redactapres l'ecriture de la demande200 quand meme (la ligne existe, un retry Shopify n'apporte rien) + log shopify.gdpr.shop_redact.vector_purge_failed a draîner

shop/redact : ce qui part tout seul, et ce qui attend un humain

Le webhook enregistre une ShopifyComplianceRequest et vide le vectoriel. Le reste est draine par le cron gdpr-erasure, via features/shopify/compliance/shop-erasure.ts (integrations/0452). Avant, la ligne restait avec handledAt nul et rien ne la surveillait : ni cron, ni surface, ni escalade.

La regle qui decide de chaque table tient en une phrase : le clair part automatiquement, les identifiants pseudonymes et les registres du marchand attendent un humain.

Jambe automatiqueCe qu'elle efface
credentialsaccessToken, secretHash, mcpKeyHash, pairingCode, metadata.clientSecretEncrypted, status: revoked — sur les connexions de ce shopDomain, jamais d'un storeId seul
vectorsLes trois namespaces ci-dessus, en retry de la tentative du webhook
webhook-archiveAuditLog.metadata.payload des lignes shopify.webhook.*, cle par cle (sanitizeWebhookBody). C'est le gisement : prune-audit-log EXEMPTE ces lignes de sa fenetre de 180 jours, donc l'e-mail, le telephone et l'adresse y restaient en clair indefiniment
order-freetextShopifyOrder.metadata — tags et note_attributes, ou atterrissent messages cadeaux, noms de destinataires et telephones de livraison

Trois obligations restent a un operateur, et elles sont nommees sur la ligne dans pendingSteps plutot que dans un commentaire : commerce-identity-hashes, commerce-history, store-record. La derniere est la raison de fond : desinstaller une app Shopify n'est pas fermer un compte BoostEcom, et le cascade depuis Store atteint tout le reste.

Deux colonnes, deux faits : completedAt est pose par la machine quand chaque jambe automatique a reussi ET que le balayage d'archive est epuise ; handledAt n'est jamais pose que par un operateur. Une ligne avec completedAt, pendingSteps non vide et handledAt nul est donc une obligation ouverte, visible comme telle.

Au-dela de 30 jours sans handledAt, toute demande — shop/redact comme customers/data_request — leve un log.error gdpr.compliance_request.overdue. Les compteurs awaitingOperator et overdue sortent dans CronExecution.outcome, donc dans /admin/platform/crons.

Operational rule of thumb

If a credential isn't set, the corresponding API route returns 503 + a clear error message. Never silently broken, the dashboard surface always tells the seller what's missing.