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_amountis in US cents:4999is $49.99.order_idis your own reference. Store the returnedid(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 ascloudflaredorngrokfor 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 409idempotency_conflict. Without a key,order_idis not unique: each call creates a new payment. - Optional:
chainandtoken(for example"base"and"base:usdc", seeGET /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_urloverrides your account's default webhook URL for this payment. With neither set, no webhooks are sent and you must pollGET /v1/payments/:id.success_urlis where the checkout sends the customer after paying;cancel_urlis 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 acheckout_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.
| 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 getpayment.paid. If it arrives later, you getpayment.late, and Inlet accepts it by itself when it comes within 24 hours and the coin's price moved 3 % or less (you then getpayment.paid). An underpaid payment does not expire on its own: it waits for you to accept (step 6, sendspayment.paid) or refund it. payment.overpaidcomes in addition topayment.paid; fulfil on the paid event.- Final states:
paid,expired,refunded,cancelled. Alatepayment 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
| 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:
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
- Switch to your
inl_live_key and the live API URL. - Make one small real payment end to end.
- 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.

