Agent skill
Copy this into your coding agent (e.g. as .claude/skills/loofta-payment-links/SKILL.md) so it can wire up Loofta payment links and payouts for you.
---
name: loofta-payment-links
description: Integrate Loofta Pay into an app so it can receive payments (payment links) and send payments (payouts). Use when the user asks to "accept payments", "add a pay button", "get paid", "create a checkout link", "pay someone out", "send USDC to an email/handle", "integrate Loofta", "receive payments", or "send payments" — for a web app, backend, or bot that needs to charge a human, pay one out, or both.
---
# Loofta Pay
Create a checkout link that pays a specific Loofta account. The payer needs no Loofta account at
all — checkout defaults to a guest flow (email + any token on any chain, or USDC on Solana from a
connected wallet). If they do have an account, they can sign in on the same page and pay from
their balance instead. Either way the recipient always receives the exact amount — fees are on
the payer.
Keys are **live (mainnet)** — there is no sandbox/test mode yet. Payments made through them move
real money; test with small real amounts.
## 1. Get an API key
The user must generate one themselves at https://pay.loofta.xyz/docs?p=api-key (log in first) —
an agent cannot mint this on their behalf. Ask them to paste the raw key (starts with `lft_live_`)
and store it as an environment variable, e.g. `LOOFTA_API_KEY`. Never hardcode it or expose it to
a browser — it must only be used server-side.
At most one active key at a time — generating a new one revokes whatever key existed before it.
## 2. Create a payment link (server-side, one per order)
One link is one transaction — it can't be paid twice. Generate a fresh link right when the buyer
clicks "Buy now" (server-side, from your own backend); never create one link once and reuse the
same URL across multiple purchases, or every buyer lands on the same payment record.
```bash
curl -X POST https://api.loofta.xyz/payment-links \
-H "Authorization: Bearer $LOOFTA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 25,
"message": "Invoice #1042",
"webhook_url": "https://your-server.com/webhooks/loofta",
"return_url": "https://your-store.com/order-confirmed"
}'
```
Response:
```json
{
"short_id": "aB3xK9pQ",
"url": "https://pay.loofta.xyz/checkout/aB3xK9pQ",
"amount": 25,
"message": "Invoice #1042",
"webhook_secret": "a1b2c3..."
}
```
Fields:
- `amount` (required): USD, number, $10,000 maximum. $1 minimum for a plain public transfer (the
default), $5 minimum if `is_private` is `true`.
- `message` (optional): shown to the payer, 1000 chars max.
- `is_private` (optional): defaults to `false` (a plain public transfer, no privacy fee). Pass
`true` to require the payer to pay privately instead — shields the amount and that it went to
your wallet on-chain, $5 minimum.
- `webhook_url` (optional): POSTed `{ event: "payment.succeeded", short_id, amount, message, paid_at }`
the moment the payer's leg lands, signed with header `X-Loofta-Signature: sha256=<hmac>` —
verify by recomputing `HMAC-SHA256(rawBody, webhook_secret)`. `webhook_secret` is only in the
create response, shown once — store it, it can't be fetched again.
- `return_url` (optional): where the checkout page sends the buyer once paid. Baked directly into
the returned `url` as `?callback=...`, so it works no matter how the link is opened — a plain
link, a QR code, or a button — not just through a JS component with a live window handle. The
checkout page appends `?payment=success&short_id=...` and navigates there once settled.
- `url`: a minimal, unbranded checkout page — safe to link to directly or open in a popup/iframe.
No Loofta navigation chrome.
## 2b. Finding out when it's paid — poll or webhook, pick one
Save `short_id` from the create response against your own order record right when you get it, in
step 2 — that response is the only moment you'll ever see it. Use that saved value below.
Poll (no public endpoint needed on your side):
```bash
curl https://api.loofta.xyz/payment-links/aB3xK9pQ -H "Authorization: Bearer $LOOFTA_API_KEY"
# -> { "short_id": "...", "status": "OPEN" | "PRIVATE_TRANSFER_PENDING" | "SUCCESS" | ...,
# "paid": true | false, "amount": 25, "paid_at": null | "..." }
```
**Check `paid`, not `status`.** `status` is Loofta's own internal state — it goes through
`PRIVATE_TRANSFER_PENDING` once the customer's payment lands, before eventually reaching
`SUCCESS` once the recipient (the API key owner, in their own Loofta account) claims/sweeps the
funds into their wallet — a separate, later step that has nothing to do with whether the order
should be treated as paid. `paid` is `true` for both, and is what `webhook_url` also fires on.
Only ever returns links owned by this API key — someone else's short_id 404s, it doesn't leak.
Otherwise use `webhook_url` from step 2 if you'd rather be pushed to than poll.
## 3. TypeScript / JavaScript
```bash
npm install @loofta/pay-sdk
```
```ts
import { LooftaPayClient } from "@loofta/pay-sdk";
const client = new LooftaPayClient({ apiKey: process.env.LOOFTA_API_KEY! });
const link = await client.createPaymentLink({
amount: 25,
message: "Invoice #1042",
returnUrl: "https://your-store.com/order-confirmed",
});
// link.url -> hand this to the user, redirect them to it, or open it in a popup
const status = await client.getPaymentLinkStatus(link.shortId);
// status.paid -> true | false — this is the field to check, not status.status
```
## 4. Embedding a button (optional)
`@loofta/pay-sdk` is server-side only (it never exposes your API key to the browser), so it
doesn't ship a React component. Copy `components/sdk/PayButton.tsx` from the Loofta repo into
your project, or write your own equivalent — the important part is generating a fresh link
server-side on every click, not embedding one static URL:
```tsx
<PayButton
onGenerateLink={async () => {
const res = await fetch("/api/create-checkout", { method: "POST" }); // your backend, calls createPaymentLink
const { url } = await res.json();
return url;
}}
buttonText="Buy now"
onSuccess={(paymentId) => console.log("Paid:", paymentId)}
/>
```
A plain link/button pointing at a server-generated `link.url` also works fine if you don't need
a full component:
```tsx
<a href={link.url} target="_blank" rel="noopener noreferrer">
Pay ${'$'}{link.amount}
</a>
```
See https://pay.loofta.xyz/docs?p=try-it for this pattern wired up end to end.
## 5. Send payments (pay someone out)
The reverse of a payment link: `POST /payouts` pays a recipient — by email, username, or social
handle — from the API key owner's own account, instead of collecting from someone else.
Self-custody: it returns an **unsigned** transaction, never moves funds itself. Only the caller's
own `sender_wallet` can authorize it, by signing that transaction and broadcasting it.
```bash
curl -X POST https://api.loofta.xyz/payouts \
-H "Authorization: Bearer $LOOFTA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 25,
"recipient_type": "email",
"recipient_identifier": "alice@example.com",
"sender_wallet": "5dCT...BKuq",
"message": "August payout"
}'
```
Response:
```json
{
"payment_id": "b3f1...e02a",
"short_id": "aB3xK9pQ",
"claim_link": "https://pay.loofta.xyz/c/aB3xK9pQ",
"unsigned_transaction": "AQAAAAAAAAAAAAAAAAAAAAAA...",
"transaction_version": "legacy",
"is_private": false
}
```
Fields:
- `amount` (required): USD, $10,000 maximum, $1 minimum ($5 if `is_private` is `true`).
- `recipient_type` (required): `email`, `username`, `twitter`, `discord`, `github`, or `telegram`.
- `recipient_identifier` (required): the email/username/handle matching `recipient_type`.
- `sender_wallet` (required): the caller's own Solana wallet public key — pays gas and signs the
returned transaction. Loofta never holds this wallet's key.
- `message` (optional): shown to the recipient on their claim page, 500 chars max.
- `is_private` (optional): defaults to `false`. `true` shields the amount and sender wallet
on-chain — a MagicBlock private transfer, ~10 bps + $0.20 flat gas on top of `amount`.
Sign and broadcast `unsigned_transaction` yourself — `transaction_version` tells you whether to
deserialize it as a legacy `Transaction` or `VersionedTransaction`:
```ts
import { Connection, Transaction, VersionedTransaction, Keypair } from "@solana/web3.js";
const connection = new Connection("https://api.mainnet-beta.solana.com");
const senderKeypair = Keypair.fromSecretKey(/* your own key, never sent to Loofta */);
const raw = Buffer.from(unsigned_transaction, "base64");
const tx = transaction_version === "v0"
? VersionedTransaction.deserialize(raw)
: Transaction.from(raw);
tx.sign(senderKeypair); // VersionedTransaction: tx.sign([senderKeypair])
const signature = await connection.sendRawTransaction(tx.serialize());
```
The recipient sees the payout waiting at `claim_link` the moment the transaction lands — same
claim/burner mechanics as every other Loofta payment. An unregistered recipient claims by signing
in with the same email/handle it was sent to.
## Constraints to respect
- One payment link = one fixed amount, one transaction. There is no "open amount" / pay-what-you-want
link, and a link cannot be reused across multiple orders.
- Links are public (not private) by default. `is_private: true` requires the payer to pay
privately and adds a fee to their total — you still net exactly `amount` either way. Don't
assume privacy or add your own fee expecting to net more.
- `/payouts` is single-recipient only — there is no batch/multi-recipient payout endpoint.
- `/payouts` never signs or broadcasts anything itself — you must sign `unsigned_transaction`
with `sender_wallet`'s own key and submit it. Don't expect the funds to move just from calling
the endpoint.
- No sandbox/test mode — every key is live. Don't build a "test mode" flag expecting one to exist.
- Full reference: https://pay.loofta.xyz/docs