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.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
}