# Integrate Inlet

Inlet takes crypto payments for you. You create a payment for a USD amount; your customer pays it
in the coin and network they choose; Inlet watches the chain and tells your server when it is paid.

This page is everything you need. It is written so a coding agent can follow it top to bottom.

- API host: `https://api.inlet.cash`. Every path on this page is written in full, starting with `/v1`.
- Checkout pages: `https://pay.inlet.cash/p/<payment id>`
- Machine-readable reference: `https://api.inlet.cash/v1/openapi.json`

The test API and the live API are separate hosts. The URLs on this page are the ones for the API
you fetched it from; Inlet sends you both pairs with your keys.

## 1. Get your keys

Inlet gives you, once:

- an **API key**: `inl_test_…` for test mode, `inl_live_…` for real money. Test keys work only on
  the test API, live keys only on the live API.
- a **webhook secret**: `whsec_…`, used to check that webhooks come from Inlet.

Keep both on your server. Never put them in a browser or a mobile app.

## 2. Create a payment (your server)

```sh
curl -s https://api.inlet.cash/v1/payments \
  -H "Authorization: Bearer $INLET_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1001" \
  -d '{"price_amount": 4999, "price_currency": "USD", "order_id": "order-1001",
       "description": "Pro plan, 1 month",
       "success_url": "https://example.com/thanks",
       "webhook_url": "https://example.com/inlet/webhook"}'
```

- `price_amount` is in US cents: `4999` is $49.99.
- `order_id` is your own reference. Store the returned `id` (`pay_…`) on your order.
- URLs must be `https://` on the live API. With a test key, `http://` is accepted so you can
  develop locally; webhooks still need an address the test API can reach (use a tunnel such as
  `cloudflared` or `ngrok` for a laptop).
- Send an `Idempotency-Key` (your order id is a good one). Retrying with the same key and the same
  body returns the same payment (HTTP 200 instead of 201). The same key with a different body is
  refused with 409 `idempotency_conflict`. Without a key, `order_id` is not unique: each call
  creates a new payment.
- Optional: `chain` and `token` (for example `"base"` and `"base:usdc"`, see `GET /v1/currencies`)
  skip the coin picker and issue the address in this call; `metadata` (any JSON object up to 4 kB)
  comes back on the payment and in every webhook.
- `webhook_url` overrides your account's default webhook URL for this payment. With neither set,
  no webhooks are sent and you must poll `GET /v1/payments/:id`.
- `success_url` is where the checkout sends the customer after paying; `cancel_url` is where its
  "back" button goes if you cancel the payment. Both are for the customer's browser only.
- The response is the payment object (below) with `status: "waiting"` and a `checkout_url`.

## 3. Send your customer to pay (your front end)

Either redirect:

```js
window.location.href = payment.checkout_url;
```

or open the checkout in a modal on your page:

```html
<script src="https://pay.inlet.cash/inlet.js"></script>
<script>
  Inlet.open({ paymentId: "pay_…" }).then((result) => {
    // result.status is "paid", "expired", "cancelled" or "closed" (the customer closed the modal).
    // Do NOT fulfil the order here. Wait for the webhook (step 4); the browser can be faked.
  });
</script>
```

The customer picks a network and a coin, gets a fresh address and an exact amount, and has
10 minutes to send it. Inlet checks the address every 10 seconds.

**Timing.** The payment link lives 24 hours (`expires_at`). Each coin choice holds its price for
10 minutes; if the customer does not send in time, they can choose again (up to 3 choices). Money
that arrives after a choice ran out is still found and reported (`payment.late`). When the 24 hours
pass with nothing paid, you get `payment.expired`.

## 4. Handle the webhook (your server)

