Accept payments with Open Payments
In this tutorial your app asks a Dassie node to be paid, watches the
money arrive, and then pays from a node with its owner's consent. You
use the reference client, @interledger/open-payments,
the same one Dassie's own tests run.
On this page
What you need
-
A Dassie node with a hostname on the testnet, that you own, with
test money in its XRP hub. The
Quick Start sets one up, and
Add and fund a ledger puts money
in. This page uses
node.example.comfor its name. -
Node.js, and the client library:
$npm install @interledger/open-payments -
A web server on the public internet where your app can put two small
files. The node fetches your app's key from it. This page uses
app.example.com.
If you would rather not touch the testnet yet, the
local dev network runs the same
flow on your machine, and its nodes also reach a server on
localhost, over HTTPS with a certificate from the dev
network's mkcert CA.
Give your app a key
Open Payments knows an app by a wallet address of its own, whose key set holds the app's public key. The app signs every request with the private half, and the node checks each signature against that key.
-
Make an Ed25519 key pair, once, and keep the private key secret:
make-key.mjs import { generateKeyPairSync } from "node:crypto"; import { writeFileSync } from "node:fs"; const { publicKey, privateKey } = generateKeyPairSync("ed25519"); writeFileSync("key.pem", privateKey.export({ format: "pem", type: "pkcs8" }), { mode: 0o600, }); const jwk = { ...publicKey.export({ format: "jwk" }), kid: "my-app-key", alg: "EdDSA", use: "sig", }; console.log(JSON.stringify({ keys: [jwk] }, null, 2));$node make-key.mjs > jwks.json -
Serve your app's wallet address. The node shows its
publicNameto the owner when it asks for consent:https://app.example.com/client { "id": "https://app.example.com/client", "publicName": "My App", "assetCode": "XRP", "assetScale": 9, "authServer": "https://app.example.com/auth", "resourceServer": "https://app.example.com" } -
Serve
jwks.jsonbeside it, athttps://app.example.com/client/jwks.json. The node fetches the key from there, over the public internet: it never fetches from a private address, so a server on your laptop does not work against a node on the testnet.
Now make a client that signs as your app:
import { createHash, randomBytes } from "node:crypto";
import {
createAuthenticatedClient,
isFinalizedGrantWithAccessToken,
isPendingGrant,
} from "@interledger/open-payments";
const client = await createAuthenticatedClient({
walletAddressUrl: "https://app.example.com/client",
privateKey: "key.pem",
keyId: "my-app-key",
});
The snippets below go on in the same file.
Read the node's wallet address
A node has one wallet address per asset:
https://node.example.com/pay/xrp for XRP and
https://node.example.com/pay/dsusd for DSUSD, the
testnet's dollar. The payment pointer $node.example.com
means https://node.example.com/.well-known/pay, the asset
the owner picked as the default.
const wallet = await client.walletAddress.get({
url: "https://node.example.com/pay/xrp",
});
console.log(wallet);
{
id: 'https://node.example.com/pay/xrp',
assetCode: 'XRP',
assetScale: 9,
authServer: 'https://node.example.com/gnap',
resourceServer: 'https://node.example.com',
dassie: { nodeId: 'hauavng4okp6d55tghlsiqvnn6j5v6mpmktgnfq' }
}
Every asset counts in billionths: an assetScale of 9
means that 1 XRP is 1000000000. The
dassie field names the node, for clients that check its
signatures; other clients ignore it.
Signed responses and names
shows how to use it. A publicName appears too once the
owner gives the node an alias.
A node without a hostname, such as the
desktop app, has its wallet
addresses at https://<node ID>.dassiepay.net/pay/xrp.
The dassiepay.net front door passes your requests there
on to the node, over a connection the node keeps to it as its relay.
A node without a hostname keeps that connection by default, so asking
to be paid, below, works there too, while the node is connected. If it
is not, the front door answers 503.
Ask to be paid
To create an incoming payment, your app needs a grant from the node's authorization server. A grant to create and read incoming payments moves no money, so the node gives it at once, without asking the owner.
const incomingGrant = await client.grant.request(
{ url: wallet.authServer },
{
access_token: {
access: [{ type: "incoming-payment", actions: ["create", "read"] }],
},
},
);
if (!isFinalizedGrantWithAccessToken(incomingGrant)) {
throw new Error("expected the grant at once");
}
const incomingToken = incomingGrant.access_token.value;
Now ask for 2 XRP. The amount must be in the wallet address's own asset and scale:
const incoming = await client.incomingPayment.create(
{ url: wallet.resourceServer, accessToken: incomingToken },
{
walletAddress: wallet.id,
incomingAmount: {
value: "2000000000",
assetCode: wallet.assetCode,
assetScale: wallet.assetScale,
},
metadata: { description: "Order 1042" },
},
);
console.log(incoming.id);
https://node.example.com/incoming-payments/…
The URL in incoming.id is a payment request. Hand it to
whoever pays you. The incoming payment also carries its STREAM
credentials, in incoming.methods: an
ilpAddress and a sharedSecret. Each read of
the payment hands out fresh ones, so every payer has a connection of
its own.
It stays open for a day unless you say otherwise with
expiresAt, at most seven days ahead. The money lands in
the node's account in the settlement hub of that asset, where
everything the node receives arrives.
Watch the money arrive
To try it, pay the request yourself: in the wallet of any node on the testnet, yours too, open Send and paste the URL. Then watch the payment:
let payment = incoming;
while (!payment.completed && new Date(payment.expiresAt) > new Date()) {
await new Promise((resolve) => setTimeout(resolve, 2000));
payment = await client.incomingPayment.get({
url: incoming.id,
accessToken: incomingToken,
});
}
console.log(payment.completed, payment.receivedAmount);
true { value: '2000000000', assetCode: 'XRP', assetScale: 9 }
completed turns true once what arrived is within a tiny
tolerance of what you asked for, a ten-thousandth of a unit at most.
So receivedAmount can be a hair under or over
incomingAmount; it is always what really arrived.
Pay from a node, with its owner's consent
Paying moves the owner's money, so the owner decides. Your app asks for an outgoing-payment grant with a limit, the node holds it until the owner answers, and the owner approves or denies it in their wallet.
Consent through a front door is not there yet. So a node without a
hostname, such as the desktop app, takes payments at
<node ID>.dassiepay.net, but it cannot grant your
app outgoing payments: use a node with a hostname for this part.
What's next
-
Ask for the grant. This one lets the app spend up to 5 XRP a day from the node's XRP wallet address, fees and exchange included, and make quotes:
const payer = await client.walletAddress.get({ url: "https://node.example.com/pay/xrp", }); const clientNonce = randomBytes(16).toString("base64url"); // A minute back, so that the node's clock is surely in the first window. const start = new Date(Date.now() - 60_000).toISOString(); const pending = await client.grant.request( { url: payer.authServer }, { access_token: { access: [ { type: "outgoing-payment", actions: ["create", "read"], identifier: payer.id, limits: { debitAmount: { value: "5000000000", assetCode: payer.assetCode, assetScale: payer.assetScale, }, interval: `R/${start}/P1D`, }, }, { type: "quote", actions: ["create", "read"] }, ], }, interact: { start: ["redirect"], finish: { method: "redirect", uri: "https://app.example.com/finish", nonce: clientNonce, }, }, }, ); if (!isPendingGrant(pending)) throw new Error("expected the grant to wait"); console.log("Send the owner to", pending.interact.redirect);The debit limit must be in the wallet address's asset. A grant for outgoing payments waits for the owner for ten minutes. Grants and limits lists what a grant may ask for.
-
Send the owner to
pending.interact.redirect. The node takes them to the consent page in their wallet, which names your app and says what it may spend. To try it, be the owner yourself: open the link in a browser where you are signed in to your node's wallet. Connect apps shows the owner's side. -
The wallet sends the owner back to your finish URI with
hashandinteract_refin the query. Check the hash before you go on, so that nobody else's interaction is passed off as yours:// In your handler for https://app.example.com/finish const back = new URL(request.url); const interactRef = back.searchParams.get("interact_ref"); const expected = createHash("sha256") .update( [clientNonce, pending.interact.finish, interactRef, payer.authServer].join( "\n", ), ) .digest("base64url"); if (back.searchParams.get("hash") !== expected) { throw new Error("this is not the interaction the app started"); }If the owner denied the request, the query says
result=grant_rejectedinstead, and the grant cannot be continued. -
Continue the grant to get its access token:
await new Promise((resolve) => setTimeout(resolve, (pending.continue.wait ?? 0) * 1000) ); const grant = await client.grant.continue( { url: pending.continue.uri, accessToken: pending.continue.access_token.value, }, { interact_ref: interactRef }, ); if (!isFinalizedGrantWithAccessToken(grant)) throw new Error("not approved"); const accessToken = grant.access_token.value; -
Quote, then pay. The incoming payment above is paid already, and the node quotes nothing for a payment that takes no more money. So to try it, make a fresh one with the incoming grant and pay that. Any incoming payment URL works, on any node:
const toPay = await client.incomingPayment.create( { url: wallet.resourceServer, accessToken: incomingToken }, { walletAddress: wallet.id, incomingAmount: { value: "1000000000", assetCode: wallet.assetCode, assetScale: wallet.assetScale, }, }, ); const quote = await client.quote.create( { url: payer.resourceServer, accessToken }, { walletAddress: payer.id, receiver: toPay.id, method: "ilp" }, ); const outgoing = await client.outgoingPayment.create( { url: payer.resourceServer, accessToken }, { walletAddress: payer.id, quoteId: quote.id }, ); console.log(outgoing.grantSpentDebitAmount);To one of the node's own wallet addresses, as here, the rate is one for one: the quote's
debitAmountequals itsreceiveAmount. To another node, it measures the path with a small probe before it quotes, even between two wallets of one asset, since nodes on the way may charge a fee. ThendebitAmountis a little more thanreceiveAmount, room for the rate to move on the way; what the payment does not need, it does not debit, and the grant gets back. A quote lasts five minutes. -
Watch it. An outgoing payment says
failed: trueif it gives up, which it does after ten minutes at the latest. ItssentAmountis what it debited so far. Whether the money arrived, the incoming payment says: here,toPayturnscompleted, as in Watch the money arrive.
The owner sees your app, and what it spent, on the wallet's Connected Apps page, and can revoke it there at any time. From then on, your token gets 401.
When something goes wrong
Every error comes in the Open Payments error form, with a code and a description in plain words. The ones you are most likely to meet:
| Status and code | What happened | What to do |
|---|---|---|
401 invalid_client |
The node could not fetch your key set, or it has no key with
your keyId, or the signature does not verify.
|
Check that /client/jwks.json is on the public
internet, and that its kid matches.
|
401 unauthorized |
The token is unknown, revoked or expired. Access tokens last an hour. |
Rotate it with client.token.rotate, or ask for a
new grant.
|
400 invalid_request |
The request does not fit the node: an amount in another asset than the wallet address's, a limit on what is received alone, an expired quote, or funds that do not cover the payment. The description says which. | Fix what the description names. |
403 invalid_request |
The payment would go past the grant's limits. | Wait for the next window of the interval, or ask for a new grant. |
421 misdirected_request |
A node reached at <node ID>.dassiepay.net
or at a name it holds does not answer to the name you used.
A node with a hostname answers under it whatever name you
use, so it never returns this.
|
Use the name in the wallet address's id. |
429 too_many_requests |
Too many requests from your address, or too many grants from your app or your network. | Slow down, and try again later. |
503 unavailable |
The node is not ready yet. Or, at
<node ID>.dassiepay.net or a name there,
the front door can't reach it: the node is not connected to
it now, or is busy.
|
Try again later. The node's owner sees in their wallet whether it is connected. |
502 unreachable |
The node found no route to the receiver, or could not measure the rate. | Try again in a while: the testnet is small, and routes come and go. |
The Open Payments endpoints reference lists every route the node serves.
Why it works this way: what the node decides where the specs leave it room.