Integrate the Magma API

End-to-end Magma walkthrough - API key, cost estimate, buy mutation, HODL payment, polling, and recovery. Production-grade code you can copy.

A complete walk-through from zero to a confirmed inbound channel. By the end of this guide you'll have an integration that:

  • Estimates cost before charging customers
  • Places a buy order and pays the HODL invoice
  • Tracks the order through the full lifecycle
  • Handles failures cleanly (refunds, retries, terminal states)

The API is a single GraphQL endpoint:

https://magma.amboss.tech/graphql

For shorter quickstarts see Get Started. For focused references see Buy Liquidity, Sell Liquidity, Subscriptions, and the LSP REST API.


What you'll build


Prerequisites

  • An Amboss account at account.amboss.tech. Required to mint an API key for an integration that needs to manage subscriptions, sell, or read orders without a session key.
  • A Lightning node with at least the inbound or routing scenario you're solving for. The node must have a reachable pubkey@host:port socket.
  • A Lightning wallet (or your own node) capable of paying the HODL invoice the API returns.
  • Optional: a session-cookie store if you want anonymous buyers to come back to their own orders (web/mobile flows).

Buying liquidity is fully anonymous. You only need an API key if you want long-lived access tied to an Amboss account - e.g. selling, managing offers, or reading the same orders from multiple devices.


Quickstart

Create an API key (optional)

Skip this if you're an anonymous buyer - every liquidity.buy returns a session_key that does the same job for one identity.

Otherwise: go to account.amboss.tech/settings/api-keys and create one. Treat it like a password.

export AMBOSS_API_KEY="amboss_..."

Send it as a Bearer token on every authenticated request:

Authorization: Bearer amboss_...

See Authentication for the full credential matrix.

Estimate cost

Always price the channel before you charge a customer. The liquidity_per_usd query is free and reflects current market depth.

query LiquidityPerUsd {
  market {
    liquidity {
      liquidity_per_usd { sats usd }
    }
  }
}
curl -X POST https://magma.amboss.tech/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"query { market { liquidity { liquidity_per_usd { sats usd } } } }"}'
import { GraphQLClient, gql } from "graphql-request";

const magma = new GraphQLClient("https://magma.amboss.tech/graphql");

const { market } = await magma.request(gql`
  query {
    market { liquidity { liquidity_per_usd { sats usd } } }
  }
`);

const satsPerUsd = Number(market.liquidity.liquidity_per_usd.sats);
const channelSatsFor = (usd) => satsPerUsd * usd;
console.log("$50 ≈", channelSatsFor(50).toLocaleString(), "sats inbound");
{
  "data": {
    "market": {
      "liquidity": {
        "liquidity_per_usd": { "sats": "543260", "usd": "501.09" }
      }
    }
  }
}

sats is inbound capacity per USD. usd is the expected dollar-receiving capacity per USD spent - at the rate above, $1 of fees can carry ~$500 of payments through this channel.

Place a buy order

liquidity.buy returns a Lightning invoice you'll pay to lock in the channel.

mutation BuyLiquidity($input: LiquidityOrderInput!) {
  liquidity {
    buy(input: $input) {
      account { id session_key }
      order { transaction_id usd_cents }
      payment {
        lightning_invoice
        redirect_url
        amount { sats }
      }
    }
  }
}
{
  "input": {
    "connection_uri": "[email protected]:9735",
    "usd_cents": "5000",
    "options": { "private": false, "rails_cluster_only": false }
  }
}
curl -X POST https://magma.amboss.tech/graphql \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $AMBOSS_API_KEY" \
  -d @- <<'JSON'
{
  "query": "mutation($input: LiquidityOrderInput!) { liquidity { buy(input: $input) { account { id session_key } order { transaction_id usd_cents } payment { lightning_invoice redirect_url amount { sats } } } } }",
  "variables": {
    "input": {
      "connection_uri": "[email protected]:9735",
      "usd_cents": "5000"
    }
  }
}
JSON
const BUY = gql`
  mutation BuyLiquidity($input: LiquidityOrderInput!) {
    liquidity {
      buy(input: $input) {
        account { id session_key }
        order { transaction_id usd_cents }
        payment { lightning_invoice redirect_url amount { sats } }
      }
    }
  }
`;

