> ## Documentation Index
> Fetch the complete documentation index at: https://docs.launchmystore.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Admin Action Extensions

> Add modal-triggered actions to admin resource pages

# Admin Action Extensions

Admin actions add a button to an admin resource page (e.g. the order detail
page). When the merchant clicks it, your app opens in a modal iframe with the
current resource id in the URL — perfect for "Refund this order", "Issue
gift card from order", "Resync inventory", or any other one-shot operation
that needs your app's UI but does not belong as an always-visible block.

<Tip>
  If you need a panel that is **always visible** on a resource page, use an
  [Admin Block](/extensions/admin-blocks) instead. Use an Admin Action when
  the merchant only needs the UI on demand.
</Tip>

## How Admin Actions Work

```
┌──────────────────────────────────────────────────────────────┐
│  LaunchMyStore Admin · Order #1042                           │
├──────────────────────────────────────────────────────────────┤
│  [Refund]  [Send invoice]  [Refund helper ← your action]     │
│                                                              │
│  Order details ...                                           │
└──────────────────────────────────────────────────────────────┘
                            ↓ click
┌──────────────────────────────────────────────────────────────┐
│  Modal · Refund helper                                       │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ <iframe src="https://app.example.com/refund?orderId..>│  │
│  │  [Your UI: pick line items, partial amount, reason]   │  │
│  │                                                       │  │
│  │  [Cancel]                              [Issue refund] │  │
│  └────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘
```

The host renders the button in the resource page's action toolbar. On click,
it opens a modal with an iframe pointing at your `appUrl` plus query
parameters describing the resource. Your app drives the UX inside the modal
and uses [App Bridge](/app-bridge/overview) to close it, show a toast, or
trigger a host redirect when work is done.

## Available Targets

Admin Actions are wired at specific resource-detail action slots. The
initial release ships with one target — more will be added in subsequent
releases.

| Target                              | Where it renders                        |
| ----------------------------------- | --------------------------------------- |
| `admin.order-details.action.render` | Action toolbar on the order detail page |

<Note>
  Additional targets for product, customer, draft order, and collection
  detail pages are planned next. Track the changelog for new
  `admin.{resource}-details.action.render` targets as they ship.
</Note>

## Extension Manifest

Declare admin actions in your `app.json` under `extensions.adminActions`,
or send them inline to the install pipeline. Each entry corresponds to one
button.

### Inline in `app.json`

```json theme={null}
{
  "handle": "refund-helper",
  "name": "Refund Helper",
  "version": "1.0.0",
  "extensions": {
    "adminActions": [
      {
        "handle": "issue-partial-refund",
        "title": "Refund helper",
        "target": "admin.order-details.action.render",
        "appUrl": "https://refund-helper.example.com/admin/refund",
        "icon": "https://refund-helper.example.com/icon.svg"
      }
    ]
  }
}
```

### Schema file form

When installed through the install pipeline, each admin action is persisted
as a single schema file at:

```
extensions/{domainSlug}/{appHandle}/admin-actions/{handle}.schema.json
```

```json theme={null}
{
  "target": "admin.order-details.action.render",
  "title": "Refund helper",
  "appUrl": "https://refund-helper.example.com/admin/refund",
  "icon": "https://refund-helper.example.com/icon.svg"
}
```

### Fields

