Skip to main content

Setting up a donation page

A page anybody can pay, any amount, as many times as they like, accumulating toward a total — an appeal, a campaign, a tip jar, a crowdfund, a church offering, a memorial fund.

It is the same primitive as a one-time link with two differences that change everything about how you run it: many payers, and a closing condition that is not "paid".

1. Create the page

curl -X POST $CHAINOS/v1/payments/links \
-H "X-API-Key: $CHAINOS_PAYMENT_SECRET" \
-H "Content-Type: application/json" \
-d '{
"mode": "donation",
"addressMode": "per_payer",
"chain": "polygon",
"asset": "USDT",
"slug": "harvest-appeal",
"title": "Harvest appeal",
"description": "Rebuilding the community hall roof",
"thresholdAmount": "50000",
"closesAt": "2026-12-31T23:59:59Z",
"minAmount": "1",
"collectEmail": true
}'
{
"slug": "harvest-appeal",
"url": "https://pay.your-company.example/pay/harvest-appeal",
"mode": "donation",
"addressMode": "per_payer",
"amountRaised": "0",
"paymentCount": 0,
"thresholdAmount": "50000",
"closesAt": "2026-12-31T23:59:59Z",
"address": null,
"status": "active"
}

Custom slugs are honoured here, unlike on a fixed link. A donation page is meant to be found, printed and said out loud, so harvest-appeal is the point rather than a leak.

A donation link must have a threshold, a closing date, or both

One with neither is refused. An open-ended link is an address nobody ever stops watching, and "we will take it down eventually" is not a closing condition any system can act on.

If the appeal genuinely has no end, give it a date a year out and extend it. That is a decision somebody makes annually rather than a resource nothing reclaims.

2. The decision that matters: addressMode

This is the one choice on the page worth thinking hard about, because it cannot be changed afterwards and it decides whether your donors can be thanked by name.

per_payer (default)shared
AddressOne derived per donorOne, for the life of the link
The donorEnters an email, gets their own addressSends to the address printed on the page
ReceiptsAttributable, with a nameAnonymous unless volunteered
Concurrent donorsTold apart exactlyBest effort
Printable, postable, QR on a posterNoYes — that is the point
InvoicesIssuedNone at all

Choose per_payer if donors need receipts

Which is almost always, if the money is tax-deductible, if you send thank-you emails, if you publish a donor list, or if anyone might ask "did my gift arrive?".

The donor flow is one extra step: they enter an email, and POST /v1/checkout/links/{slug}/donate derives an address that is theirs alone. Every payment to it is unambiguously theirs.

Choose shared only when nothing ships

One address, printed once. A poster on a wall, a QR code in a printed order of service, a slide behind a stage, an address read out on a broadcast. Nothing has to be personalised and nothing has to be issued at the moment of giving.

The cost is real and stated: two donors paying at the same moment cannot be told apart well enough to put a name on a receipt, so no invoice is issued at all. Do not choose shared and then ask how to attribute donations — it is the trade you made, and the answer is to ask donors to tell you separately.

This is the same trade ChainOS refuses elsewhere

A shared address plus a memo field is how XRP and Stellar exchanges usually work, and ChainOS deliberately does not do it for customer deposits — a customer who forgets the memo has sent funds nobody can attribute. It is admissible here only because nothing ships and nobody is owed anything in return.

Every payment accumulates toward the link's total in both modes. The difference is only whether you know who sent it.

3. Pick a chain donors can actually use

Donors are not your customers. They will not install anything, they will not learn what a network is, and a page that asks them to will lose most of them.

Polygon or Base, USDC or USDTCheap, widely supported, small gifts are economic
TRON, USDTVery widely held; the default for a lot of the world
BitcoinFor an audience that holds it. Fees make small gifts uneconomic
EthereumOnly for large gifts. A 20 USDC donation can cost more than that to collect

A gift below the chain's economic minimum is refused at creation. Set minAmount to something at or above that floor rather than letting donors hit the refusal — a donor who is told "no" at the moment of giving usually does not come back.

{ "minAmount": "5", "maxAmount": "100000" }

maxAmount is worth setting too, as a typo guard. Somebody meaning 50 and typing 5000 happens, and on an irreversible rail the only cheap moment to catch it is before they send.

4. Show the running total honestly

curl -s $CHAINOS/v1/payments/links/$ID -H "X-API-Key: $KEY"

amountRaised is confirmed money only. A detected payment can still be reorganised out of existence, and a public thermometer that went backwards would be worse than one that lags by a few minutes.

If you are rendering the total on your own site, cache it and refresh on a timer rather than on every page load; an appeal that goes well can get a lot of page loads.

5. The close, and the announcement

A donation link closes by itself on whichever condition fires first:

closedReason
thresholdIt reached its target
dateclosesAt passed
manualSomebody closed it

payment_link_closed carries amountRaised and paymentCount — the two numbers you want for the announcement, in one event.

if (event.event === 'payment_link_closed') {
await publishResult({
raised: event.data.amountRaised,
donors: event.data.paymentCount,
reason: event.data.closedReason,
});
}

To pause an appeal — a scandal, a duplicate campaign, a change of plan — pause rather than close. Closing is irreversible and freezes the total; pausing keeps it and resuming puts the page back.

Gifts arrive after you close

The address is monitored until monitorUntil, weeks after the close. A donor who sends on the day the campaign ended has given real money and it is credited.

Have an answer before it happens: credit it to the same cause, roll it into the next appeal, or return it. All three are defensible; discovering the question during an audit is not.

6. Thanking people

With per_payer and collectEmail, an invoice is issued and emailed on the first confirmation of each donor's gift. For most appeals you will want your own acknowledgement as well, since ours is a receipt rather than a thank-you.

if (event.event === 'payment_confirmed' && event.data.linkId === APPEAL_LINK_ID) {
await sendThanks({
email: event.data.customerEmail,
name: event.data.customerName,
amount: event.data.amountPaid,
asset: event.data.asset,
});
}

Fire it on payment_confirmed, not payment_detected. Thanking somebody for a gift that a reorg then removes is a message you cannot take back.

7. Sweeping, and what it costs

Each donor's address in per_payer mode is an address holding a small balance. A thousand donors is a thousand addresses.

Set a sweep policy for the (chain, asset) of the appeal, and set the dustFloor with donations in mind. A 1 USDC gift on Ethereum costs more in gas to consolidate than it is worth; on Polygon it does not. Run POST /v1/treasury/sweeps/preview and read skipped — if it shows nine hundred addresses below the floor, the floor is wrong or the chain is.

Sweeps are never billed, so the only cost is the network fee.

8. Practical points that catch people out

One appeal, one link. A second link for the same campaign splits the total and neither page tells you so. Use metadata to tag a single link if you need to attribute by channel.

Do not put the address in the email. Link to the page. An address in an email is a string somebody can alter in a forward, and an address on a page you control is not.

A printed QR is permanent. In shared mode the address on a poster stays valid for as long as the poster is on the wall — monitoring continues well past the close. Decide in advance what you do about gifts arriving from a poster nobody took down.

collectEmail is not consent. Whatever your jurisdiction requires about contacting donors is yours to handle; the field collects an address so a receipt can be sent.

Next