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:
- Open https://dev.shopify.com/dashboard while signed into the merchant organization that owns the store.
- Apps → Create app → name "BoostEcom Verified Revenue".
- Create/release an app version with the required Admin API scopes:
read_ordersread_products
- Install that app on the store from the Dev Dashboard.
- Open the app's Settings / Credentials.
- Copy Client ID and Client secret (
shpss_…). - 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)
- Seller goes to Stripe → Developers → API keys → Restricted keys.
- Click "Create restricted key" → name "BoostEcom revenue read".
- Permissions (read-only):
- Charges → Read
- Subscriptions → Read
- Account → Read (to validate the key + display the account id)
- Copy the
rk_live_…(orrk_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 callingmarkLegalDocSigned()(src/services/marketplace/seller.ts), which stamps the deal directly. - V5.1 audit trail:
LegalSignaturerows created byrequestSignature()(src/services/marketplace/legal-signatures.ts), requested throughPOST /api/vendor/marketplace/deals/[id]/request-signatureand signed throughPOST /api/marketplace/signatures/[id]. TheSignatureProviderenum already carriesDOCUSIGN/HELLOSIGN/BOOSTECOM, but onlyBOOSTECOM(internal click-through + sha256 audit hash) is implemented: asking forDOCUSIGNorHELLOSIGNreturnshostedUrl: nulland logs "scaffold only". NoDOCUSIGN_*key is declared insrc/env/server.ts.
To upgrade to real e-signature:
Required DocuSign setup
- Create a DocuSign developer account → upgrade to JWT auth.
- Create an integration key, an API user, a private key (RSA).
- 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/HELLOSIGNbranch ofrequestSignature()insrc/services/marketplace/legal-signatures.ts(today aconsole.warnreturninghostedUrl: null): acreateEnvelope()call that uploads the rendered markdown as a PDF (via@react-pdf/rendererserver-side), sets seller + buyer as signers, and returns the hosted URL. - Add
POST /api/webhooks/docusignto receiveenvelope-completedevents and callsignSignature(), which already stampsndaSignedAt/loiSignedAt/apaSignedAton 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
- Sign up as merchant at escrow.com: fee accounts.
- Apply for API access (1-2 business days approval).
- Generate API key + paired webhook secret.
- 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.tsexposingcreateTransaction,getTransactionStatus,releaseFunds. - Call
createTransactionwhenadvanceDealStageflips a deal toAPA_SIGNED: store the returnedescrowIdon the DealThread. - New
POST /api/webhooks/escrowroute handling:transaction.created→ no-op (we already wrote it)transaction.funded→ setescrowFundedAt, advance toTRANSFERRINGtransaction.released→ setescrowReleasedAt, flip listing toSOLD
- 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):
| Champ | Valeur | Pourquoi |
|---|---|---|
status | revoked | seul mot que l'enum Postgres ConnectionStatus accepte — disconnected, ecrit par le DELETE de custom-app, n'y figure pas |
accessToken | null | le token d'une app desinstallee est mort ; le garder n'offre qu'une boucle de retry |
metadata.clientSecretEncrypted | supprime | c'est la cle de signature de webhooks qui ne peuvent plus arriver |
metadata.uninstalledAt | horodatage | la 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 :
| Chemin | Quand | Si ca echoue |
|---|---|---|
DELETE /api/stores/[storeId] | avant le cascade | 503, le store n'est PAS supprime — apres le cascade il n'y a plus rien pour piloter un retry |
POST /api/webhooks/shopify/redact | apres l'ecriture de la demande | 200 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 automatique | Ce qu'elle efface |
|---|---|
credentials | accessToken, secretHash, mcpKeyHash, pairingCode, metadata.clientSecretEncrypted, status: revoked — sur les connexions de ce shopDomain, jamais d'un storeId seul |
vectors | Les trois namespaces ci-dessus, en retry de la tentative du webhook |
webhook-archive | AuditLog.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-freetext | ShopifyOrder.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.