const { liquidity } = await magma.request(BUY, {
  input: {
    connection_uri: process.env.MY_NODE_URI,
    usd_cents: "5000",
  },
});

const { lightning_invoice, redirect_url } = liquidity.buy.payment;
const sessionKey = liquidity.buy.account.session_key;
const orderId = liquidity.buy.order.transaction_id;

// Persist the session_key NOW - it's the only way back to this order
// without an API key.
await db.sessions.upsert({ id: orderId, sessionKey });
{
  "data": {
    "liquidity": {
      "buy": {
        "account": {
          "id": "54b9e82d-39d0-42b9-9229-f67786cdf145",
          "session_key": "b65b867c345468a0a2e07b3b86aa3078"
        },
        "order": {
          "transaction_id": "ec562479-a4b8-44f4-95b4-150b310832de",
          "usd_cents": "5000"
        },
        "payment": {
          "lightning_invoice": "lnbc125u1p...",
          "redirect_url": "https://btcpay.amboss.tech/i/...",
          "amount": { "sats": "125000" }
        }
      }
    }
  }
}

LiquidityOrderInput fields

FieldRequiredTypeNotes
connection_uriStringpubkey@host:port for the destination node. Tor sockets work.
usd_centsStringInteger string in USD cents. Minimum 500 ($5).
redirect_urlStringURL to send the customer to after BTCPay checkout. Defaults to a Magma status page.
options.privateBooleanOpen an unannounced channel.
options.rails_cluster_onlyBooleanRestrict matching to Amboss Rails cluster sellers (higher reliability tier).
options.asset_idStringNon-BTC asset id. Omit for BTC.

Don't retry liquidity.buy blindly. The mutation is not idempotent - every successful call creates a new order. If a previous call timed out, list user.transactions.transaction_list first and reconcile.

Pay the invoice

Present payment.lightning_invoice to whoever's paying. Three common patterns:

  1. End-user wallet. Render the invoice as a QR code or a lightning: URI. The customer scans it from their wallet.
  2. Hosted checkout. Send the customer to payment.redirect_url. Same invoice, hosted UI with QR + copy button.
  3. Programmatic payment from your own node. Pay payment.lightning_invoice directly with lncli payinvoice or your node's RPC.

The instant the HTLC lands, the order's payment_status goes to SUCCESSFUL_PAYMENT and its status moves to WAITING_FOR_CHANNEL_OPEN.

Track the order

Use the session_key you saved (or your API key) as a Bearer token and poll user.market.orders.get_order.

const GET_ORDER = gql`
  query GetOrder($orderId: String!) {
    user {
      market {
        orders {
          get_order(order_id: $orderId) {
            id
            status
            payment_status
            channel_id
            transaction_id
            confirmations { confirmations }
          }
        }
      }
    }
  }
`;

const SUCCESS = new Set(["VALID_CHANNEL_OPENING", "CHANNEL_MONITORING_FINISHED"]);
const FAILURE = new Set([
  "SELLER_REJECTED", "SELLER_FAILED_TO_REACT",
  "BUYER_REJECTED", "BUYER_FAILED_TO_PAY",
  "SELLER_FAILED_TO_OPEN_CHANNEL", "SELLER_FAILED_TO_SEND_SWAP",
  "INVALID_CHANNEL_OPENING", "ADMIN_CLOSED",
]);

async function trackOrder(orderId, sessionKey) {
  const client = new GraphQLClient("https://magma.amboss.tech/graphql", {
    headers: { Authorization: `Bearer ${sessionKey}` },
  });

  while (true) {
    const { user } = await client.request(GET_ORDER, { orderId });
    const order = user.market.orders.get_order;

    if (SUCCESS.has(order.status)) return { ok: true, order };
    if (FAILURE.has(order.status)) return { ok: false, order };

    // Slow polling once the funding tx is on chain
    const onChain = ["SELLER_SENT_TRANSACTION", "WAITING_FOR_ON_CHAIN_CONFIRMATION"]
      .includes(order.status);
    await new Promise((r) => setTimeout(r, onChain ? 45_000 : 10_000));
  }
}