Inlet POSTs JSON to your `webhook_url` on every change. Check the signature first, using the raw
request body exactly as received. The check, with no dependencies:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(raw, header, secret) {
  if (typeof header !== "string") return false;
  const parts = {};
  for (const kv of header.split(",")) {
    const i = kv.indexOf("=");
    if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
  }
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300 || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
```

Then handle the event. The example uses Express; with plain `node:http`, collect the request
body as a string yourself and read the header as `req.headers["inlet-signature"]`.

```js
app.post("/inlet/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verify(raw, req.get("Inlet-Signature"), process.env.INLET_WEBHOOK_SECRET)) {
    return res.status(400).end();
  }
  const event = JSON.parse(raw);
  // Deduplicate: the same event can arrive more than once. Record event.id in the same
  // transaction as the effect (marking the order paid), so a crash cannot lose or repeat it.
  if (alreadyHandled(event.id)) return res.status(200).end();
  switch (event.type) {
    case "payment.paid":      fulfil(event.data.order_id); break;
    case "payment.underpaid": /* decide: accept (step 6) or wait for the rest */ break;
    case "payment.late":      /* paid after the window: accept or refund */ break;
    case "payment.expired":   releaseOrder(event.data.order_id); break;
  }
  res.status(200).end();
});
```

The webhook body is the event: `{"id": "evt_…", "type": "payment.paid", "created_at": "…", "data": <the
payment object below>}`. Headers: `Inlet-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of
"<t>.<raw body>">`, `Inlet-Event-Id`, `Inlet-Event-Type`. A Node SDK with `verifyWebhook` exists
(`@oneobit/inlet-node`) but is not on npm yet; use the function above.

Answer `2xx` within 10 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes,
2 hours, 6 hours and 12 hours. Events for one payment arrive in order.

| Event | Meaning | What to do |
|---|---|---|
| `payment.detected` | Money seen on the network, not yet confirmed | Show "payment received, confirming" |
| `payment.confirming` | Confirmations are counting | Nothing |
| `payment.paid` | Confirmed and enough | **Fulfil the order** |
| `payment.underpaid` | Confirmed but short | Accept or wait for the rest |
| `payment.overpaid` | More than asked arrived | Optional: refund the difference |
| `payment.late` | Paid after the window closed | Accept or refund |
| `payment.expired` | Nothing arrived in time | Release the order |
| `payment.wrong_asset` | A different coin arrived, worth at least that coin's minimum | Contact the customer |
| `payment.reorged` | A paid payment's transaction left the chain | Pause fulfilment until paid again |
| `payment.refunded` | A refund was sent | Nothing |
| `payment.cancelled` | You cancelled it | Nothing |

- After `payment.underpaid`, if the rest arrives within the coin's 10 minutes you get
  `payment.paid`. If it arrives later, you get `payment.late`, and Inlet accepts it by itself when
  it comes within 24 hours and the coin's price moved 3 % or less (you then get `payment.paid`).
  An underpaid payment does not expire on its own: it waits for you to accept (step 6, sends
  `payment.paid`) or refund it.
- `payment.overpaid` comes **in addition to** `payment.paid`; fulfil on the paid event.
- Final states: `paid`, `expired`, `refunded`, `cancelled`. A `late` payment is not final until you
  accept or refund it.

## 5. When in doubt, ask Inlet

The webhook is a notification. The payment itself is the truth:

```sh
curl -s https://api.inlet.cash/v1/payments/$PAYMENT_ID -H "Authorization: Bearer $INLET_API_KEY"
```

## 6. Other actions

| Call | Does |
|---|---|
| `POST /v1/payments/:id/accept` | Mark an underpaid or late payment paid |
| `POST /v1/payments/:id/cancel` | Cancel before any money is seen |
| `POST /v1/payments/:id/refund` with `{"destination": "<address>"}` | Ask for a refund |
| `GET /v1/payments?order_id=…` | Find payments by your order id |
| `GET /v1/currencies` | Networks and coins available now |
| `GET /v1/estimate?price_amount=4999&price_currency=USD&token=base:usdc` | Preview an amount |

Errors look like `{"error": {"code": "…", "message": "…", "param": "…"}}`. The API allows 60
requests a minute per key. Common codes:

| Status | Code | Meaning |
|---|---|---|
| 401 | `unauthorized` | Missing, wrong or revoked key |
| 403 | `wrong_mode` | A live key on the test API or the reverse |
| 409 | `idempotency_conflict` | Same `Idempotency-Key`, different body |
| 409 | `too_many_attempts` | Already 3 coin choices on this payment |
| 422 | `amount_too_small` | Below the minimum for that coin |
| 503 | `price_unavailable` | No current price for that coin; retry shortly or pick another |
| 503 | `chain_paused` | That network is paused; pick another |

### Payment links: take money with no code

`POST https://api.inlet.cash/v1/links` with `{ "kind": "fixed", "title": "Pro plan", "amount_usd_cents": 4999 }`
returns a link whose `url` is a public page: the payer opens it, picks a coin, pays. `"kind": "open"`
lets them type the amount (invoices); `"kind": "tip"` shows preset buttons; `"kind": "pos"` is a
point-of-sale terminal for your staff (keypad, QR, PAID on screen). Every payment made through
a link arrives in your webhooks like any other, with `metadata.link_id`. The dashboard makes them too.

### Subscriptions and mass payouts

`POST https://api.inlet.cash/v1/subscriptions` schedules hosted invoices for a customer (each period: a
payment, an email with its link, `metadata.subscription_id` on the webhook). `POST
https://api.inlet.cash/v1/disbursements` pays up to 500 addresses from your balance once your owners approve
it on the dashboard. On USDC the checkout also offers paying by signature alone: Inlet pays the gas.

## 7. Test without coins

With a test key, make a payment reach any outcome:

```sh
curl -s https://api.inlet.cash/v1/test/payments/$PAYMENT_ID/simulate \
  -H "Authorization: Bearer $INLET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"outcome": "paid"}'
```

`outcome` is `paid`, `underpaid`, `expired`, `late` or `wrong_asset`. It runs the real logic and
sends the real, signed webhooks. It jumps straight to the outcome, so you get that outcome's event
(for example `payment.paid`) without the `payment.detected` and `payment.confirming` a real
payment sends first. If the customer has not picked a coin yet, simulate picks a stablecoin for them
(except `expired`, which needs none). A checkout page left open on the coin picker does not
change when you simulate; reload it to see the outcome. It does not exist on the live API.

## 8. Go live

1. Switch to your `inl_live_` key and the live API URL.
2. Make one small real payment end to end.
3. Check that your webhook marked the order paid.

## The payment object

```json
{
  "id": "pay_…",
  "status": "paid",
  "price_amount": 4999,
  "price_currency": "USD",
  "order_id": "order-1001",
  "checkout_url": "https://pay.inlet.cash/p/pay_…",
  "expires_at": "…",
  "paid_at": "…",
  "attempts": [
    {
      "chain": "base",
      "token": "base:usdc",
      "address": "0x…",
      "pay_amount": "49990000",
      "pay_amount_display": "49.99",
      "status": "paid",
      "deposits": [{ "tx_hash": "0x…", "amount": "49990000", "status": "confirmed", "explorer_url": "…" }]
    }
  ],
  "metadata": {}
}
```

Token amounts are strings of the token's smallest unit; `*_display` fields are for people.
A token id is `<chain>:<symbol>` in lower case (`base:usdc`, `solana:sol`); to show a coin to a
person, take the part after the colon in upper case, or the names from `GET /v1/currencies`.
`status` is one of `waiting`, `detected`, `confirming`, `paid`, `underpaid`, `expired`, `late`,
`refunded`, `cancelled`.
