How to accept bitcoin as merchant with BTCpayserver

  • by

We at LAWGATE are constantly looking for increasing the customer experience and striving to achieve a high level of confidentiality of customer data. This also includes the payment process. By design, this is not possible with credit cards as they do not run without a third party and that is why we looked into how to accept Bitcoin.

While ok for standard use cases, customers might be less eager to use credit cards involved with legal matters. Another reason is the notoriously bad customer experience and the high degree of insecurity for the merchant involved with credit cards. As far as other payment processors are concerned, we also cannot be really sure on how they use payment data. So, we were on the lookout for something where we feel like having a lot of transparency about its working. That is why we started to have a look on how to accept Bitcoin and stumbled across BTCPayserver.

Tackling the question of how to accept Bitcoin?

Long story short, we came across and decided to give it a shot and self-hosting it. Internally, we decided to create a detailed documentation along the way in our engineering team. We like BTCpayserver and would like to create a comprehensive tutorial for it. We hope to help others successfully in their quest on how to accept Bitcoin.

How to prepare BTCPayserver?

1. Get yourself a new VPS with Docker configuration from your hoster of choice. Take one where you do not have to configure Docker from scratch, but instead can set up one directly from the hosters marketplace as this will save you time and headache.

2. DNS setup: In order to make BTCPayserver accessible for the outside world and for the setup process, the DNS setup has to be working before.

2.1. Create an A record pointing to the IP of the newly created VPS

2.2. Additionally, create a CNAME which will be pointing to the newly created A record. 
We recommend a fresh domain to avoid messing around with SSL certificates and to leave the standard settings from the installation guide.

– A record with your hostname pointing to the IP of the VPS.

– A CNAME record will look like and acts as an alias of your hostname in your A record.

– You will use the CNAME for configuring BTCPayserver later on. Pick a nice one, it is on your invoices.

3. Get BTCPay docker sources from Github:

There is a comprehensive guide about it which is worth a reading as well as:

4. Create the parameters for the setup process. We decided for a pruned node for starting with a plan to keep the full node later on and giving our node a nice name as well.

export BTCPAY_HOST=""
export NBITCOIN_NETWORK="mainnet"
export BTCPAYGEN_CRYPTO1="btc"
export BTCPAYGEN_ADDITIONAL_FRAGMENTS="opt-save-storage-xs"

5. Run the setup script as root (and not as another user) and create a second sudo user afterwards and disable root ssh login for some added security.

. ./ -i

6. Let BTCPayserver sync until the end. This will take a couple of days up until a week.


The node must be fully synced in order to being able to play around with invoices. It is ready when the pop-up window indicating the sync process has disappeared, otherwise you see the following error:  “Payment method unavailable (Full node not available)”. However, you can setup the user accounts, store and pairing with your backend.

Important: If you want to play around with different parameters, you have to export them again only if you changed them. Lets say you had crypto02=”ltc” but you figured out your space is not large enough. Then simply export crypto02=””, re-run the setup script and ltc sync will be stopped. You can check it on the page of your personal BTCpay instance where the pop-up should disappear.

Preparation in node.js for how to accept Bitcoin

