Building Extensions

External APIs

Extend your platform by connecting to external services. This page covers how to make HTTP requests from your extension through the framework's secure proxy, including permission setup, domain configuration, and best practices.

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