| Field    | Required | Description                                                                           |
| -------- | -------- | ------------------------------------------------------------------------------------- |
| `handle` | yes      | URL-safe identifier — unique per app. Also used as the schema filename.               |
| `target` | yes      | Action slot the button is rendered into. See [Available targets](#available-targets). |
| `title`  | yes      | Button label shown to the merchant.                                                   |
| `appUrl` | yes      | HTTPS URL loaded into the modal iframe. Receives resource context as query params.    |
| `icon`   | no       | URL of an SVG/PNG icon shown next to the button label.                                |

## Installing Admin Actions

Send the action manifests when your app completes OAuth, the same way you
upload theme blocks or snippets. The install endpoint accepts an
`adminActions` array on the `extensions` payload:

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://store.launchmystore.io/api/apps/install-extensions \
    -H "Content-Type: application/json" \
    -d '{
      "domainSlug": "acme-store",
      "appHandle": "refund-helper",
      "extensions": {
        "adminActions": [
          {
            "handle": "issue-partial-refund",
            "title": "Refund helper",
            "target": "admin.order-details.action.render",
            "appUrl": "https://refund-helper.example.com/admin/refund",
            "icon": "https://refund-helper.example.com/icon.svg"
          }
        ]
      }
    }'
  ```

  ```javascript fetch theme={null}
  await fetch('https://store.launchmystore.io/api/apps/install-extensions', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      domainSlug: 'acme-store',
      appHandle: 'refund-helper',
      extensions: {
        adminActions: [
          {
            handle: 'issue-partial-refund',
            title: 'Refund helper',
            target: 'admin.order-details.action.render',
            appUrl: 'https://refund-helper.example.com/admin/refund',
            icon: 'https://refund-helper.example.com/icon.svg',
          },
        ],
      },
    }),
  });
  ```
</CodeGroup>

Each entry produces a `{handle}.schema.json` file under the app's
`admin-actions/` directory. The host's admin extension API picks it up
on the next request — `/api/apps/admin-extensions?target=...` returns
entries with `type: "admin_action"` for actions, as opposed to
`type: "admin_block"` for blocks.

## Iframe Context

When the merchant clicks the action button, your `appUrl` is loaded into
the modal iframe with these query parameters appended:

| Parameter      | Always set | Description                                                         |
| -------------- | ---------- | ------------------------------------------------------------------- |
| `target`       | yes        | The extension's manifest `target`.                                  |
| `domainSlug`   | yes        | The merchant's domain slug.                                         |
| `host`         | yes        | `btoa(window.location.origin)` — base64-encoded admin origin.       |
| `resourceId`   | optional   | The id of the resource currently being viewed (e.g. `ord_8a7f...`). |
| `resourceType` | optional   | The type of the resource (e.g. `order`).                            |

```javascript theme={null}
const params = new URLSearchParams(location.search);
const orderId = params.get('resourceId');
const resourceType = params.get('resourceType');
const domainSlug = params.get('domainSlug');
```

<Note>
  Unlike admin blocks, the action modal does **not** append an `extensionId`
  query param — the modal's resize listener filters on the manifest id it
  already holds, so you don't need to echo one. Resize
  clamp for the modal is **`Math.min(height, 800)`**, with a 400px default.
</Note>

```javascript theme={null}
// Auto-resize the action modal
window.parent.postMessage({
  type: 'APP_BRIDGE_RESIZE',
  extensionId: '<your-manifest-handle>',  // matched against host's state
  height: document.body.scrollHeight,
}, '*');
```

## Communicating with the Host

Admin actions use the standard [App Bridge SDK](/app-bridge/overview).
`app.dispatch(action, payload)` and `app.dispatchAndWait(action, payload)`
take an action **string** plus payload — not a `{type, payload}` envelope.

### Close the modal when done

After your action completes, dismiss the modal so the merchant returns to
the resource page:

```javascript theme={null}
import { createApp } from '@launchmystore/app-bridge';

const app = createApp({
  apiKey: process.env.NEXT_PUBLIC_APP_CLIENT_ID,
  host: new URLSearchParams(location.search).get('host'),
});

// Refund completed → close the modal
app.dispatch('MODAL_CLOSE');
```

### Show a toast on success

```javascript theme={null}
app.dispatch('TOAST_SHOW', {
  message: 'Refund issued for $24.00',
  duration: 3000,
  type: 'success',          // 'success' | 'error' | 'warning' | 'info'
});
app.dispatch('MODAL_CLOSE');
```

### Block close while a request is in flight

```javascript theme={null}
import { LeaveConfirmation } from '@launchmystore/app-bridge';

