This spring, Shopify quietly did something big: native B2B — Companies, catalogs, net terms — became available on all plans, not just Plus. Millions of stores can now sell wholesale without a $2k/month subscription or a heavyweight app suite.
I spent the last month building a small app on top of these APIs (Tradelane, it handles the wholesale application/approval flow that Shopify left out). Here's what I wish I'd known on day one.
1. One email = one company contact. Plan for it.
The constraint that bit me hardest: a customer email can only be the main contact of one company. If a buyer applies twice, or a merchant re-approves someone whose email is already attached to another company, companyCreate succeeds but the contact assignment fails silently unless you handle it.
My fix: treat "approved, but contact not assigned" as a first-class state with a clear message to the merchant, instead of pretending the whole operation failed. Partial success with an explanation beats a rollback nobody understands.
2. The app proxy is underrated for public-facing features
If your app needs a public page on the merchant's storefront (forms, portals, status pages), Shopify's app proxy gives you a route at store.com/apps/your-app/... that forwards to your server with an HMAC signature. Same origin as the storefront, no CORS, no theme code.
Two practical notes: authenticate.public.appProxy(request) (in the Remix/React Router template) gives you the shop and an admin API client, and because the page is same-origin you can later embed it in a theme app extension with a plain <iframe> — no postMessage gymnastics.
3. React escapes your <style> tags. Yes, really.
I shipped a form whose fonts silently fell back to Times New Roman. The culprit:
<style>{`
body { font-family: -apple-system, "Segoe UI", sans-serif; }
`}</style>
React escapes text children — including inside <style> — so "Segoe UI" becomes "Segoe UI", which is invalid CSS, which invalidates the whole declaration. Border-radius from the same stylesheet worked fine, so it took me days to notice. Same thing breaks url("data:...") values.
The fix is the escape hatch that exists precisely for this (spaces added between the braces so this renders on dev.to — in real code they'd be together):
<style dangerouslySetInnerHTML={ { __html: `...` } } />
4. Validate EU VAT numbers with VIES — it's free and merchants love it
If your app touches B2B in Europe, the EU's VIES service validates VAT numbers (and returns the registered business name) via a free API. Fake wholesale applications are more common than I expected, and "VAT valid ✓" next to an application turned out to be one of the most-mentioned features in feedback.
Cache the result — VIES is slow and occasionally down, so check once per application and store valid | invalid | unavailable.
5. hCaptcha over Turnstile for multi-tenant storefronts
My form renders on any merchant's domain via the app proxy. Cloudflare Turnstile wants per-hostname configuration; hCaptcha sitekeys work on any hostname by default. For a multi-tenant Shopify app, that's the whole decision. (Also: append ?hl=en to the script URL, or the widget renders in the visitor's browser language while the rest of your form is English.)
6. Fire-and-forget your emails, but never your writes
Every notification email in my submit path is void sendEmail().catch(log) — if the email provider hiccups, the application still saves and the buyer still sees the success page. A missed email is a degraded state; a lost application is a lost customer. Decide explicitly which failures are allowed to break the request and which aren't.
The gap, if you're looking for one: native B2B handles pricing, catalogs and payment terms well, but onboarding is still rough. The free Forms app can take applications, yet it creates the Company before approval (junk piles up in the admin), skips VAT validation, and doesn't assign catalogs. That's the hole I built Tradelane into. If you're building on the Companies API and hit something weird, my DMs are open. Happy to compare notes.
Top comments (0)