We've all built that one MVP. You know the flow:
- Your bot spits out a USDT address.
- You ask the customer to reply with a transaction hash or, worse, a screenshot.
- You manually open Tronscan or Solscan.
- You squint at the network, token, recipient, amount, and confirmations.
- You manually grant them access.
That’s fine for your first five sales. At fifty, it turns into a miserable support queue. You are no longer a developer; you are a human blockchain explorer.
Let's automate this properly. We are going to build a non-custodial checkout service in Go that creates invoices, catches signed webhooks, and fulfills Telegram orders exactly once. We’ll use Recv as the processor because it skips the custody nonsense—funds go straight to your wallet, and it just pings you when a transfer matches.
Here is how to do it without shooting yourself in the foot.
The Architecture: Don't Trust the Client
The core principle here is simple: your bot does not decide a payment is complete just because a user clicked an "I Paid" button. You wait for the server-side webhook.
The flow looks like this:
- User hits
/buy. - Go service creates a local order, then hits the
POST /v1/invoicesAPI. - User gets a hosted checkout URL and sends the crypto.
- Recv watcher spots the on-chain transfer and fires a signed
invoice.paidwebhook. - Go service verifies the HMAC signature, marks the order paid, and queues fulfillment.
- The Bot API finally delivers the product.
Rule 1: Your Database Owns the Order
Your app owns the commercial order. The payment provider owns the payment invoice. They are related, but they are not the same thing.
Drop the basic is_paid boolean. Crypto is chaotic. People underpay, overpay, use the wrong network, or pay three hours after the invoice expires. Use a state machine:
created -> awaiting_payment -> paid -> fulfilled
\-> payment_review
\-> expired
Always create your local order before you call the invoice API. If the network drops during the API call, you still have a local record to retry or cancel.
Rule 2: Idempotency is Not Optional
When you hit the API to generate the invoice, use an Idempotency-Key tied to your internal order ID (e.g., create-invoice:ORDER_123). If your request drops and you retry, you won't accidentally spin up two payable invoices for the same cart.
// Creating the invoice
invoice, err := recvClient.CreateInvoice(ctx, idempotencyKey, recv.CreateInvoiceRequest{
Title: "Premium Access",
BaseAmountUSD: "29.00",
PayableNetwork: "TRON",
PayableAsset: "USDT",
ExpiresInMinutes: 30,
PaymentOptions: []recv.PaymentOption{
{Network: "TRON", Asset: "USDT"},
{Network: "SOLANA", Asset: "USDC"},
},
})
Why is the payable amount suddenly 29.004281 instead of 29?
Because TRC-20 and ERC-20 transfers don't have reliable memo fields. If three people send exactly 29 USDT to the same wallet at the same time, you have no idea who paid for what. Generating a tiny unique suffix (like .004281) acts as the matching key. This is why you must always display the payable_amount returned by the API, not your base USD amount.
Rule 3: Verify the Damn Signature
If you accept JSON blindly just because it hit your endpoint, you are asking to be exploited.
The webhook comes with an X-recv-Signature containing an HMAC-SHA256 digest of the timestamp and the raw request body.
Do not parse the JSON first. Parsing and re-encoding changes whitespace and field orders, which destroys the signature. Verify the raw bytes, then unmarshal.
func Verify(secret string, rawBody []byte, timestampHeader string, signatureHeader string, now time.Time, tolerance time.Duration) error {
// Parse timestamp and check against tolerance (e.g., 5 mins) to prevent replay attacks
// ... (timestamp validation logic) ...
const prefix = "v1="
provided, err := hex.DecodeString(strings.TrimPrefix(signatureHeader, prefix))
mac := hmac.New(sha256.New, []byte(strings.TrimSpace(secret)))
_, _ = mac.Write([]byte(timestampHeader))
_, _ = mac.Write([]byte("."))
_, _ = mac.Write(rawBody)
expected := mac.Sum(nil)
// Use constant-time comparison to prevent timing leaks
if !hmac.Equal(provided, expected) {
return ErrBadSignature
}
return nil
}
Rule 4: At-Least-Once Delivery Will Break Your Bot
Webhook delivery is "at least once", not "exactly once".
Imagine this: you receive the webhook, update the DB, send the Telegram message with the download link, and then your Go process panics before returning a 200 OK. The provider assumes the webhook failed and retries. Your bot sends the product again.
The fix is idempotency in your database. Use an inbox table and process the event in a single transaction:
BEGIN;
-- 1. Deduplicate the event
INSERT INTO webhook_events (event_id, event_type, payload)
VALUES ($1, $2, $3)
ON CONFLICT (event_id) DO NOTHING;
-- If RowsAffected is 0, halt. We've seen this before.
-- 2. Update the order
UPDATE orders SET status = 'paid' WHERE invoice_public_id = $4;
-- 3. Queue the fulfillment, DO NOT execute it inline
INSERT INTO fulfillment_jobs (order_id, job_type)
VALUES ($id, 'deliver_product')
ON CONFLICT DO NOTHING;
COMMIT;
Notice we didn't call the Telegram API inside the transaction. DB transactions aren't atomic with network calls. You write the intent to fulfill to a durable queue, and a separate worker picks it up.
Handling the Awkward States
The happy path looks great in a tutorial, but edge cases are where your bot actually lives.
-
Underpayment: A user withdraws from an exchange, and the exchange eats a $1 fee. The invoice expected 29.004281, but 28.004281 arrived. Do not auto-fulfill. Trigger an
invoice.underpaidevent, store the observed amount, and alert support. - Late Payment: Blockchains don't care if your 30-minute timer expired. The transaction might still confirm. Decide in your code if you'll fulfill it anyway, flag it for manual review, or offer an alternative.
- Wrong Network: USDT on TRON isn't USDT on Ethereum. Put the required network in massive, bold text right next to the address in your bot's UI.
Stop wiring invoice.paid directly to a sendMessage call. Build the queue, verify your HMACs, and you'll actually be able to leave your checkout running overnight without waking up to a destroyed database. Full API references and supported chains are in the Recv docs. Keep your private keys offline.
Top comments (0)