const leave = LeaveConfirmation.create(app, {
  message: 'Refund in progress — leave anyway?',
});

leave.enable();
await issueRefund();
leave.disable();

app.dispatch('MODAL_CLOSE');
```

The `LeaveConfirmation` helper also dispatches the underlying
`LEAVE_CONFIRMATION_ENABLE` / `LEAVE_CONFIRMATION_DISABLE` actions for you,
and wires the browser's `beforeunload` event so closing the tab triggers
the confirmation too.

## Complete Example

A minimal refund helper that loads the order, takes a partial amount, and
issues the refund via your app's backend before closing the modal.

```jsx theme={null}
import { createApp } from '@launchmystore/app-bridge';
import { useEffect, useState } from 'react';

export default function RefundHelper() {
  const [order, setOrder] = useState(null);
  const [amount, setAmount] = useState('');
  const [submitting, setSubmitting] = useState(false);

  const app = createApp({
    apiKey: process.env.NEXT_PUBLIC_APP_CLIENT_ID,
    host: new URLSearchParams(location.search).get('host'),
  });

  useEffect(() => {
    const params = new URLSearchParams(location.search);
    const orderId = params.get('resourceId');
    if (orderId) loadOrder(orderId);
  }, []);

  const loadOrder = async (orderId) => {
    const token = await app.getSessionToken();
    const response = await fetch(`/api/orders/${orderId}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    setOrder(await response.json());
  };

  const submit = async () => {
    setSubmitting(true);
    try {
      const token = await app.getSessionToken();
      await fetch(`/api/orders/${order.id}/refunds`, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${token}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ amount: parseFloat(amount) }),
      });
      app.dispatch('TOAST_SHOW', {
        message: `Refunded $${amount}`,
        type: 'success',
      });
      app.dispatch('MODAL_CLOSE');
    } catch (err) {
      app.dispatch('TOAST_SHOW', {
        message: 'Refund failed — please try again',
        type: 'error',
      });
    } finally {
      setSubmitting(false);
    }
  };

  if (!order) return <p>Loading order...</p>;

  return (
    <div className="refund-helper">
      <h3>Refund order {order.name}</h3>
      <p>Total paid: ${order.total}</p>

      <label>
        Refund amount
        <input
          type="number"
          step="0.01"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
        />
      </label>

      <div className="actions">
        <button onClick={() => app.dispatch('MODAL_CLOSE')}>
          Cancel
        </button>
        <button onClick={submit} disabled={submitting || !amount}>
          {submitting ? 'Refunding...' : 'Issue refund'}
        </button>
      </div>
    </div>
  );
}
```

## Admin Actions vs Admin Blocks

|               | Admin Action                                   | Admin Block                                   |
| ------------- | ---------------------------------------------- | --------------------------------------------- |
| Trigger       | Click a button                                 | Always rendered with the page                 |
| Layout        | Modal iframe on top of the page                | Inline iframe in the page layout              |
| Use cases     | Refund, resync, issue gift card, one-shot ops  | Reviews summary, warehouse status, KPIs       |
| `type` field  | `admin_action`                                 | `admin_block`                                 |
| File location | `admin-actions/{handle}.schema.json`           | `app.json` → `extensions.adminExtensions[]`   |
| Discovery API | `/api/apps/admin-extensions?type=admin_action` | `/api/apps/admin-extensions?type=admin_block` |

## See Also

* [Admin Blocks](/extensions/admin-blocks) — always-visible iframe panels.
* [Admin Print Actions](/extensions/admin-print-actions) — print-template variant.
* [App Bridge: Modal](/app-bridge/actions) — `MODAL_CLOSE`, `MODAL_OPEN`.
* [Sessions & Authentication](/app-bridge/session-tokens) — verifying calls from the iframe.
