Reference for frontend developers building shopper-facing storefronts (Next.js, Remix, Astro, native mobile apps, kiosks) against BuilderBlack stores.
Audience: you're writing code that buyers see — product pages, cart, checkout. You hold the relationship with the merchant's tenant. If you're building partner apps (analytics, ERP sync, marketing automations) instead, see
PUBLIC-API.md. If you're a merchant operating a store, seeDASHBOARD-GUIDE.md.
- Base URL:
https://api.builderblack.com - Version:
v1(all paths begin/api/v1/) - Auth model: anonymous endpoints + per-shopper customer JWT + an optional read-only Storefront API token for server-side renders
- Source of truth: this repo (
apps/api/src/)
- Three deployment modes
- Authentication
- CORS + tenant resolution
- Storefront SDK (recommended path)
- Cart session model
- Customer auth (registered shoppers)
- Resource reference
- Checkout flow
- Webhooks for storefronts
- Performance notes
- Errors
- Migrating from Shopify Storefront API
- Hard constraints
A storefront can talk to our API in three configurations. Pick the one that fits how you want to host the buyer-facing experience.
┌──────────────────────────────────────────────────────────────────────┐
│ Mode A: Platform-rendered │
│ <slug>.builderblack.com → our Next.js (apps/web) │
│ No code from you. Merchant picks a theme in /customize. │
│ Use this when the merchant doesn't have engineers. │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ Mode B: Self-hosted │
│ yourbrand.com → YOUR server (Vercel / your VPS / wherever) │
│ YOUR Next.js / Remix / native app calls api.builderblack.com. │
│ Tenant has deployment_mode='self_hosted'; we don't generate an │
│ nginx vhost or cert for the domain — you handle SSL on your end. │
│ Use this when you want full design freedom + your own perf. │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ Mode C: Native / kiosk / voice │
│ Mobile app, in-store screen, Alexa skill, Telegram bot, etc. │
│ No domain involved. Calls api.builderblack.com directly with the │
│ storefront token + customer JWT for logged-in shoppers. │
└──────────────────────────────────────────────────────────────────────┘
This document covers all three. Differences are flagged inline.
There are three auth surfaces. Use them in combination.
Most public endpoints work with no auth — just x-tenant-slug so we
know which store you mean:
GET /api/v1/products?limit=24
Host: api.builderblack.com
x-tenant-slug: kartse
For Mode A (platform-rendered) and Mode B (self-hosted via custom
domain registered in tenant_domains), the Host header alone resolves
the tenant. x-tenant-slug is optional but recommended for
explicitness.
A read-only token bound to one tenant. Issue from the dashboard
under Settings → API Tokens. Looks like bb_sft_<48 hex>.
GET /api/v1/products?limit=24
Authorization: Bearer bb_sft_4f2e8a3b...
Use this when:
- You're rendering server-side (Next.js getServerSideProps, ISR revalidate hooks, edge functions). The token lets you bypass per-IP rate limits and gets a higher cache budget.
- You're doing static generation that pre-fetches product data at build time.
- You're a kiosk / native app with no user logged in yet — the token identifies the tenant without needing a custom domain.
The token grants:
- ✅ Read all anonymous endpoints (products, collections, navigation, pages, FX, theme settings)
- ✅ Create cart sessions + add/remove items (rate-limited)
- ❌ Mutations on products / orders / customers / settings (use the Partner API for that — different auth flow)
- ❌ Read other tenants (token is tenant-scoped)
When a buyer logs in, you call our auth endpoints and get back a JWT.
Send it as Authorization: Bearer <jwt> on shopper-specific
endpoints (/api/v1/me/*).
See Section 6 for the full flow.
A typical authenticated storefront request stack:
Authorization: Bearer <customer_jwt> # shopper identity
x-tenant-slug: kartse # which store
X-Cart-Session: <uuid> # guest cart fallback if not logged in
Idempotency-Key: <uuid> # for write requests
Most endpoints work with any one of token/JWT/session header. The combination above is the safest defaults bundle.
The server checks (in order):
x-tenant-idheaderx-tenant-slugheader?tenant=<slug>query param:tenantSlugURL path param (some endpoints)- Custom domain match —
Hostheader looked up intenant_domains(must beverified=true) - Subdomain match —
<slug>.builderblack.comfromHost
Source: apps/api/src/middleware/tenantResolver.ts.
Our CORS plugin allows:
app.builderblack.com,admin.builderblack.com— credentialed- Tenant subdomains
*.builderblack.com— non-credentialed - Verified custom domains in
tenant_domains— non-credentialed
For Mode B (self-hosted), your domain MUST be verified by the merchant
before CORS will allow it. The merchant adds it under Domain & SSL
in their dashboard. Set deployment_mode='self_hosted' so we know
not to provision an nginx vhost on our end.
CORS-allowed request headers:
Content-Type, Authorization, x-tenant-id, x-tenant-slug,
x-csrf-token, x-cart-session, idempotency-key, x-request-id
If you need to send a header not on this list, the merchant has to add it via their CORS allowlist (or you ask us to extend the default).
CORS-exposed response headers:
x-request-id, x-api-version, deprecation, sunset, link
Source: apps/api/src/plugins/cors.ts.
Don't write raw fetch() calls — use our typed SDK:
npm install @builderblack/storefront-sdkimport { createStorefrontClient } from '@builderblack/storefront-sdk';
// In a Next.js project: lib/storefront-client.ts
export const sf = createStorefrontClient({
apiUrl: process.env.NEXT_PUBLIC_BB_API_URL!,
tenantSlug: process.env.NEXT_PUBLIC_BB_TENANT_SLUG!,
storefrontToken: process.env.BB_STOREFRONT_TOKEN, // optional, server-only
cartSessionId: typeof window !== 'undefined'
? localStorage.getItem('bb_cart_session') ?? undefined
: undefined,
});
// In a page / API route:
const { data: products } = await sf.products.list({ limit: 24 });
const { data: cart } = await sf.cart.addItem({ variantId, quantity: 1 });The SDK handles:
- Tenant header injection
- Cart session persistence (browser localStorage)
- Customer JWT refresh
- Idempotency key generation for writes
- Type-safe responses (zod schemas exported from
@builderblack/shared) - Universal (browser + Node + edge runtimes)
Source: packages/storefront-sdk/. Reference implementation:
github.com/builderblack/storefront-starter.
Buyers shop with one of two cart identities:
Browser generates a UUID once and stores it in localStorage. Sends
it as X-Cart-Session: <uuid> on every cart call.
// First page load — generate + persist
let sessionId = localStorage.getItem('bb_cart_session');
if (!sessionId) {
sessionId = crypto.randomUUID();
localStorage.setItem('bb_cart_session', sessionId);
}
// Every cart call carries the header
fetch('/api/v1/cart', { headers: { 'X-Cart-Session': sessionId } });The cart row persists in our carts table for 14 days from last
activity, so a returning visitor sees their cart.
Once the buyer logs in, we identify the cart by customer_id from
the JWT. The X-Cart-Session header is ignored if a customer JWT
is also present.
When a guest logs in, we merge their guest cart into the customer
cart automatically (mergeGuestIntoCustomer() in
apps/api/src/modules/auth/auth.service.ts):
- Same variant in both → quantities sum
- Different variants → all kept
- Customer cart wins on prices (in case prices changed since the guest added the item)
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/cart |
Fetch the cart (creates empty if missing) |
PUT |
/api/v1/cart |
Replace the entire items array (used after optimistic local mutations) |
POST |
/api/v1/cart/items |
Add one line item ({ variantId, quantity }); applies stock check |
PATCH |
/api/v1/cart/items/:variantId |
Update quantity; quantity <= 0 removes |
DELETE |
/api/v1/cart/items/:variantId |
Remove one line item |
DELETE |
/api/v1/cart |
Clear all items |
Source: apps/api/src/modules/cart/cart.routes.ts.
{
"id": "uuid",
"items": [
{
"variant_id": "uuid",
"product_id": "uuid",
"title": "Widget Pro - Red / Large",
"image_url": "https://api.builderblack.com/files/store-acme/media/...webp",
"sku": "WID-PRO-RED-L",
"price": "1499.00",
"quantity": 2,
"subtotal": "2998.00"
}
],
"subtotal": "2998.00",
"currency": "INR",
"updated_at": "2026-05-08T10:30:00.000Z"
}Note: cart does not include shipping, tax, or final total. Those are computed at checkout when the buyer enters an address — see Section 8.
POST /api/v1/auth/customer/register
Content-Type: application/json
x-tenant-slug: kartse
{
"email": "buyer@example.com",
"password": "8+ chars",
"first_name": "Asha",
"last_name": "Sharma",
"phone": "+919876543210" // optional
}
201 Created
{
"success": true,
"data": {
"customer": { "id": "uuid", "email": "buyer@example.com", ... },
"access_token": "eyJ...", // 24h TTL
"refresh_token": "...", // 30d TTL
"email_verified": false
}
}
We send a verification email automatically. Until verified,
/api/v1/me/* endpoints return 403 with code: EMAIL_NOT_VERIFIED.
POST /api/v1/auth/customer/login
Content-Type: application/json
x-tenant-slug: kartse
{ "email": "buyer@example.com", "password": "..." }
200 OK
{
"success": true,
"data": {
"customer": { ... },
"access_token": "eyJ...",
"refresh_token": "...",
"email_verified": true
}
}
Access tokens expire in 24h. Use the refresh token (30d):
POST /api/v1/auth/customer/refresh
Content-Type: application/json
{ "refresh_token": "..." }
The SDK does this automatically when it sees a 401 with
code: TOKEN_EXPIRED.
POST /api/v1/auth/customer/forgot-password
{ "email": "buyer@example.com" }
Always returns 200 (don't leak whether the email exists). We email the reset link.
POST /api/v1/auth/customer/reset-password
{ "token": "...", "new_password": "..." }
Once authenticated, shoppers access their own data:
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/me |
Profile |
PATCH |
/api/v1/me |
Update profile (name, phone) |
GET |
/api/v1/me/addresses |
Saved addresses |
POST |
/api/v1/me/addresses |
Add address |
PUT |
/api/v1/me/addresses/:id |
Update |
DELETE |
/api/v1/me/addresses/:id |
Remove |
GET |
/api/v1/me/orders |
Order history |
GET |
/api/v1/me/orders/:id |
Single order detail |
POST |
/api/v1/me/orders/:id/cancel |
Cancel an unshipped order |
GET |
/api/v1/me/wishlist |
Wishlist items |
POST |
/api/v1/me/wishlist |
Add to wishlist |
DELETE |
/api/v1/me/wishlist/:productId |
Remove |
GET |
/api/v1/me/gift-cards |
Gift card balances |
Source: apps/api/src/modules/customers/me-account.routes.ts.
Everything below is anonymous-callable (no JWT required), with
optional Bearer bb_sft_<token> for higher rate limits + caching.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/products |
List. Query: limit, cursor, q, status, tag, vendor, collection_id, sort |
GET |
/api/v1/products/slug/:slug |
Single product by storefront slug (most common — your URLs use slug) |
GET |
/api/v1/products/:id |
Single product by UUID |
Sample list response:
{
"success": true,
"data": [
{
"id": "uuid",
"title": "Widget Pro",
"slug": "widget-pro",
"description": "<p>HTML-sanitised description</p>",
"vendor": "Acme",
"currency": "INR",
"min_price": "1499.00",
"max_price": "2999.00",
"thumbnail": "https://api.builderblack.com/files/store-acme/media/...webp",
"tags": ["new", "featured"],
"in_stock": true
}
],
"next_cursor": "eyJpZCI6IjEyMyJ9",
"total": 42
}For the full product (with variants + images + features), call
/api/v1/products/slug/widget-pro — different shape, more data.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/collections |
List collections |
GET |
/api/v1/collections/slug/:slug |
Single collection (with metadata) |
GET |
/api/v1/collections/:id/products |
Products in collection. Query: limit, cursor, sort |
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/navigation/:menuKey |
Menu tree. menuKey typically header, footer, mobile, legal |
Used for header / footer / drawer rendering.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/pages/:slug |
Single CMS page (HTML body, sanitised) |
For /pages/about, /pages/privacy, /pages/refund-policy, etc.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/theme/live |
Active theme payload (settings + section config) for the tenant |
Use this in Mode B / C to mirror the dashboard /customize settings
(brand colors, fonts, hero copy) without redeploying your storefront.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/stock-notifications |
Subscribe to back-in-stock alerts |
Body: { email, product_id, variant_id }.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/gift-cards/check |
Check balance by code (used at checkout). No auth needed. |
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/public/fx/rates |
Daily rates from frankfurter.app, base USD |
Use to show approximate prices in a buyer's local currency on the storefront. Authoritative pricing still happens at checkout in the store's settlement currency.
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/public/tenants/resolve?host=<hostname> |
Resolve a hostname to a tenant slug (useful for SSR routing) |
GET |
/api/v1/public/tenants/:slug/primary-host |
Look up a tenant's canonical hostname |
This is the core flow your code will exercise heavily. Follow it step-by-step.
POST /api/v1/checkout/shipping-rates
Content-Type: application/json
x-tenant-slug: kartse
X-Cart-Session: <uuid> # or Bearer <customer_jwt>
{
"address": {
"country": "IN",
"state": "Karnataka",
"postal_code": "560001",
"city": "Bengaluru"
}
}
200 OK
{
"success": true,
"data": {
"methods": [
{ "id": "standard", "label": "Standard (3-5 days)", "rate": "60.00" },
{ "id": "express", "label": "Express (1-2 days)", "rate": "180.00" }
]
}
}
POST /api/v1/checkout/tax-quote
Content-Type: application/json
{
"address": { "country": "IN", "state": "Karnataka", "postal_code": "560001" },
"subtotal": "1499.00"
}
200 OK
{
"success": true,
"data": {
"tax_total": "269.82",
"rate": 0.18,
"breakdown": [
{ "label": "CGST 9%", "amount": "134.91" },
{ "label": "SGST 9%", "amount": "134.91" }
]
}
}
POST /api/v1/discounts/validate
Content-Type: application/json
{
"code": "WEEKEND10",
"subtotal": "1499.00",
"items": [{ "variant_id": "...", "quantity": 1 }]
}
200 OK
{
"success": true,
"data": {
"type": "percentage",
"value": 10,
"discount": "149.90",
"label": "WEEKEND10 — 10% off"
}
}
POST /api/v1/orders
Content-Type: application/json
Idempotency-Key: <uuid> # IMPORTANT — see below
x-tenant-slug: kartse
X-Cart-Session: <uuid> # or Bearer <customer_jwt>
{
"customer": {
"email": "buyer@example.com",
"first_name": "Asha",
"last_name": "Sharma",
"phone": "+919876543210"
},
"shipping_address": { "country": "IN", "state": "...", "city": "...", "line1": "...", "postal_code": "560001" },
"billing_address": { /* same as shipping or different */ },
"shipping_method": "standard",
"payment_method": "razorpay", // razorpay | stripe | cod | manual | gift_card
"discount_code": "WEEKEND10", // optional
"gift_card_code": "...", // optional
"notes": "Leave at door"
}
201 Created
{
"success": true,
"data": {
"order_id": "uuid",
"order_number": "1042",
"status": "pending",
"currency": "INR",
"subtotal": "1499.00",
"discount_total": "149.90",
"shipping_total": "60.00",
"tax_total": "242.83",
"grand_total": "1651.93",
"payment_required": true,
"payment_intent": {
"provider": "razorpay",
"razorpay_order_id": "order_LkX7v...",
"amount": 165193, // paise
"currency": "INR",
"key": "rzp_live_..."
}
}
}
Idempotency-Key is strongly recommended — without it, a network
retry can place a duplicate order. Use a UUID per checkout button
click; same key within 24h returns the cached response.
For Razorpay — use their checkout SDK with payment_intent data:
<script src="https://checkout.razorpay.com/v1/checkout.js"></script>
<script>
const rzp = new Razorpay({
key: paymentIntent.key,
amount: paymentIntent.amount,
currency: paymentIntent.currency,
order_id: paymentIntent.razorpay_order_id,
handler: async (response) => {
// On payment success, our server hears the webhook independently
// and confirms the order. The handler can just navigate to the
// confirmation page — order will be updated by the time it loads.
window.location.href = `/order-confirmed/${orderId}`;
},
});
rzp.open();
</script>For Stripe — use Stripe Elements with payment_intent.client_secret.
For COD — order is pending until merchant confirms. Show the
buyer the confirmation page directly; they don't pay now.
GET /api/v1/orders/confirmation/:orderId
200 OK (works without auth for 7 days post-checkout)
{ "success": true, "data": { /* order with items, addresses, totals */ } }
Use this on /order-confirmed/:id so the buyer sees their order
without needing to log in.
Most webhook events are for partner apps, not storefronts. But two patterns matter for buyers:
Coming in a future release — for now, poll /api/v1/me/orders/:id
every 30s on the order-confirmed page until status flips from
pending to confirmed.
Buyer subscribes to back-in-stock via
POST /api/v1/stock-notifications. We email them when the variant
restocks. No webhook to your storefront needed.
If you want order-created / order-updated webhooks delivered to your
own server (e.g. to update an external CRM after a checkout), you
need a partner app — not a storefront. See PUBLIC-API.md
section 6.
All anonymous GET endpoints set:
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600
This means:
- Browser caches for 60s
- CDN edges cache for 5 minutes
- For 10 minutes after expiry, the CDN can serve stale + revalidate in the background
In Mode B/C with the Storefront API token, you get more
aggressive caching: s-maxage=3600 (1h CDN). Set up a reverse proxy
(Vercel, Cloudflare Workers, your own Varnish) and your storefront
serves products at edge speed.
Bust the cache by:
- Bumping the product version (we set
ETagon every response) - Subscribing to
product.updatedwebhooks in a partner app and purging your CDN
The SDK uses HTTP keep-alive by default. Don't open + close connections per request — reuse the client.
Every uploaded image has 4 responsive variants:
thumb(200px) — listing thumbnailssm(480px) — mobile cardsmd(960px) — desktop cards / mobile PDPlg(1600px) — desktop PDP / hero
URLs come back as { url, variants: [{ label, width, url }] }. Use
the smallest one that works for the slot to save bandwidth.
<img
src={product.thumbnail}
srcset={product.images[0].variants
.map(v => `${v.url} ${v.width}w`)
.join(', ')}
sizes="(max-width: 768px) 100vw, 50vw"
alt={product.title}
/>We don't have GraphQL yet, so a typical product page does ~4 round trips:
/api/v1/products/slug/:slug— the product/api/v1/collections/:id/products?limit=4— related products/api/v1/cart— current cart count (for header)/api/v1/theme/live— theme settings (cache aggressively, only changes when merchant edits)
In Next.js, fire these in Promise.all from getServerSideProps so
they happen in parallel.
Same envelope as the partner API:
{
"success": false,
"error": "Human-readable message",
"code": "MACHINE_CODE",
"details": { /* optional */ }
}Common codes for storefronts:
| HTTP | code |
Meaning |
|---|---|---|
| 400 | INVALID_INPUT |
Validation failed |
| 401 | UNAUTHORIZED |
JWT missing / expired (refresh) |
| 403 | EMAIL_NOT_VERIFIED |
Customer hasn't verified email yet |
| 403 | TENANT_INACTIVE |
Store is suspended (don't show shop UI) |
| 404 | NOT_FOUND |
Product / order / page doesn't exist |
| 404 | TENANT_NOT_FOUND |
Wrong tenant slug or unverified domain |
| 409 | OUT_OF_STOCK |
Cart-add exceeds available inventory |
| 409 | IDEMPOTENCY_KEY_REUSED_DIFFERENT_BODY |
Same key, different payload — reset key per attempt |
| 422 | DISCOUNT_NOT_APPLICABLE |
Code doesn't match cart contents |
| 422 | PAYMENT_FAILED |
Payment provider declined |
| 429 | RATE_LIMITED |
Slow down; respect Retry-After |
If you already have a Hydrogen / Storefront-Kit codebase, the porting effort is mostly mechanical.
| Shopify | BuilderBlack | Notes |
|---|---|---|
Storefront Access Token (shpat_...) |
Storefront API Token (bb_sft_...) |
Same idea — header Authorization: Bearer ... |
| GraphQL Storefront API | REST /api/v1/* |
We're REST today; GraphQL is a roadmap item |
/api/2024-04/... versioned path |
/api/v1/... |
Different versioning scheme; same idea |
productByHandle(handle: $h) |
GET /api/v1/products/slug/:slug |
"handle" → "slug" |
cartCreate mutation |
Implicit — POST /api/v1/cart/items creates the cart on first call |
No explicit cart-create needed |
cartLinesAdd |
POST /api/v1/cart/items |
One item at a time today; bulk-add roadmap |
checkoutCreate + checkoutCompleteWithCreditCard |
POST /api/v1/orders |
Single endpoint that returns payment_intent |
Webhook orders/create |
Same — but subscribed via partner app, not storefront | See PUBLIC-API.md §6 |
customerAccessToken |
Customer JWT | Same idea, different format |
Hydrogen <Money /> component |
Use the formatPrice helper from the SDK or roll your own with Intl.NumberFormat |
We export currency settings via /api/v1/theme/live |
Differences worth flagging:
- We have multi-currency on every store without a paid tier.
Read
currency+currency_symbol+currency_positionfrom/api/v1/theme/liveinstead of guessing from locale. - HSN code on every product (Indian tax). Display on B2B invoices, ignore for B2C.
- COD as a payment method is first-class. Show it conditionally based on the buyer's pincode (configured in the store's payment methods).
- No customer addresses on initial order — buyers can place orders as guests; the address goes on the order, not the customer record. The customer record gets the address only after they register or save it.
These keep your storefront within the platform's contract. Violating them gets your domain rate-limited or removed from CORS.
-
Never expose the Storefront API token to browser JS. It's a server-only credential. Browser code uses anonymous endpoints + customer JWT. Token in browser DevTools = hand it back, rotate it, investigate.
-
Don't poll
/api/v1/productsfaster than once per minute per tenant per IP. That's 60 product-list requests per minute. If you need fresh data, subscribe toproduct.updatedwebhooks via a partner app. -
Don't try to confirm your own orders. The order's
financial_statusflips frompendingtopaidonly after our server hears the payment webhook from Razorpay/Stripe. Don't write client code that "trusts" the buyer's payment-success callback — it can be spoofed. -
Don't bypass
Idempotency-Keyon/api/v1/orders. Every missed retry without the key risks duplicate charges. The SDK adds the key automatically; if you go rawfetch(), you do it. -
Don't proxy our API through your own server then expose those responses without filtering. If you SSR a page that includes a product price, never include the merchant's
cost_price(your buyers would see margin). The product list endpoint doesn't return cost_price, but be careful about what you forward. -
Don't render unsanitised HTML from the product description / page body. We sanitise on save (P17), but you should still render through
dangerouslySetInnerHTMLonly with a known-safe sanitiser on your end if you fetch via a non-trusted path. -
Don't store customer JWTs in localStorage if you can avoid it. Use
httpOnlycookies set by your backend for the auth handoff. The SDK will help with this in v0.2. -
Don't keep a customer JWT past expiry without refreshing it. Stale tokens get the
UNAUTHORIZEDcode; if your code keeps retrying with a stale token, you'll trip the auth-rate-limit (10/min). -
Don't change
tenant_slugmid-session. A storefront serves ONE tenant. Mixing tenants in the same client instance is a bug and tripping our anti-pivot rate limit will throttle you hard. -
Don't fetch
/api/v1/admin/*from a storefront. Those are dashboard endpoints — they require platform-user JWT, not customer JWT. Storefront callers stay on/api/v1/*(anonymous +/me) and the resources listed in Section 7.
- Storefront SDK + starter:
packages/storefront-sdk/github.com/builderblack/storefront-starter
- Partner-app reference (different audience):
PUBLIC-API.md - Bug reports / feature requests:
partners@builderblack.com - Security disclosures:
security@builderblack.com
When reporting an issue, include the x-request-id header from a
failed response — we log every request with this id end-to-end.