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

# Moove Payment Links

> Create and list payment links. POST returns a shareable checkout URL; GET reconciles every link on the account.

Create a payment link, hand the payer its URL, take payment status from [webhooks](/api-reference/moove-receive/webhooks), and reconcile against the list endpoint.

<Info>
  Base URL `https://api.moove.xyz`. Generated against [`openapi.json`](https://api.moove.xyz/openapi.json).
</Info>

***

## Create a payment link

<ParamField path="POST /v1/payment-link" type="endpoint" required>
  Requires the `payment_link:create` scope.
</ParamField>

Creates a payment link and returns its id and shareable checkout `url` — send the payer to that URL.

<Note>
  The link settles to the authenticated user's **default wallet**, in that wallet's chain and token. `toAmount` is denominated in that token. **The destination cannot be specified by the caller.**
</Note>

### Body

<ParamField body="toAmount" type="number | string" required>
  Amount to request, denominated in the settlement token of the authenticated user's default wallet. Must be greater than 0.

  Send it as a **string** to avoid floating-point rounding.

  **It must also fit the settlement token's decimal precision.** The amount is quantised to the token's `decimals`; if that changes the value, the request is rejected with `422` `INVALID_PAYMENT_LINK_AMOUNT`.

  USDC has 6 decimals, so against a USDC wallet:

  | `toAmount`      | Result                                        |
  | --------------- | --------------------------------------------- |
  | `"49.99"`       | Accepted                                      |
  | `"49.999999"`   | Accepted — exactly 6 decimals                 |
  | `"49.9999999"`  | **Rejected** — 7 decimals                     |
  | `"100.0000000"` | Accepted — trailing zeros are not significant |

  Different tokens have different precision, and the limit is the *destination* wallet's token, not yours. Read `token.decimals` from the [list endpoint](#list-payment-links) if you need to round before sending.
</ParamField>

<ParamField body="description" type="string">
  Shown to the payer on the checkout page. Maximum 500 characters.
</ParamField>

<ParamField body="maxUsage" type="integer">
  How many payments the link accepts before it completes. Minimum 1. **Unlimited when omitted.**
</ParamField>

<ParamField body="expirationDate" type="string">
  ISO 8601 timestamp, in the future. After it passes the link stops accepting payments. **Never expires when omitted.**
</ParamField>

### Request

<CodeGroup>
  ```bash cURL theme={null}
  curl -sS -X POST "https://api.moove.xyz/v1/payment-link" \
    -H "X-API-Key: $MOOVE_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "toAmount": "49.99",
      "description": "INV-2026-0142",
      "maxUsage": 1,
      "expirationDate": "2026-12-31T23:59:59Z"
    }'
  ```

  ```python Python theme={null}
  import httpx

  res = httpx.post(
      "https://api.moove.xyz/v1/payment-link",
      headers={"X-API-Key": MOOVE_API_KEY},
      json={
          "toAmount": "49.99",
          "description": "INV-2026-0142",
          "maxUsage": 1,
          "expirationDate": "2026-12-31T23:59:59Z",
      },
  )
  res.raise_for_status()
  link = res.json()
  ```

  ```typescript TypeScript theme={null}
  const res = await fetch('https://api.moove.xyz/v1/payment-link', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.MOOVE_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      toAmount: '49.99',
      description: 'INV-2026-0142',
      maxUsage: 1,
      expirationDate: '2026-12-31T23:59:59Z',
    }),
  });

  if (!res.ok) throw new Error(`${res.status}`);
  const link = await res.json();
  ```
</CodeGroup>

### Response

<ResponseField name="id" type="string" required>
  The payment link's id.
</ResponseField>

<ResponseField name="url" type="string" required>
  The shareable checkout URL. Send the payer here.
</ResponseField>

```json 200 theme={null}
{
  "id": "0c8f2e5a-4b91-4c3e-9d17-2f6a8b0d1e34",
  "url": "https://moove.xyz/@yourhandle/pay/0c8f2e5a-4b91-4c3e-9d17-2f6a8b0d1e34"
}
```

<Warning>
  Create returns **only** `id` and `url` — not the full record. Status, token and amounts require the list endpoint. This is deliberate: the caller's job is to produce a link, and a second round-trip to learn the URL would accomplish nothing.
</Warning>

***

## List payment links

<ParamField path="GET /v1/payment-link" type="endpoint" required>
  Requires the `payment_link:read` scope.
</ParamField>

Returns the payment links belonging to the authenticated user, **newest first, 10 per page**.

<Note>
  A key sees every link its owner can see — including links created from the dashboard or by a sibling key. This is the endpoint to reconcile against.
</Note>

### Query parameters

<ParamField query="status" type="string">
  Filter by status. One of `active`, `inactive`, `completed`. Omit for all.
</ParamField>

<ParamField query="offset" type="integer" default="0">
  Page offset. Follow `nextOffset` from the previous response.
</ParamField>

### Request

<CodeGroup>
  ```bash cURL theme={null}
  curl -sS "https://api.moove.xyz/v1/payment-link?status=active&offset=0" \
    -H "X-API-Key: $MOOVE_API_KEY"
  ```

  ```python Python theme={null}
  res = httpx.get(
      "https://api.moove.xyz/v1/payment-link",
      headers={"X-API-Key": MOOVE_API_KEY},
      params={"status": "active", "offset": 0},
  )
  res.raise_for_status()
  page = res.json()
  ```

  ```typescript TypeScript theme={null}
  const url = new URL('https://api.moove.xyz/v1/payment-link');
  url.searchParams.set('status', 'active');
  url.searchParams.set('offset', '0');

  const res = await fetch(url, {
    headers: { 'X-API-Key': process.env.MOOVE_API_KEY! },
  });
  const page = await res.json();
  ```
</CodeGroup>

### Response

<ResponseField name="data" type="PaymentLinkData[]" required>
  The page of results.

  <Expandable title="PaymentLinkData">
    <ResponseField name="id" type="string" required>The link's id.</ResponseField>
    <ResponseField name="userId" type="string" required>The owner's user id.</ResponseField>
    <ResponseField name="toAmount" type="string" required>Amount requested, in the settlement token.</ResponseField>
    <ResponseField name="destinationAddress" type="string" required>The wallet address the link settles to.</ResponseField>
    <ResponseField name="url" type="string" required>The shareable checkout URL.</ResponseField>
    <ResponseField name="dateCreated" type="string" required>ISO 8601 creation timestamp.</ResponseField>
    <ResponseField name="token" type="TokenData" required>The settlement token, including its chain.</ResponseField>
    <ResponseField name="status" type="string" required>`active`, `completed` or `inactive`.</ResponseField>
    <ResponseField name="description" type="string | null">What the payer sees.</ResponseField>
    <ResponseField name="maxUsage" type="integer | null">Usage cap, if set.</ResponseField>
    <ResponseField name="receivedAmount" type="string | null">Total paid against the link so far.</ResponseField>
    <ResponseField name="expirationDate" type="string | null">Expiry, if set.</ResponseField>
    <ResponseField name="transactionUrl" type="string | null">Always `null` in list responses. Read it from [retrieve](#retrieve-a-payment-link).</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="limit" type="integer" required>Results per page. Fixed at 10.</ResponseField>
<ResponseField name="offset" type="integer" required>The offset this page was fetched at.</ResponseField>
<ResponseField name="nextOffset" type="integer | null">Offset for the next page. `null` at the end.</ResponseField>

```json 200 theme={null}
{
  "data": [
    {
      "id": "0c8f2e5a-4b91-4c3e-9d17-2f6a8b0d1e34",
      "userId": "9b1d4c77-0a3e-4f52-8c61-77ab2d90ef15",
      "toAmount": "49.99",
      "destinationAddress": "0x5f3a...c81b",
      "url": "https://moove.xyz/@yourhandle/pay/0c8f2e5a-4b91-4c3e-9d17-2f6a8b0d1e34",
      "dateCreated": "2026-08-14T09:21:03Z",
      "token": {
        "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
        "decimals": 6,
        "symbol": "USDC",
        "name": "USD Coin",
        "logo": "https://cdn.moove.xyz/assets/images/tokens/usdc.svg",
        "isNative": false,
        "isStablecoin": true,
        "isVerified": true,
        "currencyCode": "USD",
        "commodityCode": null,
        "priceUsd": "1.0001",
        "chain": {
          "id": "8453",
          "name": "Base",
          "symbol": "BAS",
          "chainType": "EVM",
          "logo": "https://…"
        }
      },
      "status": "active",
      "description": "INV-2026-0142",
      "maxUsage": 1,
      "receivedAmount": null,
      "expirationDate": "2026-12-31T23:59:59Z",
      "transactionUrl": null
    }
  ],
  "limit": 10,
  "offset": 0,
  "nextOffset": null
}
```

[Pagination →](/api-reference/pagination)

***

## Retrieve a payment link

<ParamField path="GET /v1/payment-link/{id}" type="endpoint" required>
  **No authentication.** This endpoint is public.
</ParamField>

Returns a single payment link by id, enriched with its owner's public profile.

<Warning>
  **This endpoint is unauthenticated by design, and its response is public.**

  It is what the hosted checkout page calls to render a link: the payer has no Moove account and no API key, so requiring either would make the link unpayable. Anyone holding a link id can read it.

  It returns the owner's public profile — handle, username, profile image — the same fields their [Moove Profile](/brand/moove-profile) already shows. It does **not** expose anything private to the account. Treat a link id as a shareable secret: it is not a credential, but it does identify a request for payment.
</Warning>

### Request

<CodeGroup>
  ```bash cURL theme={null}
  curl -sS "https://api.moove.xyz/v1/payment-link/0c8f2e5a-4b91-4c3e-9d17-2f6a8b0d1e34"
  ```

  ```python Python theme={null}
  res = httpx.get(
      f"https://api.moove.xyz/v1/payment-link/{link_id}",
  )
  res.raise_for_status()
  link = res.json()
  ```

  ```typescript TypeScript theme={null}
  const res = await fetch(
    `https://api.moove.xyz/v1/payment-link/${linkId}`,
  );
  if (!res.ok) throw new Error(`${res.status}`);
  const link = await res.json();
  ```
</CodeGroup>

### Response

Every field of `PaymentLinkData` — as returned by [list](#list-payment-links) — plus:

<ResponseField name="user" type="UserData" required>
  The link owner's public profile.

  <Expandable title="UserData">
    <ResponseField name="id" type="string" required>The owner's user id.</ResponseField>
    <ResponseField name="handle" type="string" required>Their Moove Handle, without the `@`.</ResponseField>
    <ResponseField name="username" type="string" required>Their display name.</ResponseField>
    <ResponseField name="dateCreated" type="string" required>ISO 8601 timestamp the account was created.</ResponseField>
    <ResponseField name="profileImage" type="string | null">Profile image URL.</ResponseField>
    <ResponseField name="bio" type="string | null">Their bio.</ResponseField>
    <ResponseField name="backgroundImage" type="string | null">Profile background image URL.</ResponseField>
    <ResponseField name="backgroundColour" type="string | null">Profile background colour.</ResponseField>
    <ResponseField name="links" type="string[] | null">Links shown on their profile.</ResponseField>
    <ResponseField name="isMooveUser" type="boolean | null">Whether the owner has a Moove account.</ResponseField>
    <ResponseField name="contact" type="object | null">Contact relationship with the caller. Always `null` here — this endpoint has no caller.</ResponseField>
    <ResponseField name="badges" type="object[] | null">Profile badges.</ResponseField>

    <ResponseField name="wallet" type="UserWalletData" required>
      The wallet the link settles to.

      <Expandable title="UserWalletData">
        <ResponseField name="walletAddress" type="string" required>The wallet address.</ResponseField>
        <ResponseField name="token" type="object" required>The settlement token: `id` plus a `token` object shaped like `TokenData`.</ResponseField>
        <ResponseField name="chainType" type="string" required>The chain family, such as `EVM`.</ResponseField>
        <ResponseField name="isDefault" type="boolean" required>Whether this is the owner's default wallet.</ResponseField>
        <ResponseField name="isChainDefault" type="boolean" required>Whether this is the default wallet for its chain.</ResponseField>
        <ResponseField name="provider" type="string | null">The wallet provider, if known.</ResponseField>
      </Expandable>
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="transactionUrl" type="string | null">
  A `PaymentLinkData` field, populated only here. `null` until the link is completed; then the moove.xyz page for its most recent settled payment, `https://moove.xyz/tx/{id}`. Not a block-explorer link.
</ResponseField>

<Note>
  Use this to build your own checkout page against a link you created. For reconciliation across many links, use [list](#list-payment-links) — it is scoped to your key and pages properly.
</Note>

### Errors

| Status | `code`                     | Meaning              |
| ------ | -------------------------- | -------------------- |
| `404`  | `CANNOT_FIND_PAYMENT_LINK` | No link with that id |

***

## Errors

Branch on `code`, never on `message`.

| Status | `code`                           | Meaning                                                              | Retry?                                |
| ------ | -------------------------------- | -------------------------------------------------------------------- | ------------------------------------- |
| `401`  | `UNAUTHENTICATED`                | No `X-API-Key` header                                                | No                                    |
| `401`  | `INVALID_API_KEY`                | Unknown, revoked or deactivated key                                  | No                                    |
| `401`  | `EXPIRED_API_KEY`                | The key is past its expiry                                           | No                                    |
| `403`  | `INSUFFICIENT_API_SCOPE`         | Key lacks the scope this endpoint needs                              | No                                    |
| `404`  | `CANNOT_FIND_PAYMENT_LINK`       | Retrieve: no link with that id. List: the lookup failed unexpectedly | Retrieve: no. List: yes, with backoff |
| `409`  | `PAYMENT_LINK_ACCOUNT_NOT_READY` | Owner has no default wallet, or no handle                            | No                                    |
| `422`  | `INVALID_PAYMENT_LINK_AMOUNT`    | `toAmount` exceeds the settlement token's decimals                   | No                                    |
| `429`  | `429`                            | Rate limited, per key and per IP                                     | Yes, with backoff                     |
| `500`  | `CANNOT_CREATE_PAYMENT_LINK`     | Creation failed                                                      | Yes, with backoff                     |

<Note>
  A malformed body is also a `422`, raised by request validation before it reaches any of the above. `errors` carries one entry per problem: `message` describes the rule that failed and `code` is a validation type such as `missing`, `greater_than` or `json_invalid`. The field is not named.

  ```json 422 theme={null}
  { "errors": [{ "message": "Field required", "code": "missing" }] }
  ```
</Note>

<Warning>
  `INVALID_API_KEY` deliberately covers unknown, revoked **and** deactivated keys with one message. Distinguishing them would tell an attacker whether a guessed key ever existed.
</Warning>

<Warning>
  A `409` means the key owner has no default wallet or no handle. Nothing in your code can fix it — surface it to the user.
</Warning>

[Errors →](/api-reference/errors)

## Get paid without polling

<Card title="Webhooks" icon="webhook" href="/api-reference/moove-receive/webhooks">
  Register an HTTPS endpoint and Moove POSTs a signed body when a link is paid, and again when a payment completes it. Deactivation and expiry send no event. Keep `GET /v1/payment-link` for reconciling those, and anything a delivery missed.
</Card>

## Not in the API

<Note>
  **Deactivating a link is a dashboard action**, not an API call. Manage links at [moove.xyz/dashboard/payment-link](https://www.moove.xyz/dashboard/payment-link).

  **Registering a webhook endpoint is also a console action**, at [moove.xyz/business/manage/webhooks](https://www.moove.xyz/business/manage/webhooks). Both are done by a signed-in person, not by a key.
</Note>

## Next

<CardGroup cols={3}>
  <Card title="Pagination" icon="list-ordered" href="/api-reference/pagination">
    Page through every link.
  </Card>

  <Card title="Moove Agentic Payments" icon="sparkles" href="/transact/moove-agentic-payments">
    Build the integration with a prompt.
  </Card>

  <Card title="Moove Payment Links" icon="link" href="/manage/moove-payment-links">
    The dashboard side.
  </Card>
</CardGroup>
