471 lines
9.4 KiB
Markdown
471 lines
9.4 KiB
Markdown
# App Development Reference
|
|
|
|
Guide for building Shopify apps with OAuth, GraphQL/REST APIs, webhooks, and billing.
|
|
|
|
## OAuth Authentication
|
|
|
|
### OAuth 2.0 Flow
|
|
|
|
**1. Redirect to Authorization URL:**
|
|
```
|
|
https://{shop}.myshopify.com/admin/oauth/authorize?
|
|
client_id={api_key}&
|
|
scope={scopes}&
|
|
redirect_uri={redirect_uri}&
|
|
state={nonce}
|
|
```
|
|
|
|
**2. Handle Callback:**
|
|
```javascript
|
|
app.get('/auth/callback', async (req, res) => {
|
|
const { code, shop, state } = req.query;
|
|
|
|
// Verify state to prevent CSRF
|
|
if (state !== storedState) {
|
|
return res.status(403).send('Invalid state');
|
|
}
|
|
|
|
// Exchange code for access token
|
|
const accessToken = await exchangeCodeForToken(shop, code);
|
|
|
|
// Store token securely
|
|
await storeAccessToken(shop, accessToken);
|
|
|
|
res.redirect(`https://${shop}/admin/apps/${appHandle}`);
|
|
});
|
|
```
|
|
|
|
**3. Exchange Code for Token:**
|
|
```javascript
|
|
async function exchangeCodeForToken(shop, code) {
|
|
const response = await fetch(`https://${shop}/admin/oauth/access_token`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
client_id: process.env.SHOPIFY_API_KEY,
|
|
client_secret: process.env.SHOPIFY_API_SECRET,
|
|
code
|
|
})
|
|
});
|
|
|
|
const { access_token } = await response.json();
|
|
return access_token;
|
|
}
|
|
```
|
|
|
|
### Access Scopes
|
|
|
|
**Common Scopes:**
|
|
- `read_products`, `write_products` - Product catalog
|
|
- `read_orders`, `write_orders` - Order management
|
|
- `read_customers`, `write_customers` - Customer data
|
|
- `read_inventory`, `write_inventory` - Stock levels
|
|
- `read_fulfillments`, `write_fulfillments` - Order fulfillment
|
|
- `read_shipping`, `write_shipping` - Shipping rates
|
|
- `read_analytics` - Store analytics
|
|
- `read_checkouts`, `write_checkouts` - Checkout data
|
|
|
|
Full list: https://shopify.dev/api/usage/access-scopes
|
|
|
|
### Session Tokens (Embedded Apps)
|
|
|
|
For embedded apps using App Bridge:
|
|
|
|
```javascript
|
|
import { getSessionToken } from '@shopify/app-bridge/utilities';
|
|
|
|
async function authenticatedFetch(url, options = {}) {
|
|
const app = createApp({ ... });
|
|
const token = await getSessionToken(app);
|
|
|
|
return fetch(url, {
|
|
...options,
|
|
headers: {
|
|
...options.headers,
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
## GraphQL Admin API
|
|
|
|
### Making Requests
|
|
|
|
```javascript
|
|
async function graphqlRequest(shop, accessToken, query, variables = {}) {
|
|
const response = await fetch(
|
|
`https://${shop}/admin/api/2025-01/graphql.json`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'X-Shopify-Access-Token': accessToken,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ query, variables })
|
|
}
|
|
);
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.errors) {
|
|
throw new Error(`GraphQL errors: ${JSON.stringify(data.errors)}`);
|
|
}
|
|
|
|
return data.data;
|
|
}
|
|
```
|
|
|
|
### Product Operations
|
|
|
|
**Create Product:**
|
|
```graphql
|
|
mutation CreateProduct($input: ProductInput!) {
|
|
productCreate(input: $input) {
|
|
product {
|
|
id
|
|
title
|
|
handle
|
|
}
|
|
userErrors {
|
|
field
|
|
message
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Variables:
|
|
```json
|
|
{
|
|
"input": {
|
|
"title": "New Product",
|
|
"productType": "Apparel",
|
|
"vendor": "Brand",
|
|
"status": "ACTIVE",
|
|
"variants": [
|
|
{ "price": "29.99", "sku": "SKU-001", "inventoryQuantity": 100 }
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
**Update Product:**
|
|
```graphql
|
|
mutation UpdateProduct($input: ProductInput!) {
|
|
productUpdate(input: $input) {
|
|
product { id title }
|
|
userErrors { field message }
|
|
}
|
|
}
|
|
```
|
|
|
|
**Query Products:**
|
|
```graphql
|
|
query GetProducts($first: Int!, $query: String) {
|
|
products(first: $first, query: $query) {
|
|
edges {
|
|
node {
|
|
id
|
|
title
|
|
status
|
|
variants(first: 5) {
|
|
edges {
|
|
node { id price inventoryQuantity }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
pageInfo { hasNextPage endCursor }
|
|
}
|
|
}
|
|
```
|
|
|
|
### Order Operations
|
|
|
|
**Query Orders:**
|
|
```graphql
|
|
query GetOrders($first: Int!) {
|
|
orders(first: $first) {
|
|
edges {
|
|
node {
|
|
id
|
|
name
|
|
createdAt
|
|
displayFinancialStatus
|
|
totalPriceSet {
|
|
shopMoney { amount currencyCode }
|
|
}
|
|
customer { email firstName lastName }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Fulfill Order:**
|
|
```graphql
|
|
mutation FulfillOrder($input: FulfillmentInput!) {
|
|
fulfillmentCreate(input: $input) {
|
|
fulfillment { id status trackingInfo { number url } }
|
|
userErrors { field message }
|
|
}
|
|
}
|
|
```
|
|
|
|
## Webhooks
|
|
|
|
### Configuration
|
|
|
|
In `shopify.app.toml`:
|
|
```toml
|
|
[webhooks]
|
|
api_version = "2025-01"
|
|
|
|
[[webhooks.subscriptions]]
|
|
topics = ["orders/create"]
|
|
uri = "/webhooks/orders/create"
|
|
|
|
[[webhooks.subscriptions]]
|
|
topics = ["products/update"]
|
|
uri = "/webhooks/products/update"
|
|
|
|
[[webhooks.subscriptions]]
|
|
topics = ["app/uninstalled"]
|
|
uri = "/webhooks/app/uninstalled"
|
|
|
|
# GDPR mandatory webhooks
|
|
[webhooks.privacy_compliance]
|
|
customer_data_request_url = "/webhooks/gdpr/data-request"
|
|
customer_deletion_url = "/webhooks/gdpr/customer-deletion"
|
|
shop_deletion_url = "/webhooks/gdpr/shop-deletion"
|
|
```
|
|
|
|
### Webhook Handler
|
|
|
|
```javascript
|
|
import crypto from 'crypto';
|
|
|
|
function verifyWebhook(req) {
|
|
const hmac = req.headers['x-shopify-hmac-sha256'];
|
|
const body = req.rawBody; // Raw body buffer
|
|
|
|
const hash = crypto
|
|
.createHmac('sha256', process.env.SHOPIFY_API_SECRET)
|
|
.update(body, 'utf8')
|
|
.digest('base64');
|
|
|
|
return hmac === hash;
|
|
}
|
|
|
|
app.post('/webhooks/orders/create', async (req, res) => {
|
|
if (!verifyWebhook(req)) {
|
|
return res.status(401).send('Unauthorized');
|
|
}
|
|
|
|
const order = req.body;
|
|
console.log('New order:', order.id, order.name);
|
|
|
|
// Process order...
|
|
|
|
res.status(200).send('OK');
|
|
});
|
|
```
|
|
|
|
### Common Webhook Topics
|
|
|
|
**Orders:**
|
|
- `orders/create`, `orders/updated`, `orders/delete`
|
|
- `orders/paid`, `orders/cancelled`, `orders/fulfilled`
|
|
|
|
**Products:**
|
|
- `products/create`, `products/update`, `products/delete`
|
|
|
|
**Customers:**
|
|
- `customers/create`, `customers/update`, `customers/delete`
|
|
|
|
**Inventory:**
|
|
- `inventory_levels/update`
|
|
|
|
**App:**
|
|
- `app/uninstalled` (critical for cleanup)
|
|
|
|
## Billing Integration
|
|
|
|
### App Charges
|
|
|
|
**One-time Charge:**
|
|
```graphql
|
|
mutation CreateCharge($input: AppPurchaseOneTimeInput!) {
|
|
appPurchaseOneTimeCreate(input: $input) {
|
|
appPurchaseOneTime {
|
|
id
|
|
name
|
|
price { amount }
|
|
status
|
|
confirmationUrl
|
|
}
|
|
userErrors { field message }
|
|
}
|
|
}
|
|
```
|
|
|
|
Variables:
|
|
```json
|
|
{
|
|
"input": {
|
|
"name": "Premium Feature",
|
|
"price": { "amount": 49.99, "currencyCode": "USD" },
|
|
"returnUrl": "https://your-app.com/billing/callback"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Recurring Charge (Subscription):**
|
|
```graphql
|
|
mutation CreateSubscription($input: AppSubscriptionCreateInput!) {
|
|
appSubscriptionCreate(input: $input) {
|
|
appSubscription {
|
|
id
|
|
name
|
|
status
|
|
confirmationUrl
|
|
}
|
|
userErrors { field message }
|
|
}
|
|
}
|
|
```
|
|
|
|
Variables:
|
|
```json
|
|
{
|
|
"input": {
|
|
"name": "Monthly Subscription",
|
|
"returnUrl": "https://your-app.com/billing/callback",
|
|
"lineItems": [
|
|
{
|
|
"plan": {
|
|
"appRecurringPricingDetails": {
|
|
"price": { "amount": 29.99, "currencyCode": "USD" },
|
|
"interval": "EVERY_30_DAYS"
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
**Usage-based Billing:**
|
|
```graphql
|
|
mutation CreateUsageCharge($input: AppUsageRecordCreateInput!) {
|
|
appUsageRecordCreate(input: $input) {
|
|
appUsageRecord {
|
|
id
|
|
price { amount }
|
|
description
|
|
}
|
|
userErrors { field message }
|
|
}
|
|
}
|
|
```
|
|
|
|
## Metafields
|
|
|
|
### Create Metafield
|
|
|
|
```graphql
|
|
mutation CreateMetafield($input: MetafieldInput!) {
|
|
metafieldsSet(metafields: [$input]) {
|
|
metafields {
|
|
id
|
|
namespace
|
|
key
|
|
value
|
|
}
|
|
userErrors { field message }
|
|
}
|
|
}
|
|
```
|
|
|
|
Variables:
|
|
```json
|
|
{
|
|
"input": {
|
|
"ownerId": "gid://shopify/Product/123",
|
|
"namespace": "custom",
|
|
"key": "instructions",
|
|
"value": "Handle with care",
|
|
"type": "single_line_text_field"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Metafield Types:**
|
|
- `single_line_text_field`, `multi_line_text_field`
|
|
- `number_integer`, `number_decimal`
|
|
- `date`, `date_time`
|
|
- `url`, `json`
|
|
- `file_reference`, `product_reference`
|
|
|
|
## Rate Limiting
|
|
|
|
### GraphQL Cost-Based Limits
|
|
|
|
**Limits:**
|
|
- Available points: 2000
|
|
- Restore rate: 100 points/second
|
|
- Max query cost: 2000
|
|
|
|
**Check Cost:**
|
|
```javascript
|
|
const response = await graphqlRequest(shop, token, query);
|
|
const cost = response.extensions?.cost;
|
|
|
|
console.log(`Cost: ${cost.actualQueryCost}/${cost.throttleStatus.maximumAvailable}`);
|
|
```
|
|
|
|
**Handle Throttling:**
|
|
```javascript
|
|
async function graphqlWithRetry(shop, token, query, retries = 3) {
|
|
for (let i = 0; i < retries; i++) {
|
|
try {
|
|
return await graphqlRequest(shop, token, query);
|
|
} catch (error) {
|
|
if (error.message.includes('Throttled') && i < retries - 1) {
|
|
await sleep(Math.pow(2, i) * 1000); // Exponential backoff
|
|
continue;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
**Security:**
|
|
- Store credentials in environment variables
|
|
- Verify webhook HMAC signatures
|
|
- Validate OAuth state parameter
|
|
- Use HTTPS for all endpoints
|
|
- Implement rate limiting on your endpoints
|
|
|
|
**Performance:**
|
|
- Cache access tokens securely
|
|
- Use bulk operations for large datasets
|
|
- Implement pagination for queries
|
|
- Monitor GraphQL query costs
|
|
|
|
**Reliability:**
|
|
- Implement exponential backoff for retries
|
|
- Handle webhook delivery failures
|
|
- Log errors for debugging
|
|
- Monitor app health metrics
|
|
|
|
**Compliance:**
|
|
- Implement GDPR webhooks (mandatory)
|
|
- Handle customer data deletion requests
|
|
- Provide data export functionality
|
|
- Follow data retention policies
|