Coinland PayDocs
Concepts

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.k7Qw3xR2mB9pLd4vN8sYc1TfHj0aXeU6ZgO5rWq

You 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)))>
  • v1 is 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:

  1. 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.
  2. 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.
  3. 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"}'
200 OK
{
  "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_url page trusts to mark an order paid.
  • A bearer token for anything. It grants nothing; it attests.

On this page