# Medusa

> Accept cryptocurrency payments in Medusa v2 through Cryptonly hosted checkout.

The **Cryptonly payment provider for Medusa** lets your Medusa v2 store accept cryptocurrency payments through Cryptonly's hosted checkout.

Customers select Cryptonly at checkout, pay on the Cryptonly payment page, and return to your store. The Medusa payment status updates automatically via signed webhooks — no manual reconciliation.

Setting up takes four steps:

1. [Install and register the provider](https://docs.cryptonly.net/integration/plugins/medusa.md#installation)
2. [Configure your credentials and webhook URL](https://docs.cryptonly.net/integration/plugins/medusa.md#configuration)
3. Enable the provider on a region in Medusa Admin
4. [Wire up your storefront](https://docs.cryptonly.net/integration/plugins/medusa.md#storefront)

## How the payment flow works

```mermaid
sequenceDiagram
    participant Customer
    participant Storefront
    participant Medusa
    participant Cryptonly
    Customer->>Storefront: Checkout with Cryptonly
    Storefront->>Medusa: initiatePaymentSession
    Medusa->>Cryptonly: Create invoice
    Cryptonly-->>Storefront: paymentPageUrl
    Storefront-->>Customer: Redirect to hosted checkout
    Customer->>Cryptonly: Pay with crypto
    Cryptonly->>Medusa: invoice.statusChanged webhook
    Medusa-->>Customer: Payment status updated
```

At a high level:

1. The customer checks out and chooses Cryptonly as the payment method.
2. Medusa creates a Cryptonly invoice server-side — your API key never reaches the browser.
3. The storefront redirects the customer to the hosted payment page (`paymentPageUrl`).
4. After payment, Cryptonly sends signed webhooks to your Medusa backend.
5. The provider verifies each webhook and marks the payment session **captured** once the invoice is `paid` or `overpaid`.

For details on invoices and statuses, see [Invoices](https://docs.cryptonly.net/guides/invoices.md).

## Requirements

| Requirement          | Details                                                                       |
| -------------------- | ----------------------------------------------------------------------------- |
| Medusa               | v2.8+ (peer dependency)                                                       |
| Node                 | 20+                                                                           |
| Cryptonly account    | [merchant.cryptonly.net](https://merchant.cryptonly.net/)                     |
| Public HTTPS backend | Cryptonly must reach `{MEDUSA_BACKEND_URL}/hooks/payment/cryptonly_cryptonly` |

## Installation

Install the plugin:

```bash
npm install @cryptonly/medusa-plugin-cryptonly
```

Register the provider in `medusa-config.ts`:

```ts
module.exports = defineConfig({
  modules: [
    {
      resolve: "@medusajs/medusa/payment",
      options: {
        providers: [
          {
            resolve: "@cryptonly/medusa-plugin-cryptonly/providers/cryptonly",
            id: "cryptonly",
            options: {
              apiKey: process.env.CRYPTONLY_API_KEY,
              webhookSigningKey: process.env.CRYPTONLY_WEBHOOK_SIGNING_KEY,
              sandbox: process.env.CRYPTONLY_SANDBOX === "true",
              backendUrl: process.env.MEDUSA_BACKEND_URL,
            },
          },
        ],
      },
    },
  ],
})
```

Finally, enable the provider on a region in Medusa Admin.

## Configuration

| Option              | Required | Description                                                         |
| ------------------- | -------- | ------------------------------------------------------------------- |
| `apiKey`            | Yes      | API key from the account Security settings. The key selects the account. See [required scopes](https://docs.cryptonly.net/integration/plugins/medusa.md#api-key-scopes). |
| `webhookSigningKey` | Yes      | Signing key from the same account. Verifies incoming Cryptonly webhooks |
| `backendUrl`        | Yes      | Public HTTPS Medusa origin used to build the webhook URL            |
| `webhookUrl`        | Yes      | Full HTTPS webhook URL (overrides `backendUrl`)                     |
| `sandbox`           | No       | Use the Cryptonly sandbox API (default: `false`)                    |
| `returnUrl`         | No       | Default browser URL after `cancelled` / `expired`                   |
| `successUrl`        | No       | Default browser URL after `paid` / `overpaid`                       |
| `failedUrl`         | No       | Default browser URL after `failed` / `suspended` / `partially_paid` |
| `expiresInMinutes`  | No       | Invoice expiry passed to Cryptonly (default: 1440)                  |

Provide either `backendUrl` or `webhookUrl`.

### API key scopes

Create the key on the account’s **Security** settings and enable only these scopes:

| Scope | Used for |
| ----- | -------- |
| `currencies.list` | Confirm the API key can reach the merchant API |
| `invoices.create` | Start checkout (`POST /invoice`) and recreate an invoice when the cart amount or currency changes |
| `invoices.list` | Read the invoice while authorizing, polling status, or retrieving the session (`GET /invoice`) |
| `invoices.cancel` | Cancel the invoice when the customer abandons checkout or the cart total changes (`POST /invoice/cancel`) |

Keep in mind:

* `apiKey` and `webhookSigningKey` must be non-empty — otherwise the provider will not register.
* Webhook URLs must be public **HTTPS**. Localhost and private IPs are rejected.
* The provider sets `webhookUrl` automatically on every invoice it creates, so you do **not** need to configure a default invoice webhook in the Cryptonly dashboard.

### Browser return URLs

Cryptonly's hosted page shows a "Back to Store" button whose destination depends on the invoice status:

| Option       | Used when invoice status is               |
| ------------ | ----------------------------------------- |
| `successUrl` | `paid` / `overpaid`                       |
| `returnUrl`  | `cancelled` / `expired`                   |
| `failedUrl`  | `failed` / `suspended` / `partially_paid` |

:::note
These are **browser** links, not server webhooks. Order completion always depends on Cryptonly calling your Medusa `webhookUrl` — even if the customer never clicks "Back to Store". See [Invoice statuses](https://docs.cryptonly.net/guides/invoices/statuses.md) for the full status model.
:::

A few things to know:

* Any `http` or `https` URL is accepted, including local storefronts such as `http://localhost:8000/...`.
* Provider options are only fallbacks. Prefer passing per-session values in the `initiatePaymentSession` `data` so each cart gets the right confirmation or retry URL.

**Recommended storefront mapping:**

* `successUrl` → confirmation / poll page (e.g. `/order/cryptonly/return?cart_id=…`)
* `returnUrl` / `failedUrl` → checkout review, so the customer can try again (e.g. `/checkout?step=review&cart_id=…`)

Include `cart_id` on all three URLs. On the Next.js starter, set the `_medusa_cart_id` cookie to `SameSite=Lax` so it is sent on the top-level return from Cryptonly — otherwise checkout may 404 after a cancel.

For webhook signing details, see [Webhooks](https://docs.cryptonly.net/api-reference/webhooks.md). For sandbox testing (simulated payments, test webhooks), see [Testing your integration](https://docs.cryptonly.net/guides/testing-your-integration.md).

## Storefront

### Start a new session on every pay attempt

On **each** "Pay with Cryptonly" click, call `initiatePaymentSession` again and redirect to the **new** `paymentPageUrl`. Do not reuse a URL from an earlier session — once the customer cancels or the invoice expires on Cryptonly, that URL is dead. (Same principle as Stripe Checkout: one session per pay attempt.)

```ts
const base = baseUrl.replace(/\/$/, "")
const successUrl = `${base}/order/cryptonly/return?cart_id=${cart.id}`
const returnUrl = `${base}/checkout?step=review&cart_id=${cart.id}`
const failedUrl = `${base}/checkout?step=review&cart_id=${cart.id}`

const { payment_collection } = await initiatePaymentSession(cart, {
  provider_id: "pp_cryptonly_cryptonly",
  data: { returnUrl, successUrl, failedUrl },
})

const session = payment_collection.payment_sessions.find(
  (s) => s.provider_id.startsWith("pp_cryptonly_")
)

window.location.href = session.data.paymentPageUrl
```

### Checkout label and icon

Checkout labels and icons are storefront-owned — Medusa does not expose them from the provider. On the [Next.js starter](https://github.com/medusajs/nextjs-starter-medusa), add an entry to `paymentInfoMap` in `src/lib/constants.tsx`:

```tsx

pp_cryptonly_cryptonly: {
  title: "Cryptonly (crypto)",
  icon: <Cryptonly />,
},
```

A reference icon component ships with the plugin under [assets/storefront/cryptonly.tsx](https://github.com/cryptonly-net/cryptonly-medusa/blob/main/assets/storefront/cryptonly.tsx), and PNG/SVG brand marks are available in `assets/` as well.

## Status mapping

The plugin listens for `invoice.statusChanged` webhooks and maps Cryptonly statuses to Medusa results as follows:

| Cryptonly status                 | Medusa result           |
| -------------------------------- | ----------------------- |
| `paid`, `overpaid`               | Captured (successful)   |
| `created`, `processing`          | Pending (no completion) |
| `partially_paid`                 | Failed                  |
| `failed`, `expired`, `suspended` | Failed                  |
| `cancelled`                      | Canceled                |

## Live demo (sandbox)

Try the full flow - no real funds needed: [medusa-demo.cryptonly.net](https://medusa-demo.cryptonly.net)

## See also

  <ExternalLinkCard title="Plugin on GitHub" href="https://github.com/cryptonly-net/cryptonly-medusa" icon="github" />
  <ExternalLinkCard title="Official Medusa marketplace" href="https://medusajs.com/integrations/@cryptonlymedusa-plugin-cryptonly" />
  <ExternalLinkCard title="npm package" href="https://www.npmjs.com/package/@cryptonly/medusa-plugin-cryptonly" icon="npm" />
- [Invoices](https://docs.cryptonly.net/guides/invoices.md): How checkout payments work
