Building Extensions

External APIs

We'll walk through how to make direct HTTP requests to external services from your extension. All requests are proxied through the framework for security — your extension runs in a sandbox and never makes raw network calls.

Setup

1. Add Permission

Add data:fetch to your manifest permissions:

{
  "permissions": ["data:fetch"]
}

2. Configure Allowed Domains

Every domain your extension calls must be listed in allowedDomains:

{
  "allowedDomains": ["api.example.com", "graphql.example.com", "*.myshopify.com"]
}

Rules:

  • Exact hostnames or *.<suffix> wildcards (subdomain match) — no paths, no protocols
  • Wildcards take the form *.<suffix> and must use a multi-label suffix (e.g., *.example.com, not *.com)
  • The framework will reject requests to unlisted domains
  • Add each subdomain separately when listing exact hosts (e.g., api.example.com and cdn.example.com)

Wildcard Domains

Prefer exact hostnames where possible. Use *.example.com only when you need to match any subdomain — for example, tenant-per-subdomain platforms (Shopify shops, Salesforce subdomains, Zendesk subdomains, multi-region API hosts) where you don't know the full set of hostnames at authoring time or will differ by instance.

{
  "allowedDomains": ["*.myshopify.com"]
}

With this entry, the framework allows requests to acme.myshopify.com, widgets.myshopify.com, and any subdomain at any depth (e.g., shop.eu.myshopify.com). A request to evil.com is still rejected.

Apex is separate. *.myshopify.com does not match myshopify.com itself — this matches CORS, TLS-certificate, and DNS-wildcard convention. To allow both the apex and any subdomain, list both:

{
  "allowedDomains": ["myshopify.com", "*.myshopify.com"]
}

Worked example:

{
  "allowedDomains": [
    "*.myshopify.com",
    "myshopify.com",
    "api.example.com",
    "*.staging.example.com"
  ]
}

This allows any customer shop subdomain, the Shopify apex, an exact API host, and any subdomain (at any depth) under staging.example.com.

What's rejected and where. Wildcards must use a multi-label suffix (e.g., *.example.com, not *.com). Two layers enforce this:

  • Format-level (instant feedback in the CLI, MCP, and Studio AI panel) — * alone, multiple wildcards, mid-string wildcards (api-*.example.com), single-label suffixes (*.com, *.localhost, *.io), protocols (https://...), paths, ports, and IP literals.
  • Server-level (at submission) — TLD patterns such as *.co.uk, *.com.au, and *.github.io may pass the format check but will be rejected at submission, since they would otherwise allow any registrant under a shared registry.

Local development. Exact localhost and IPv4 literals (127.0.0.1) are still valid for exact-host entries. Dev/staging extension modes bypass the domain check entirely, so wildcards aren't needed for local iteration — they matter at submission and in production.

3. Make Requests

Access data.fetch through the useCapabilities() hook:

const capabilities = useCapabilities()

const result = await capabilities.data.fetch('https://api.example.com/data', {
  method: 'GET',
  headers: { 'Authorization': 'Bearer token' },
})

if (!result.ok) throw new Error(`Request failed: ${result.status}`)
const data = result.data as MyType

API Signature

capabilities.data.fetch(url: string, init?: FetchRequestInit): Promise<FetchResponse>

FetchRequestInit:

FieldTypeDefault
method'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE''GET'
headersRecord<string, string>{}
bodyunknown—

FetchResponse:

FieldTypeDescription
statusnumberHTTP status code
okbooleantrue if status is 2xx
dataunknownParsed response body

API Wrapper Pattern

Create a typed wrapper in src/lib/api.ts to keep components clean:

import type { Capabilities } from '@stackable-labs/sdk-extension-react'

const BASE_URL = 'https://api.example.com'

export async function fetchCustomer(
  capabilities: Capabilities,
  customerId: string
): Promise<Customer> {
  const result = await capabilities.data.fetch(
    `${BASE_URL}/customers/${customerId}`
  )
  if (!result.ok) throw new Error(`Failed to fetch customer: ${result.status}`)
  return result.data as Customer
}

export async function updateCustomer(
  capabilities: Capabilities,
  customerId: string,
  data: Partial<Customer>
): Promise<Customer> {
  const result = await capabilities.data.fetch(
    `${BASE_URL}/customers/${customerId}`,
    {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: data,
    }
  )
  if (!result.ok) throw new Error(`Failed to update customer: ${result.status}`)
  return result.data as Customer
}

Then use it in components:

const capabilities = useCapabilities()
const customer = await fetchCustomer(capabilities, customerId)

data.fetch vs data.query

data.fetchdata.query
Who handles the requestExtension (via proxy)Platform
Permissiondata:fetchdata:query
Domain configRequired (allowedDomains)Not needed
Use whenCalling external APIs directlyPlatform provides the API integration

Error Handling

Always check result.ok before accessing result.data:

const result = await capabilities.data.fetch(url)
if (!result.ok) {
  capabilities.actions.toast({
    message: 'Request failed. Please try again.',
    type: 'error',
  })
  return
}

Auto-generated from Stackable Extension SDK. Questions/Issues? developers@stackablelabs.com

Previous
Permissions
External APIs | Stackable Labs :. Dev Documentation