const result = await trackOrder(orderId, sessionKey);
console.log(result.ok ? "channel opened" : `failed: ${result.order.status}`);

See Tracking Orders for the full reference and Order Lifecycle for every status.

Handle failures

When trackOrder returns { ok: false, order }, you have a terminal failure. Each terminal status maps to a specific recovery:

StatusWhat to tell the userWhat to do
SELLER_REJECTED / SELLER_FAILED_TO_REACT"Seller declined; trying again."Re-issue liquidity.buy (the original payment is automatically refunded by the HODL invoice).
BUYER_FAILED_TO_PAY"Payment didn't go through in time."Generate a fresh order. The previous invoice has expired.
SELLER_FAILED_TO_OPEN_CHANNEL / SELLER_FAILED_TO_SEND_SWAP"Channel couldn't open. Refund issued."Refund is automatic via HODL. Optionally retry with rails_cluster_only: true for higher reliability.
INVALID_CHANNEL_OPENING"Seller breached agreed terms."Refund is automatic. Open a support ticket.
ADMIN_CLOSED"Order cancelled by Amboss."Contact support.

HODL refund. When a seller fails to open the channel, the HODL invoice never settles - the buyer's payment is released back to their wallet automatically. You do not need to call a refund endpoint.

See Errors for the broader error catalog including auth and validation failures.


Production checklist

Once the happy path works in dev, these are the things to wire up before you ship.

Idempotency

  • Estimate queries (liquidity_per_usd, offer_book) are pure - retry freely.
  • liquidity.buy is not idempotent. Always reconcile by listing transactions before retrying after a timeout:
query Reconcile {
  user {
    transactions {
      transaction_list(page: { limit: 10, offset: 0 }) {
        list { id status created_at }
      }
    }
  }
}
  • Polling is safe to repeat unboundedly - the response is deterministic.

Rate limits

Don't poll an individual order faster than once every 5 seconds. Spread bulk reads across transaction_list rather than fanning out to many get_order calls.

Storage

Persist three things for every order you create:

FieldWhy
order.transaction_idIdentifies the order across all subsequent calls.
account.session_key (if anonymous)The only way to authenticate as this buyer later.
payment.lightning_invoiceLets you show the customer the invoice again if they close the tab before paying.

Observability

Log on every status transition you observe. Aggregating those into a funnel chart (WAITING_FOR_SELLER_APPROVALVALID_CHANNEL_OPENING) makes it obvious when sellers are dropping orders.

Going from anonymous to authenticated

If you start with session_key (anonymous buyer flow) and later mint an API key, the two identities don't merge automatically. Plan for this:

  • Stay anonymous for one-off purchases. Simplest.
  • Use API keys from the start if you'll have a single backend tracking many orders.
  • Don't switch mid-flow - the session-key orders remain accessible only via the session key.

Authentication

Two credentials, same Authorization: Bearer … header:

ModeWhere it comes fromUse for
API keyaccount.amboss.tech/settings/api-keysLong-lived integrations, selling, reading orders across devices
Session keyReturned once by liquidity.buy / liquidity.create_subscriptionAnonymous buyer flows

See Authentication for the full credential matrix.


Lifecycle: a buyer's view

See Order Lifecycle for the full state machine, including in-flight states this diagram collapses.


Error handling cheatsheet

  • Unauthorized token - bad or missing Bearer header. Mint a new key.
  • Invalid ID - malformed UUID. Validate before sending.
  • Payment errors are surfaced via payment_status (HODL_INVOICE_TIMEOUT, INVALID_PAYMENT_SECRET, etc.), not GraphQL errors. Always read both status and payment_status when reasoning about an order.
  • Don't trust HTTP 200. GraphQL returns 200 even when errors is populated.

Full reference: Errors.


Next steps