# Payments API

A read API over the payments made through your integration. Same payment shape as [payment notifications](/integrations/notifications), by pull.

Base URL: `https://api.yodl.me`

## Endpoints

```text
GET /api/v1/integration/payments
GET /api/v1/integration/payments/{txHash}
GET /api/v1/integration/wallet/{address}/payments
GET /api/v1/integration/wallet/{address}/payments/{txHash}
```

| Endpoint | Returns |
| --- | --- |
| `/payments` | Every payment made through your integration, newest first. Paginated |
| `/payments/{txHash}` | One payment, by **destination** transaction hash |
| `/wallet/{address}/payments` | The payments of one wallet, made through your integration. Paginated |
| `/wallet/{address}/payments/{txHash}` | One payment, scoped to both the wallet and your integration |

`{txHash}` is always the **destination** transaction hash — the same `txHash` a notification carries. Source-chain hashes are not accepted as a lookup key; use `source.txHash` for reconciliation only.

For the wallet endpoints, `address` matches **either side** of the payment, so a wallet's outgoing and incoming payments both appear. Matching is case-insensitive.

## Authentication

HTTP Basic, on every request.

| | |
| --- | --- |
| Username | Your integration address |
| Password | Your **API secret** |

:::code-group
```bash [curl]
curl -u "0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1:$YODL_API_SECRET" \
  https://api.yodl.me/api/v1/integration/payments
```

```ts [TypeScript]
const credentials = Buffer.from(
  `${process.env.YODL_INTEGRATION_ADDRESS}:${process.env.YODL_API_SECRET}`,
).toString('base64');

const res = await fetch('https://api.yodl.me/api/v1/integration/payments', {
  headers: { Authorization: `Basic ${credentials}` },
});

if (!res.ok) throw new Error(`Yodl API ${res.status}`);
const page = await res.json();
```

```python [Python]
import os, requests

res = requests.get(
    "https://api.yodl.me/api/v1/integration/payments",
    auth=(os.environ["YODL_INTEGRATION_ADDRESS"], os.environ["YODL_API_SECRET"]),
    timeout=10,
)
res.raise_for_status()
page = res.json()
```
:::

Send it as a real `Authorization: Basic <base64>` header — the raw `address:secret` form is rejected.

