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.comandcdn.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.iomay 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:
| Field | Type | Default |
|---|---|---|
method | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'GET' |
headers | Record<string, string> | {} |
body | unknown | — |
FetchResponse:
| Field | Type | Description |
|---|---|---|
status | number | HTTP status code |
ok | boolean | true if status is 2xx |
data | unknown | Parsed 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.fetch | data.query | |
|---|---|---|
| Who handles the request | Extension (via proxy) | Platform |
| Permission | data:fetch | data:query |
| Domain config | Required (allowedDomains) | Not needed |
| Use when | Calling external APIs directly | Platform 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
}