Get the client for node.js (which is documented here and install it.

$ npm i btcpay

Afterwards, open a terminal, create a new keypair and save the private key in your .env.

$ node -p "require('btcpay').crypto.generate_keypair()"

>>> <Key priv: XXXXXXX pub: null >

You can create a new store tied to your custom node.js backed after you have stored the key.

  • On BTCPay Server > Stores > Settings > Access Tokens > Create a new token, (leave PublicKey blank) > Request pairing

Copy the received pairing code and save it. We are going to use it right away. Go to a terminal again and execute the following code. Store the output for merchant again in .env.

# Replace the BTCPAY_XXX envirnoment variables with your values and run:

$ [space] BTCPAY_URL= BTCPAY_KEY=... BTCPAY_PAIRCODE=... node -e "const btcpay=require('btcpay'); new btcpay.BTCPayClient(process.env.BTCPAY_URL, btcpay.crypto.load_keypair(Buffer.from(process.env.BTCPAY_KEY, 'hex'))).pair_client(process.env.BTCPAY_PAIRCODE).then(console.log).catch(console.error)"

# (prepend the line with a space to prevent BTCPAY_KEY from being saved to your bash history)

>>> { merchant: 'XXXXXX' }

Now you are ready to run BTCPayserver and are a step closer to accept Bitcoin. Import it and create the client. You can also test the connection with the store by trying to output the acutal US$ rate.

// BTC Pay server
 const keypair = btcpay.crypto.load_keypair(new Buffer.from(process.env.BTC_PAY_SERVER_PRIV, 'hex'));

 // Create client
 const client = new btcpay.BTCPayClient('', keypair, {merchant: process.env.BTC_PAY_SERVER_MERCHANT});

 // store id on btcpay
 client.get_rates(['BTC_USD'], process.env.BTC_PAY_SERVER_STORE_ID)
   .then(rates => console.log(rates))
   .catch(err => console.log(err));

Use Case & Architecture

With the setup working, let us think about this use case for a second: A user wishing to top up his account by 20 $.
As we need a customised user experience we create this in a somewhat server-heavy style which is running on node.js in order to minimize client development in the web and mobile applications. 
The user should only send his user_idtoken and the amount to top up the account to our application server which then in turn does all the communication with our own self-hosted BTCPayserver and returns the invoice to him and eventually top ups his account. 

Breakdown the workflow of how to accept Bitcoin

1. Custom API call to our application server with user ID and the payment amount.

2. Our application server queries the BTCPayserver to create the invoice object.

3. The BTCPayserver sends the invoice object back to our application server which sends the invoiceID back to the client.

3. The client will display the invoice to the end user based on the invoice ID.

4. The BTCPayserver sends an IPN with extended notification to our backend on a custom URL. Here one word of caution. This API route has no signing or other security. So, we have to do that in the following steps. (See the note from Bitpay on this)

We secure this by parsing every incoming message from BTCPayserver for checking the correct format and discarding all invalid messages. You can check the status for “confirmed”. This indicates a paid invoice. As this could also be spoofed, we then proceed to check the received invoice_id directly with the BTCPayserver by pulling the relevant information from the the BTCPayserver. 

5. If this has been identical to the submitted info by IPN, we then credit the paid amount to the customer account, the amount will be credited to the user account. 

  1. The front-end part is rather simple and can be done with just a few lines of code as for example:
    You need just an input field for the amount to be loaded and a button to execute the action. Additionally, our API is secured by two tokens.
 // click on  button $('#addAmount').click(function() {  

 // show loader  
var idToken = Cookies.get('id_token');   
var inputAmount = $('#amountInput').val();  

// create search query JSON   var stringifiedJson = JSON.stringify({     id_token: idToken,     amount: inputAmount   });   

     type: 'POST',
     url: 'http://localhost:3001/api/v1/protected/createPayment',
beforeSend: function(xhr) {
       xhr.setRequestHeader('Authorization', 'Bearer ' + access_tok);     },
     data: stringifiedJson,
     contentType: 'application/json'
   }).done(function(data) {

    // show the invoice as modal -> step 3  window.btcpay.showInvoice(;        

// hide loader
     .fail(function(jqXHR, textStatus) {       });

In our backend, we can create an invoice like this: Enable extendedNotifications for getting detailed status updates.

// create an invoice in BTC payserver passing the info from db
                          price: amount,
                          currency: 'USD',
                          itemDesc: 'Load your account on LAWGATE',
                          notificationEmail: email,
                          notificationURL: '',
                          redirectURL: '',
                          posData: '{"id_token":'+ '"'+ idToken +'"'+'}', // JSON format, use JSON.parse() for getting the JSON object
                          transactionSpeed: 'medium', // for medium security, not speed 1 confirmation: new --> paid --> confirmed --> complete
                          extendedNotifications: true, // needed for getting expired notification
                          physical: false,
                          buyer: {
                            name: fullName,
                            address1: street,
                            email: email,
                            phone: phone,
                            city: city,
                            region: '',
                            zip: zip,
                            country: country
      .then(invoice => sendInvoiceBackToClient(invoice))
      .catch(err => console.log(err))
.catch(function(err) {
  return next(err);
// function handling the invoice response
    function sendInvoiceBackToClient(invoice) {
        status: "success",
        data: invoice,
        message: "Data could have been retrieved"

Handling the IPNs in order to accept Bitcoin

Now we only need to handle the IPN for updating the user account and credit with the corresponding amount.

BTCPayserver occasionally sends the following IPN:

{ event: { code: 1001, name: 'invoice_created' },
   { id: 'dkaldk34343adadfd',
      '"{ "id_token" : "klaklakldldla"}"',
     status: 'new',
     btcPrice: '0.00200381',
     price: 20,
     currency: 'USD',
     invoiceTime: 1568811624000,
     expirationTime: 1568812524000,
     currentTime: 1568811625309,
     btcPaid: '0.00000000',
     btcDue: '0.00200381',
     rate: 9981.017121251449,
     exceptionStatus: false,
     buyerFields: { buyerEmail: '' },
     transactionCurrency: null,
     paymentSubtotals: { btc: 200381 },
     paymentTotals: { btc: 200381 },
     amountPaid: '0.00000000',
     exchangeRates: { btc: [Object] } } }
{ url:
   '"{ "id_token" : "idkald"}"',
  status: 'new',
  btcPrice: '0.00200381',
  btcDue: '0.00200381',
   [ { cryptoCode: 'BTC',
       paymentType: 'BTCLike',
       rate: 9981.017121251449,
       exRates: [Object],
       paid: '0.00000000',
       price: '0.00200381',
       due: '0.00200381',
       paymentUrls: [Object],
       address: '1PP8V6gurWoEywwPhmFxMBus8cXNa8HogY',
       totalDue: '0.00200381',
       networkFee: '0.00000000',
       txCount: 0,
       cryptoPaid: '0.00000000',
       payments: [] } ],
  price: 20,
  currency: 'USD',
  exRates: { USD: 0 },
  buyerTotalBtcAmount: null,
  itemDesc: 'Load your account on LAWGATE',
  itemCode: null,
  orderId: null,
  guid: '5b72a34a-234a-343-b0a6-63888ecf4c80',
  id: 'MWvztzPnJUDk8GRPBkLnBo',
  invoiceTime: 1568811624000,
  expirationTime: 1568812524000,
  currentTime: 1568811625320,
  lowFeeDetected: false,
  btcPaid: '0.00000000',
  rate: 9981.017121251449,
  exceptionStatus: false,
   { BIP21:
      'bitcoin: 1PasdfasfdsdwwPhmFxMBus8cXNa8HogY?amount=0.00200381',
     BIP72: null,
     BIP72b: null,
     BIP73: null,
     BOLT11: null },
  refundAddressRequestPending: false,
  buyerPaidBtcMinerFee: null,
  bitcoinAddress: '1PasdfasfdsdwwPhmFxMBus8cXNa8HogY',
  token: 'GLLZntadfadaggggg36nB1wBky',
  flags: { refundable: false },
  paymentSubtotals: { BTC: 200381 },
  paymentTotals: { BTC: 200381 },
  amountPaid: 0,
  minerFees: { BTC: { satoshisPerByte: 1, totalFee: 0 } },
  exchangeRates: { BTC: { USD: 0 } },
  supportedTransactionCurrencies: { BTC: { enabled: true } },
  addresses: { BTC: '1PasdfasfdsdwwPhmFxMBus8cXNa8HogY' },
   { BTC:
      { BIP21:
        BIP72: null,
        BIP72b: null,
        BIP73: null,
        BOLT11: null } },
   { name: 'null null, M,
     address1: null,
     address2: null,
     locality: null,
     region: null,
     postalCode: null,
     country: null,
     phone: null,
     email: '' } 

With the structure of the IPN being clear, we can conclude this with the implementation of a webhook. We do not want to handle empty messages coming from BTCPay.

// check the response IPN format and check if null

  if (req === undefined || req === null) {

    res.status(200).send("success"); // feedback for gateway

    console.log('ignore empty call from btcpayserver');


Only invoices with status confirmed are handled.

// confirmed invoices
if (statusIPN == 'confirmed') {
    console.log('invoice confirmed from IPN');
    // check if the id token belongs to a user in DB
    // double check with BTCpay if the invoice is really paid after IPN message received
      .then(invoice => checkStatus(invoice))
      .catch(err => console.log(err))
      // async function for checking status again and
      function checkStatus(invoice) {
        console.log('This is the invoice, taken directly from the btc pay server');
        if (invoice.status == 'confirmed') {
          // the invoice has been confirmed in BtcPay, so now credit amount to profile
          // update the user info with the price based on its sign token
          var posData = JSON.parse(invoice.posData);
          var amountPaid = invoice.price;
          console.log('amount from btcpayserver invoice ' + amountPaid);
          // call update method
          updateUserBalance(idToken, amountPaid);
        } else {
          console.log('Status mismatch. Do nothing. Sec check fail');
} else {
  // do nothing
  console.log('invoice statusIPN is: *** ' + statusIPN + ' *** and now wait til confirmed!');

Everyone implementing this, needs to make this more secure as we can not trust the IPN coming in thru this API route because it can be easily spoofed. In order to avoid that, we double-check the invoice id being transmitted directly with our BTCPayserver in order to get the correct status from a trusted source. 
If this is successful, we can update the user balance with the amount paid coming from the BTCPayserver. Et voilà, here we are and from now on we can accept Bitcoin.

Leave a Reply

Your email address will not be published. Required fields are marked *