Receipts
The signed receipt token, how to verify it offline in any language, and the line between proof and fulfilment
Every completed payment carries a receipt token: a short, self-contained string that proves the payment happened, on terms only you can check. It is the answer to "the customer says they paid, and I have no way to know".
v1.eyJwYXltZW50X2lkIjoiYjkyZTRkMTctNmMzOC00YTA1LTlmMmItMWU3ZDNjOGE1MDQ5Iiwic
mVjZWlwdF9ubyI6IkNMUC04RjNLMk05USIsIm1lcmNoYW50X2lkIjoiOGYxYzlhMzQtM2QyZS00Y
jE3LTlmMGEtMmM2ZDViOGU0YTcxIiwicmVmZXJlbmNlX2lkIjoib3JkZXItMTA0OTIiLCJjdXJyZ
W5jeSI6InVzZHQiLCJhbW91bnQiOiIyNC45MCIsImNoYXJnZWRfYW1vdW50IjoiMjQuOTAiLCJmZ
WVfYmVhcmVyIjoibWVyY2hhbnQiLCJuZXRfYW1vdW50IjoiMjQuNzgiLCJwYWlkX2F0IjoiMjAyN
i0wOC0xMVQxMjowNDozMVoifQ.k7Qw3xR2mB9pLd4vN8sYc1TfHj0aXeU6ZgO5rWqYou receive one in three places: on the payment object as receipt, appended to your return_url
as ?receipt=..., and inside the token the customer can copy from their own payment history.
The format
Three dot-separated parts, and the middle one is the whole payload:
v1.<base64url(payload JSON)>.<base64url(HMAC-SHA256(receipt_secret, "v1." + base64url(payload)))>v1is the format version. Refuse a token whose first part you do not recognise rather than guessing at it.- The payload is plain JSON, base64url-encoded without padding. It is not encrypted -- anyone can read it. That is intentional: the token is proof, not a secret.
- The signature is an HMAC-SHA256 over the literal string
"v1." + <part two>, keyed with your receipt signing secret, then base64url-encoded.
Note what is signed: the first two parts joined, including the v1. prefix and the dot. Signing the
payload alone would let someone move a valid signature onto a future format version.
Payload fields
Prop
Type
Field order matters if you reimplement the signature
The payload is signed as serialised, so anyone rebuilding it from parts must emit the keys in the
canonical order: payment_id, receipt_no, merchant_id, reference_id, currency, amount,
charged_amount, fee_bearer, net_amount, paid_at. Verifying a token you were GIVEN needs none
of this — you hash the string as received.
There is no expiry claim. A receipt is a record of something that happened, and it stays true; if you
need recency, compare paid_at yourself.
Why it cannot be forged
The signature is an HMAC keyed with your receipt signing secret, and that secret exists in exactly two places: Coinland's encrypted store, and your server. It is never shown to a customer, never sent to a browser, and never part of the token.
So a customer -- or anyone who has seen a hundred valid receipts -- can decode a payload, edit
amount to something larger, and re-encode it. What they cannot do is produce a signature that matches
the edited payload, because computing one requires the key. Your verification rejects it in the same
line of code that would have accepted a real one.
This is the same construction as the webhook signature but with a different key, and the separation is the point. Your webhook secret rotates for ordinary operational reasons -- a leak, a host move, someone leaving the team -- and none of those should reach back and invalidate proof of payments that already happened. They did, until the two were split.
Rotating the receipt secret is retroactive, and the endpoint does not rescue old tokens. You get a
24-hour window in which tokens signed with the previous key still verify -- offline and through
POST /receipts/verify, which accepts exactly the same two keys you do. After that window a token
signed under the old key verifies nowhere: the endpoint checks the signature before it ever looks at
the payment, and it keeps no key history.
Rotate this one only if the secret itself has leaked. For durable proof of an old payment, use
GET /payments/{id} -- the payment record outlives every key.
Verifying offline
Offline is the recommended path. It is a few lines of standard-library code, it costs no network hop, and it works when Coinland is unreachable.
import crypto from "node:crypto";
/**
* Returns the payload if the token is authentic, or null.
* `secret` is your receipt signing secret.
*/
export function verifyReceipt(
token: string,
secret: string,
expectedMerchantId: string,
): Record<string, string> | null {
const parts = token.split(".");
if (parts.length !== 3 || parts[0] !== "v1") return null;
const [version, body, signature] = parts;
const expected = crypto
.createHmac("sha256", secret)
.update(`${version}.${body}`)
.digest("base64url");
// Constant-time: a comparison that returns early leaks how much of a guess
// was right, which is enough to reconstruct a signature.
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
// A signature only proves the token came from a Coinland secret. Checking the
// merchant id is what proves it came from YOURS.
if (payload.merchant_id !== expectedMerchantId) return null;
return payload;
}Three things to get right in any language:
- base64url, not base64.
-and_replace+and/, and the=padding is stripped. A plain base64 decoder will fail on some tokens and succeed on others, which is the worst kind of bug to have in a payment path. - Constant-time comparison.
crypto.timingSafeEqual,hash_equals,hmac.compare_digest-- whatever your standard library calls it. A short-circuiting==on a signature is a real, exploited weakness, not a theoretical one. - Check
merchant_id. A valid signature proves the token was minted under a Coinland webhook secret. Comparing the merchant id is what proves it was minted under yours and not another business's.
Verifying over the API
If you would rather not implement HMAC, POST /api/pay/v1/receipts/verify does it for you. It checks
the signature against your secret and that a matching payment still exists with those facts, so it
catches one thing offline verification cannot: a correctly signed token for a payment that was later
found to be something other than it claimed.
curl -X POST https://my.coinlandexchange.com/api/pay/v1/receipts/verify \
-H "Authorization: Bearer $COINLAND_PAY_KEY" \
-H "Content-Type: application/json" \
-d '{"receipt":"v1.eyJwYXltZW50X2lkIjoi....k7Qw3xR2mB9pLd4vN8sYc1TfHj0aXeU6ZgO5rWq"}'{
"valid": true,
"payment": {
"id": "b92e4d17-6c38-4a05-9f2b-1e7d3c8a5049",
"receipt_no": "CLP-8F3K2M9Q",
"reference_id": "order-10492",
"currency": "usdt",
"amount": "24.90",
"charged_amount": "24.90",
"fee_bearer": "merchant",
"net_amount": "24.78",
"paid_at": "2026-08-11T12:04:31Z"
}
}A bad signature, an altered payload and an unknown payment all answer PAY_RECEIPT_INVALID (422) --
one code, because the remedy is the same in every case and distinguishing them would tell an attacker
which half of their forgery was working.
The endpoint is a convenience, not the authority. It reads the same records offline verification reasons about, so a token that verifies offline against your own secret is already proven; the network call adds the existence check, not the trust.
Proof is not fulfilment
Fulfil on the webhook or the API
A verified receipt tells you a payment happened. It does not tell you that this order has not already
been fulfilled, and it is presented by whoever is holding it. Release goods from your
webhook handler or from GET /payments/{id}, keyed on your own order state.
The distinction matters because a receipt is portable by design. The same token can be shown twice, or by someone the customer forwarded it to. Nothing about that is a flaw -- it is what makes a receipt useful -- but it means the token answers "was this paid?" and never "should I ship this?".
Good uses for a receipt:
- Support. A customer pastes their token; your agent verifies it in one function call and knows immediately whether to believe them, without querying Coinland.
- Downstream systems. A fulfilment service or a partner can check a payment without holding your API key, because verification needs only the receipt signing secret and no network access.
- Records. Store the token with your order. Years later it still proves what was paid, even if the API has moved on.
What a receipt should never be:
- The thing your
return_urlpage trusts to mark an order paid. - A bearer token for anything. It grants nothing; it attests.