Skip to content
inlet

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://api.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)

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:

window.location.href = payment.checkout_url;

or open the checkout in a modal on your page:

<script src="https://api.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:

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"].

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.

EventMeaningWhat to do
payment.detectedMoney seen on the network, not yet confirmedShow "payment received, confirming"
payment.confirmingConfirmations are countingNothing
payment.paidConfirmed and enoughFulfil the order
payment.underpaidConfirmed but shortAccept or wait for the rest
payment.overpaidMore than asked arrivedOptional: refund the difference
payment.latePaid after the window closedAccept or refund
payment.expiredNothing arrived in timeRelease the order
payment.wrong_assetA different coin arrived, worth at least that coin's minimumContact the customer
payment.reorgedA paid payment's transaction left the chainPause fulfilment until paid again
payment.refundedA refund was sentNothing
payment.cancelledYou cancelled itNothing
  • 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:

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

6. Other actions

CallDoes
POST /v1/payments/:id/acceptMark an underpaid or late payment paid
POST /v1/payments/:id/cancelCancel 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/currenciesNetworks and coins available now
GET /v1/estimate?price_amount=4999&price_currency=USD&token=base:usdcPreview an amount

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

StatusCodeMeaning
401unauthorizedMissing, wrong or revoked key
403wrong_modeA live key on the test API or the reverse
409idempotency_conflictSame Idempotency-Key, different body
409too_many_attemptsAlready 3 coin choices on this payment
422amount_too_smallBelow the minimum for that coin
503price_unavailableNo current price for that coin; retry shortly or pick another
503chain_pausedThat network is paused; pick another

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:

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

{
  "id": "pay_…",
  "status": "paid",
  "price_amount": 4999,
  "price_currency": "USD",
  "order_id": "order-1001",
  "checkout_url": "https://api.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.

Get your test keys.

Join our first merchants. We'll set you up with test keys.

Test mode first: nothing real moves until you switch to live.