Reference for integrating GRCpay from your backend or ecommerce platform.
Overview
The GRCpay API is a small REST surface that returns application/vnd.api+json documents. No API keys, no accounts. Reads and cancels on a specific wallet use a per-wallet token returned once at creation; everything else is public. The entire flow can be driven from a half-dozen endpoints.
The API base URL depends on how you deploy. On this deployment at grcpay.gridcoin.club the API is mounted under /api (so e.g. https://grcpay.gridcoin.club/api/wallets). Running locally with docker-compose up grcpay the backend listens directly on port 7001.
Heads up: the public grcpay.gridcoin.club/api install is free for now and provided as-is. No SLA, no warranty (see the disclaimer). The recommended way to use GRCpay in production is to run your own instance against your own Gridcoin wallet.
Conventions
Every request and response uses the JSON:API envelope. POST bodies must be sent with the content type application/vnd.api+json; resources are wrapped in { "data": { "type": "...", "attributes": { ... } } }.
CORS is wide open (Access-Control-Allow-Origin: *) so the API is callable from any origin.
Rate limits: 30 requests/min per IP on /wallets, 60 requests/min on /rates. Exceeding the limit returns 429 Too Many Requests.
Per-wallet access tokens: most endpoints are public. You don't need an API key to hit /status, /rates, POST /wallets, or the QR image endpoint. But the two endpoints that read or modify an existing wallet (GET /wallets/:address and DELETE /wallets/:address) require a per-wallet token in the X-Wallet-Token header. GRCpay hands the raw token back exactly once in the response to POST /wallets and only stores its SHA256 hash server-side, so if you lose the token you can't read the wallet again (you'll get a 401, not a 404, because GRCpay intentionally doesn't leak address existence to unauthenticated callers). Stash it next to the address in whatever order record your integration keeps.
Status
Returns the service name and version. Cheap call. Use it for health checks.
GET/api/status
Service health
Request
curl https://grcpay.gridcoin.club/api/status
Response — 200 OK
{
"data":{
"type":"status",
"attributes":{
"name":"grcpay",
"version":"1.0.0"
}
}
}
Wallets
Mint a new payment wallet, then look it up later by its address. Wallet records carry the lifecycle status, the requested and received amounts, the forwarding and refund transaction ids once settlement has happened, and (if supplied) the recipient address.
POST/api/wallets
Create a payment wallet
Request
curl -X POST https://grcpay.gridcoin.club/api/wallets \
-H 'Content-Type: application/vnd.api+json' \
-d '{
"data": {
"type": "wallets",
"attributes": {
"amountRequired": 1.5,
"recipient": "SHpqN8xEjy2HHTnAGfgJjwFThuqzLbBs6i"
}
}
}'
Response — 201 Created
{
"data":{
"type":"wallets",
"id":"SXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"attributes":{
"address":"SXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"recipient":"SHpqN8xEjy2HHTnAGfgJjwFThuqzLbBs6i",
"amountRequired":"150000000",
"amountRecieved":"0",
"amountPending":"0",
"status":"new",
"mode":"checkout",
"lifespanSeconds":null,
"txOut":null,
"refundTx":null,
"refundAmount":null,
"token":"8Xf3K2…long-random-base64url-string",
"createdAt":"2026-04-13T10:00:00.000Z",
"updatedAt":"2026-04-13T10:00:00.000Z"
}
}
}
Capture thetokenon the creation response. It's a per-wallet access token GRCpay generates at creation time and reveals only once. This is the only response that will ever contain it. The server stores a SHA256 hash, not the raw token, so if you lose it there's no way to recover. You'll pass it back in the X-Wallet-Token header on every subsequent GET /wallets/:address and DELETE /wallets/:address call. Your integration layer (WooCommerce plugin, backend store controller, etc.) should stash the token alongside the address in whatever order-state table it already keeps.
Note the amounts are serialised as halford strings, not GRC floats: 1 GRC = 100,000,000 halford. Use string arithmetic (or a BigInt in your language of choice) to dodge rounding surprises. Convert to GRC only at the display layer.
The recipient field is optional. If you omit it, GRCpay will leave the funds at the generated address and just mark the wallet processed when fully funded. Useful when you sweep balances yourself. Overpayment refunds still happen in that case; only the forward-to-recipient step is skipped.
GET/api/wallets/:address
Look up a wallet
Reads are token-gated. Without the X-Wallet-Token header you get a 401 regardless of whether the address exists. GRCpay deliberately doesn't leak which addresses are live to unauthenticated callers. Only the merchant who minted the wallet should be able to observe amounts and settlement txids.
Response — 200 OK (settled wallet with overpayment refund)
{
"data":{
"type":"wallets",
"id":"SXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"attributes":{
"address":"SXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"recipient":"SHpqN8xEjy2HHTnAGfgJjwFThuqzLbBs6i",
"amountRequired":"150000000",
"amountRecieved":"200000000",
"amountPending":"0",
"status":"processed",
"mode":"checkout",
"lifespanSeconds":null,
"txOut":"abc123… (forward tx to merchant)",
"refundTx":"def456… (refund tx to customer)",
"refundAmount":"49900000",
"createdAt":"2026-04-13T10:00:00.000Z",
"updatedAt":"2026-04-13T10:00:37.000Z"
}
}
}
Notice the absence of token on the GET response. It was a one-time reveal on creation and is never echoed back. The auth layer deliberately returns an identical 401 Unauthorized response for every failure mode: missing header, wrong token, and address not found all look the same to the caller. An attacker without a token can't distinguish "this address doesn't exist" from "this address exists but I don't have its token," so the endpoint can't be used as a probe oracle to enumerate live addresses.
DELETE/api/wallets/:address
Cancel a live wallet
Merchant-initiated cancellation. Useful when the item sold out, the order was aborted upstream, the customer abandoned checkout, or you renegotiated the price and are re-issuing a fresh wallet. Same X-Wallet-Token header as GET (no token or wrong token both return 401). The wallet flips to expired. A 409 Conflict means cancel isn't safe right now: the wallet is already terminal, a broadcast is mid-flight, or a refund has already gone on-chain. Retry after a few seconds, by which point the settlement has either finished or moved into the refund flow on its own.
Cancelling a paid wallet refunds the buyer. The refund flow reads the live on-chain balance, then pays each sender back their contribution minus the network fee. Transient sender-lookup misses or RPC blips get retried under backoff, so the refund lands once the chain catches up. The wallet ends refunded, or norefund if nothing recoverable arrived, or error if a partial-refund failure needs a human. Reconcile by address. If you mint a replacement wallet for the same order, keep watching the old one until it terminalizes; a cancelled wallet isn't "closed" the moment cancel returns. With webhookUrl set, you'll get the expired and final terminal-state deliveries.
GRCpay does not settle on 0-conf. The balance updater asks the wallet daemon for two balances on every tick: one at the configured MIN_CONFIRMATIONS threshold (default 2) and one at 0-conf. The difference is tracked as a separate amountPending field on the wallet record. Only the confirmed portion counts toward amountRequired, so a same-block reorg can't trick GRCpay into flipping a wallet to funded, and therefore can't trick you into shipping goods for a payment that later disappears.
Use amountPending to show the customer a reassuring "we see your payment, waiting for confirmations" state so they don't panic-send a second tx. Operators running on a private or otherwise trusted chain can lower the threshold with MIN_CONFIRMATIONS=1 (or even 0 to accept 0-conf) in their grcpay environment.
Response — 200 OK (partial payment, some in mempool)
{
"data":{
"type":"wallets",
"id":"SXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"attributes":{
"amountRequired":"150000000",
"amountRecieved":"50000000",
"amountPending":"60000000",
"status":"new",
…
}
}
}
When the customer sends enough (confirmed plus still-unconfirmed) to cover amountRequired, the wallet transitions to confirming. The settlement path does not fire yet. The merchant only gets paid once the confirmed portion alone meets the invoice. But this is the right moment for your checkout UI to stop asking for more money and switch to a reassuring "payment detected, waiting for confirmations" banner. Without this state, customers sitting on the thank-you page during a slow 2-block wait have no signal that their transaction was seen, and they panic-send a second payment.
Response — 200 OK (confirming, full amount seen on-chain)
{
"data":{
"type":"wallets",
"id":"SXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"attributes":{
"amountRequired":"150000000",
"amountRecieved":"0",
"amountPending":"150000000",
"status":"confirming",
"confirmations":1,
"confirmationsRequired":3,
…
}
}
}
Settlement-related fields
amountPending: halford sum of inbound txs the daemon has seen at 0-conf that haven't yet reached MIN_CONFIRMATIONS. Display-only; never factored into settlement math. Drops back to 0 once everything has confirmed and moved into amountRecieved. Mempool dust / dropped txs can also make it decrease.
confirmations: minimum confirmation depth across the deposits seen so far, for the same "N of M confirmations" banner the checkout UI uses to keep customers off the panic-second-payment path. Only present while status === "confirming"; omitted in every other state so you can't accidentally render a stale number.
confirmationsRequired: the threshold the wallet needs to clear to flip from confirming to funded, i.e. the server's MIN_CONFIRMATIONS setting (3 on the public instance). Same confirming-only visibility as confirmations.
txOut: Gridcoin transaction id for the merchant forward (wallets with a recipient) or the first per-sender refund tx (expired wallets without a recipient, or wallets that went through the expired-refund flow). Null until settlement happens.
refundTx: Gridcoin transaction id for the overpayment refund sent back to the customer. Null when no overpayment refund happened: either the payment was exact, or the overpayment was too small to be worth refunding, or the sender couldn't be determined, or the refund tx itself failed to broadcast. See the Errors and fee/refund math sections for the full breakdown.
refundAmount: the actual halford amount returned to the customer (excludes the refund's own network fee, which the customer paid). Derived from refundTx: when refundTx is set, refundAmount is the halford sum the customer actually received back. Null when no refund happened.
Webhooks
Webhooks are opt-in, and they sit on top of polling rather than replace it. Polling GET /wallets/:address keeps working for every wallet exactly as before. If you also pass a webhookUrl when you create the wallet, GRCpay also POSTs you a signed notification on every meaningful status change, so your store hears about a payment in seconds instead of waiting for its own next poll on top of GRCpay's poll cadence.
The feature is off by default and self-hosted only. The public sandbox instance does not send webhooks; an operator turns them on for their own GRCpay with WEBHOOKS_ENABLED=true. Passing a webhookUrl to an instance with webhooks disabled returns a 400, so you find out at once instead of silently never receiving anything.
Enabling per wallet
Add webhookUrl to the attributes on POST /api/wallets. It is validated syntactically only at creation. GRCpay does not ping it; the reachability and safety check runs at delivery time.
Request
curl -X POST https://grcpay.gridcoin.club/api/wallets \
CapturewebhookSecret on this response. Like token, it is generated once and never echoed again. GRCpay keeps it only to sign your payloads. Store it next to the address in your order-state table; you need it to verify every delivery.
Events
One POST per status transition: confirming, funded, processed, expired, refunded, norefund, error. The initial new (you already have it from the create response) and reconciler flap-backs are intentionally not delivered. Treat status as latest-wins: the payload always carries the wallet's current amounts, so a missed intermediate event is harmless.
Amounts are halford strings (1 GRC = 100,000,000 halford), same convention as the REST API. Map the delivery back to your order by walletAddress, and dedupe on X-Grcpay-Event-Id: delivery is at-least-once, so the same event id can arrive more than once after a retry.
Verifying the signature
The signature is HMAC-SHA256(secret, "<timestamp>.<event_id>.<attempt>.<raw body>") hex-encoded. Compute it over the exact raw request bytes (do not re-serialise the JSON first) and compare in constant time. Event id and attempt are part of the signed string, so a man-in-the-middle can't replay a captured body with a rewritten X-Grcpay-Event-Id and slip past your dedup. Reject the delivery if the signature doesn't match, or if X-Grcpay-Timestamp is more than 300 seconds off your clock. The timestamp is signed too, so a captured body can't be replayed later with a fresh clock.
Any 2xx counts as delivered. Anything else gets retried with exponential backoff and then dead-lettered after the operator's configured attempt cap: a non-2xx, a timeout, a dropped connection, or a redirect (GRCpay sends maxRedirects: 0, so expose a stable endpoint and don't 30x it). Acknowledge fast: return 2xx and process out of band. A slow handler that runs past the delivery timeout counts as a failed attempt and is retried.
Endpoint requirements (self-hosting note)
Your callback must be https:// and resolve to a public address. GRCpay resolves the host on every delivery, refuses loopback, private, link-local and other non-public ranges, and pins the connection to the validated IP so a rebind can't redirect it. Operators running a local test integration (say, a WooCommerce container on the same Docker network) can relax this with WEBHOOK_ALLOW_PRIVATE=true, which also allows http://. Never set it on an internet-facing instance. The delivery is an outbound request to a URL you control, so host your receiver where you're comfortable revealing its origin, and keep it off the same host as anything you don't want correlated.
See also the cancel-with-funds note under Wallets. A merchant cancel on a paid wallet is supported and reaches you as a refunded (or, rarely, error) webhook once the buyer's funds are returned. Reconcile by walletAddress and don't assume a cancelled wallet is closed the moment you cancel it.
Fee and refund math
Settlement amounts follow a simple rule: GRCpay always pays the per-tx network fee out of the amount being sent, never on top of it. That applies equally to merchant forwards and to customer refunds. Whoever receives the tx also bears the fee for it.
The network fee is currently 0.001 GRC per transaction (the MIN_FEE constant in the backend config). Multiply by two whenever you see both a refund and a forward happen on the same wallet.
Every scenario, same row format
Each row assumes an amountRequired of 10 GRC and a recipient that was supplied at wallet creation time. Numbers round to halford precision.
Scenario
Received
Refunded to customer
Forwarded to merchant
refundTx field
Notes
Exact payment
10 GRC
—
9.999 GRC
null
No overpayment, no refund, merchant gets required − fee.
Overpayment ≤ fee. Refunding would net-negative. Skipped, merchant absorbs the tip.
Sender cannot be determined
12 GRC
—
11.999 GRC
null
Rare. Transaction-history walk fails to find a usable sender. Overpayment absorbed into the merchant forward.
Refund tx fails (within retry budget)
12 GRC
deferred
(not yet)
null
Refund RPC threw. Wallet stays funded, refund_attempts bumped, retried on the next cycle with exponential backoff (30s, 1m, 2m, 4m). Merchant payout is held until the refund either succeeds or the retry cap is exhausted. Rationale: don't commit to forwarding the merchant's cut before we've given the customer's refund a real chance.
Refund tx fails (retries exhausted)
12 GRC
—
11.999 GRC
null
After MAX_REFUND_ATTEMPTS (default 5) the refund is abandoned and the full balance is forwarded to the merchant so the payout isn't blocked indefinitely. Customer absorbs the mistake. The attempts are visible in db_logs under overpayment_refund_failed.
Forward tx fails
10 GRC
—
(not sent)
null
Wallet transitions to error for manual operator review.
Invariant the merchant can count on
On the happy path (exact or refundable overpayment), the merchant always receives exactly amountRequired − MIN_FEE. Plugin UIs can display “you paid X, merchant got X minus the network fee” with confidence. The absorb- into-tip cases (dust, sender unknown, refund tx fails) give the merchant slightly more than the required amount, never less. The only scenario where the merchant gets less than required − fee is when the forward tx itself fails; in that case the wallet transitions to error so the merchant knows to look into it.
Expired-wallet refunds (multi-sender)
A wallet that never reached its target and times out takes a different path: each contributor gets back what they originally sent, minus the per-refund network fee. If a wallet received contributions from three different senders, the expired-refund flow broadcasts three refund transactions, each consuming 0.001 GRC in fees. The first refund txid lands in txOut; the total halford refunded across all senders lands in refundAmount; and every individual refund tx is recorded in db_logs under the expired_refund action if you want the full trail.
Per-sender dust (where a single sender's contribution is smaller than the refund fee) is silently skipped. If every sender's contribution is below the fee, the wallet transitions to norefund instead of refunded. If any of the per-sender refund txs fail, the wallet transitions to error regardless of how many others succeeded. The operator should check db_logs for the wallet_id to see what went through and what didn't.
Late payments on terminal wallets
A wallet that's already settled (processed, refunded, or norefund) can still have GRC arrive late: a customer paying from a stale checkout tab, a shopper who saved the address and came back an hour later, a merchant pulling the plug on an order that was already in flight. GRCpay runs a separate slow sweep (default once an hour, LATE_PAYMENT_CHECK_INTERVAL) over all terminal wallets last touched within the past 7 days (LATE_PAYMENT_WINDOW), compares the on-chain balance against the DB's amountRecieved, and refunds any delta to the latest sender minus the fee. Same dust and no-sender absorption rules as the overpayment flow. Each successful late refund bumps the wallet's refundAmount cumulatively and lands a late_refund entry in db_logs. Past 7 days the wallet is considered cold and late funds stay in the hot wallet for an operator to sweep. Operators who don't want this sweep at all can set LATE_PAYMENT_CHECK_INTERVAL=0 to disable it.
Merchant-initiated cancellation
If an order needs to be cancelled while the wallet is still new (item sold out, customer abandoned checkout, merchant just changed their mind), the integration can call DELETE /wallets/:address with the per-wallet access token. GRCpay flips the wallet straight to expired, and the existing expired-refund flow returns any partial balance on the next job cycle (same multi-sender semantics). Cancellation is rejected with 409 Conflict on anything past new. Once funds are in, they're either already on their way to the merchant or already being refunded.
QR Codes
Returns a JSON:API document whose qr attribute is a base64-encoded PNG data URL. Fetch the JSON, pull out the data.attributes.qr field, and feed the data URL straight to an <img> tag. No client-side QR library required.
The filter[width] query parameter is optional: defaults to 256 pixels, must be between 1 and 999.
Rates
Convenience pass-through to CoinGecko, cached for 5 minutes per currency. Use it to convert a fiat price to a GRC amount before creating a wallet.
GET/api/rates
Supported currencies
Request
curl https://grcpay.gridcoin.club/api/rates
GET/api/rates/:currency
GRC price in fiat
Request
curl https://grcpay.gridcoin.club/api/rates/usd
Response — 200 OK
{
"data":{
"type":"rates",
"id":"usd",
"attributes":{
"currency":"usd",
"rate":0.0023,
"coin":"gridcoin-research",
"ticker":"grc"
}
}
}
Errors
Error responses follow the JSON:API error envelope. The HTTP status code is mirrored in the status field of each error object.
Example — 429 Too Many Requests
{
"errors":[
{
"status":"429",
"title":"Too Many Requests",
"detail":"Rate limit exceeded. Try again in 60 seconds."
}
]
}
Status codes you'll actually see
200 OK: successful GET.
201 Created: successful POST /wallets. The body contains the one-time token attribute you'll need for subsequent GETs and DELETEs.
204 No Content: successful DELETE /wallets/:address. The wallet is now expired and will be refunded on the next job cycle if it received any partial balance.
400 Bad Request: validation failed on the POST body (missing amountRequired, invalid base58 recipient, etc.).
401 Unauthorized: you didn't send the X-Wallet-Token header, or the token you sent doesn't match. Returned identically in both cases so the endpoint can't be used to probe which addresses exist.
404 Not Found: the address genuinely doesn't exist (and you did authenticate).
409 Conflict: you tried to DELETE a wallet that can't be cancelled right now. The wallet is already terminal (processed, refunded, norefund, expired, error), tx_out is already written, a broadcast is mid-flight, or a refund has already gone on-chain. Retry after a few seconds. A funded wallet with no in-flight broadcast and no prior refund still cancels cleanly: it flips to expired and the buyer's balance is returned to its sender on the next refund sweep.
429 Too Many Requests: you're hitting the per-IP rate limit.
Self-hosted Gridcoin payment facilitator. Privacy-first by design.Terms · Legal overview