:::warning
The API secret is **not** the webhook signing secret, and it travels on every request — see [the two secrets](/integrations/overview#the-two-secrets). Keep it server-side. Anything holding it can read every payment made through your integration.
:::

Every authentication failure is the same `401` with `{"error": "Unauthorized"}`. An unknown address, a wrong secret, a disabled integration, and an integration with no API secret issued are indistinguishable to the caller.

## You only ever see your own payments

Every query filters on your integration address. The wallet and transaction-hash filters only narrow that further.

* `/wallet/{address}/payments` returns what that wallet paid **through your integration**, not everything that wallet has ever done on Yodl.
* Payments indexed before integration addresses existed were not backfilled, and are invisible to every integration.

## Response

`/payments` and `/wallet/{address}/payments` return a page:

```json
{
  "payments": [
    {
      "integrationAddress": "0x5f2f6f387f49f7cfe0f6f302ff7f6ba6748b6ff1",
      "chainId": "8453",
      "txHash": "0x82aa1e4f552e56f68c92d3cf3fdccbcafb51905c356ba163a4ae84631f416b96",
      "version": 3,
      "state": "success",
      "recipient": { "title": "Acme GmbH", "subtitle": "Vietcombank 0123" },
      "invoice": {
        "amount": "12345",
        "currency": "VND",
        "settlementAmount": "0.48",
        "settlementCurrency": "USD"
      },
      "sender": "0x1111111111111111111111111111111111111111",
      "receiver": "0x2222222222222222222222222222222222222222",
      "memo": "invoice-42",
      "blockTimestamp": "2026-07-01T10:11:12.000Z",
      "source": {
        "chainId": "42161",
        "txHash": "0x9c3b1a0e1b0f9a5d4c7e2f8b6a1d0c3e5f7a9b1d3c5e7f9a1b3d5c7e9f1a3b5d"
      },
      "tokenIn": { "address": "0x3333333333333333333333333333333333333333", "symbol": "WETH", "amount": "0.05" },
      "tokenOut": {
        "address": "0x4444444444444444444444444444444444444444",
        "symbol": "USDT",
        "amountGross": "1.00",
        "amountNet": "0.99"
      }
    }
  ],
  "nextCursor": "MTc1MTM2..."
}
```

The single-payment endpoints return one payment object in exactly that shape, **not** wrapped in an array and not wrapped in a page.

### Fields

The first seven fields are the notification payload, field for field — see the [notification payload table](/integrations/notifications#payload) for `state`, `recipient`, and `invoice`. The rest is the on-chain context a read API needs and a push does not.

| Field | Type | Notes |
| --- | --- | --- |
| `integrationAddress` | string | Your integration address, lowercased |
| `chainId` | string | Destination chain ID |
| `txHash` | string | Destination transaction hash, lowercased |
| `version` | number | Payment state version |
| `state` | string | null | `pending`, `success`, `failure`, `refund_pending`, `refund_success`, `refund_failure` |
| `recipient` | object | null | `title`, `subtitle` |
| `invoice` | object | null | `amount`, `currency`, `settlementAmount`, `settlementCurrency`. All four or `null` |
| `sender` | string | Payer address, lowercased |
| `receiver` | string | Payee address, lowercased |
| `memo` | string | The payment memo, verbatim |
| `blockTimestamp` | string | ISO-8601 UTC |
| `source` | object | `chainId` and `txHash` of the source-chain transaction, hash lowercased |
| `tokenIn` | object | `address` (lowercased), `symbol` (may be `null`), `amount` |
| `tokenOut` | object | `address` (lowercased), `symbol` (may be `null`), `amountGross`, `amountNet` |

Every identifier — addresses, transaction hashes, token addresses — is lowercased on the way out, so you have one canonical form to compare against. Symbols, memos, and merchant text are returned verbatim: they are content, not identifiers. All amounts are strings.

The field set is deliberately narrow. It is the on-chain facts of the payment plus the merchant data attached to it, and nothing from Yodl's own domain: no payment ID, no Yodl account or wallet identifiers, no linkage between a wallet and an account, no points, no community, no processor identity or processor memo, no solver, no raw decoder payload, and no indexing timestamps.

Responses are sent with `Cache-Control: private, no-store`. Do not put them in a shared cache.

## Pagination

Both list endpoints take:

| Parameter | Default | Notes |
| --- | --- | --- |
| `limit` | `50` | Rows per page. Must be an integer in `1`–`200` |
| `cursor` | — | The `nextCursor` from the previous page |

A `limit` outside `1`–`200` is a `400`, not a silent cap. That is deliberate: silently returning 200 rows to a caller that asked for 10,000 lets it believe it has the whole set.

Paging is keyset, ordered by `(blockTimestamp, chainId, txHash)` descending, so a page cannot shift under you while new payments arrive.

`nextCursor` is **opaque** — treat it as a token and do not parse or construct it. It is `null` on the last page.

:::code-group
```bash [curl]
curl -u "$YODL_INTEGRATION_ADDRESS:$YODL_API_SECRET" \
  "https://api.yodl.me/api/v1/integration/payments?limit=100&cursor=$CURSOR"
```

```ts [Reconciliation sweep]
const BASE = 'https://api.yodl.me/api/v1/integration/payments';
const auth = Buffer.from(
  `${process.env.YODL_INTEGRATION_ADDRESS}:${process.env.YODL_API_SECRET}`,
).toString('base64');

// Page newest-first and stop at the first payment already stored.
let cursor: string | null = null;

do {
  const url = new URL(BASE);
  url.searchParams.set('limit', '100');
  if (cursor) url.searchParams.set('cursor', cursor);

  const res = await fetch(url, { headers: { Authorization: `Basic ${auth}` } });
  if (!res.ok) throw new Error(`Yodl API ${res.status}: ${await res.text()}`);

  const page = await res.json();

  for (const payment of page.payments) {
    if (await alreadyStored(payment.chainId, payment.txHash)) return; // caught up
    await store(payment);
  }

  cursor = page.nextCursor; // opaque — never parse or construct it
} while (cursor);
```
:::

For reconciliation, page newest-first until you reach a payment you have already stored, then stop.

:::tip
Reconcile on `chainId` + `txHash` rather than `txHash` alone. The same hash can be indexed on more than one destination chain, which is what the `409` on the single-payment endpoint is telling you.
:::

## Errors

| Status | When |
| --- | --- |
| `400` | `limit` out of range or not an integer, unparseable `cursor`, malformed `txHash` (not a 32-byte hex string), or malformed `address` (not a 20-byte hex string) |
| `401` | Missing or invalid credentials — unknown address, wrong API secret, disabled integration, or no API secret issued |
| `404` | No such payment for your integration |
| `409` | The same transaction hash is indexed on more than one destination chain, so the reference is ambiguous. Narrow it with the wallet-scoped endpoint |

Error bodies are `{"error": "..."}`. On a `400` from `limit`, the body also carries `maxLimit`.

Note that parameter validation happens *after* authentication: an unauthenticated caller always gets a `401`, never a `400`, whatever it sends.

## Same caveat as notifications

These are payments that **claimed** your integration address on chain, which is not the same as payments your app originated — see [a declared address is not an authenticated one](/integrations/integration-address#a-declared-address-is-not-an-authenticated-one).
