Lessons
Accept Payments in Bitcoin using Coinbase & Go
In this tutorial, I will step through the steps for accepting payments. We will use Coinbase and therefore will require a Coinbase account. I will publish lessons on using other services but because of the popularity (in my world) of Coinbase, it's the first service I will demonstrate how to integrate with. I am assuming you have a basic understanding of Go.
Coinbase provides a client SDK for Python, Ruby, and Node.js. There are solutions with and without using the SDK. Coinbase has also introduced the Coinbase Commerce. It has a separate API and is mostly focused around e-commerce tools (Ex. Shopify or WooCommerce) but we can use it to integrate into our own website. Below you can select a language for this tutorial, the level of details and a method of solving it.
If you head over to the Coinbased Developer guide, you will see snippets of code for:
- Receive Funds
- Send Funds
- Request Funds
We will leverage the receive funds functionality but we need to confirm the Bitcoin has arrived as well. There are a list of potential endpoints in the API docs. The general flow will be create an address to receive and wait till the transactions is listed.
From a design perspective, we need to consider two things. This payment can take a bit of time to show up. The user will not send the payment within 30 seconds and therefore your initial API call will timeout. We need to split it up into two functions: Get Address to Receive Funds, Has payment been received. The has payment been received will be called every N seconds till it shows.
I am going to use this Go Boilerplate. If you like it, please star the repository. Either fork it, create a new repository from a template, or simply create a repository and copy paste the code.
Not interested in all the steps? You can just show me the code snippet.
If you are building a e-commerce store, I recommend adding a status to the order. You may need to have a CRON job running in the background. The three statuses would be: requested_payments
(return the Bitcoin address), payment_received
(receive the Bitcoin), and order_submitted
(Order ready and submitted as valid). You would only process orders that have the status order_submitted
.
1 2 3
cd ~/go/src/ git clone git@github.com:keithweaver/lesson-accept-bitcoin-go.git cd lesson-accept-bitcoin-go
The README has more details about how the code base is structured. My repository is named lesson-accept-bitcoin-go
. We will add a new handler, a new service, a new repository file, and a few models. We will call it Order
. There will be three endpoints: POST - /order/
, POST - /order/sync
, and GET - /order/
. The sync endpoint would be used for checking for a transaction.
Open the code base in your favourite editor. I'm going to use Atom. Open the main.go
, and add (below // Repositories
stuff):
1
ordersRepository := repositories.NewInstanceOfOrdersRepository(db)
Add (below // Services
stuff):
1
ordersService := services.NewInstanceOfOrdersService(ordersRepository)
Add (below // Handlers
stuff):
1
ordersHandler := handlers.NewInstanceOfOrdersHandler(ordersService)
And at the bottom:
1 2 3 4 5 6 7 8 9 10 11
carsAPI := router.Group("/cars") { ... Add below ... ordersAPI := router.Group("/orders") { ordersAPI.POST("/", ordersHandler.SubmitOrder) ordersAPI.GET("/:id", ordersHandler.GetOrder) ordersAPI.POST("/sync", ordersHandler.SyncOrder) } router.Run(":8080") }
Add a new handler called handlers/orders_handler.go
. There are three functions: GetOrder
, SubmitOrder
, and SyncOrder
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
package handlers import ( "github.com/gin-gonic/gin" validator "github.com/go-playground/validator/v10" "lesson-accept-bitcoin-go/models" "lesson-accept-bitcoin-go/services" ) type OrdersHandler struct { OrdersService services.OrdersService } func NewInstanceOfOrdersHandler(ordersService services.OrdersService) *OrdersHandler { return &OrdersHandler{OrdersService: ordersService} } func (o *OrdersHandler) SubmitOrder(c *gin.Context) { var body models.NewOrder if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"message": err.Error()}) return } v := validator.New() if err := v.Struct(body); err != nil { c.JSON(400, gin.H{"message": err.Error()}) return } address, totalPrice, orderID, err := o.OrdersService.SubmitOrder(body) if err != nil { c.JSON(400, gin.H{"message": err.Error()}) return } c.JSON(200, gin.H{"message": "Order submitted", "address": address, "totalPrice": totalPrice, "orderID": orderID}) return } func (o *OrdersHandler) GetOrder(c *gin.Context) { orderID := c.Param("id") order, err := o.OrdersService.GetOrder(orderID) if err != nil { c.JSON(400, gin.H{"message": err.Error()}) return } c.JSON(200, gin.H{"message": "Order returned", "order": order}) return } func (o *OrdersHandler) SyncOrder(c *gin.Context) { var body models.SyncOrder if err := c.ShouldBindJSON(&body); err != nil { c.JSON(400, gin.H{"message": err.Error()}) return } v := validator.New() if err := v.Struct(body); err != nil { c.JSON(400, gin.H{"message": err.Error()}) return } err := o.OrdersService.SyncOrder(body) if err != nil { c.JSON(400, gin.H{"message": err.Error()}) return } c.JSON(200, gin.H{"message": "Order synced"}) return }
Next, we will create a repository. We are skipping the model, and service for now. We need to understand what calls need to be made and what data needs to be stored.
Create a new file repositories/orders_repository.go
. At the top:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
package repositories import ( "context" "time" "os" "strconv" "net/http" "io/ioutil" "fmt" "encoding/json" "bytes" "crypto/hmac" "crypto/sha256" "lesson-accept-bitcoin-go/models" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" ) type OrdersRepository struct { db *mongo.Database } func NewInstanceOfOrdersRepository(db *mongo.Database) OrdersRepository { return OrdersRepository{db: db} }
The first function will be create address. This method hits the Coinbase API and generates a new hash to send Bitcoin too.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
// https://developers.coinbase.com/api/v2#create-address func (o *OrdersRepository) CreateAddress(addressName string) (string, error) { accountID := o.getAccountID() reqBody, err := json.Marshal(map[string]string{ "name": addressName, }) if err != nil { return "", err } baseURL := "https://api.coinbase.com" requestPath := "/v2/accounts/" + accountID + "/addresses" url := baseURL + requestPath req, err := http.NewRequest("POST", url, bytes.NewBuffer(reqBody)) requestTimestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10) message := requestTimestamp + "POST" + requestPath + string(reqBody) sha := sha256.New h := hmac.New(sha, []byte(o.getAPISecret())) h.Write([]byte(message)) signature := fmt.Sprintf("%x", h.Sum(nil)) req.Header.Set("Content-Type", "application/json") req.Header.Set("CB-ACCESS-KEY", o.getAPIKey()) req.Header.Set("CB-ACCESS-SIGN", signature) req.Header.Set("CB-ACCESS-TIMESTAMP", requestTimestamp) client := &http.Client{} resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return "", err } var response models.CreateAddressResponse err = json.Unmarshal(body, &response) if err != nil { return "", err } return response.Data.Address, nil }
The next method for the repository file is list transactions on an address.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
// https://developers.coinbase.com/api/v2#list-address39s-transactions func (o *OrdersRepository) ListAddressTransactions(addressID string) ([]models.AddressTransaction, error) { // Methods are setup in this recursive fashion for paging, but // its not actually setup. You want to capture the next page, // make another request, and pass in the previous results. return o.listAddressTransactionCall(addressID, []models.AddressTransaction{}) } func (o *OrdersRepository) listAddressTransactionCall(addressID string, addressTransactions []models.AddressTransaction) ([]models.AddressTransaction, error) { accountID := o.getAccountID() baseURL := "https://api.coinbase.com" requestPath := "/v2/accounts/" + accountID + "/addresses/" + addressID + "/transactions" url := baseURL + requestPath // bytes.NewBuffer(reqBody) req, err := http.NewRequest("GET", url, nil) requestTimestamp := strconv.FormatInt(time.Now().UTC().Unix(), 10) message := requestTimestamp + "GET" + requestPath sha := sha256.New h := hmac.New(sha, []byte(o.getAPISecret())) h.Write([]byte(message)) signature := fmt.Sprintf("%x", h.Sum(nil)) req.Header.Set("Content-Type", "application/json") req.Header.Set("CB-ACCESS-KEY", o.getAPIKey()) req.Header.Set("CB-ACCESS-SIGN", signature) req.Header.Set("CB-ACCESS-TIMESTAMP", requestTimestamp) client := &http.Client{} resp, err := client.Do(req) if err != nil { return addressTransactions, err } defer resp.Body.Close() body, err := ioutil.ReadAll(resp.Body) if err != nil { return addressTransactions, err } // fmt.Printf("response :: %+v\n", string(body)) var response models.ListAddressTransactionsResponse err = json.Unmarshal(body, &response) if err != nil { return addressTransactions, err } for _, addressTransaction := range response.Data { addressTransactions = append(addressTransactions, addressTransaction) } // fmt.Printf("addressTransactions :: %+v\n", addressTransactions) return addressTransactions, nil }
These are the two Coinbase API methods. There will be a few other helper methods for retrieving values from the environment variables. We will setup the environment variables later on.
1 2 3 4 5 6 7 8 9 10
func (o *OrdersRepository) getAccountID() (string) { return os.Getenv("COINBASE_ACCOUNT_ID") } func (o *OrdersRepository) getAPIKey() (string) { return os.Getenv("COINBASE_API_KEY") } func (o *OrdersRepository) getAPISecret() (string) { return os.Getenv("COINBASE_API_SECRET") }
In the OrdersRepository
, we need a few methods for saving orders, updating order statuses, and get order.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
func (o *OrdersRepository) SaveOrder(order models.Order) (string, error) { insertResult, err := o.db.Collection("orders").InsertOne(context.TODO(), order) if err != nil { return "", err } return insertResult.InsertedID.(primitive.ObjectID).Hex(), nil } func (o *OrdersRepository) Get(orderID string) (models.Order, error) { docID, err := primitive.ObjectIDFromHex(orderID) if err != nil { return models.Order{}, err } filter := bson.M{"_id": docID} var result models.Order err = o.db.Collection("orders").FindOne(context.TODO(), filter).Decode(&result) if err != nil { return models.Order{}, err } return result, nil } func (o *OrdersRepository) UpdateStatus(orderID primitive.ObjectID, status string) (error) { update := bson.M{"$set": bson.M{ "status": status }} filter := bson.M{"_id": orderID} _, err := o.db.Collection("orders").UpdateOne(context.TODO(), filter, update) if err != nil { return err } return nil }
We have the repository layer setup. Now, the model layer. Create a new file called models/orders.go
. Here is the top of the file:
1 2 3 4 5 6
package models import ( "time" "go.mongodb.org/mongo-driver/bson/primitive" )
The first method is for inbound models. This is the expected JSON objects on the inbound requests.
1 2 3 4 5 6 7 8 9 10 11 12
type SyncOrder struct { OrderID string `json:"id"` } type NewOrder struct { Items []OrderItem `json:"items"` } type OrderItem struct { Name string `json:"name"` Price float64 `json:"price"` }
The next set of structs are for interacting with the Coinbase API.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
type CreateAddressResponse struct { Data CreateAddressInnerResponse `json:"data"` } type CreateAddressInnerResponse struct { ID string `json:"id"` Address string `json:"address"` Name string `json:"name"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Network string `json:"network"` Resource string `json:"resource"` ResourcePath string `json:"resource_path"` } type AddressTransaction struct { ID string `json:"id"` Type string `json:"type"` Status string `json:"status"` Amount AmountObject `json:"amount"` NativeAmount AmountObject `json:"native_amount"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Network Network `json:"network"` } type AmountObject struct { Amount string `json:"amount"` Currency string `json:"currency"` } type Network struct { Status string `json:"status"` StatusDescription string `json:"status_description"` Hash string `json:"hash"` TransactionURL string `json:"transaction_url"` } type ListAddressTransactionsResponse struct { Data []AddressTransaction `json:"data"` }
The last struct is for saving in the database.
1 2 3 4 5 6 7 8
type Order struct { ID primitive.ObjectID `json:"id" bson:"_id,omitempty"` Address string `json:"address" bson:"address"` AddressName string `json:"addressName" bson:"addressName"` Status string `json:"status" bson:"status"` Items []OrderItem `json:"items" bson:"items"` TotalPrice float64 `json:"totalPrice" bson:"totalPrice"` }
The last portion of the app would be the business layer. Create a file called services/orders_service.go
for the service layer. Like the handler, there will be three functions: SubmitOrder
, SyncOrder
, and GetOrder
. The top of the file will be:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
package services import ( "fmt" "strconv" "lesson-accept-bitcoin-go/models" "lesson-accept-bitcoin-go/repositories" "errors" "github.com/google/uuid" ) type OrdersService struct { ordersRepository repositories.OrdersRepository } func NewInstanceOfOrdersService(ordersRepository repositories.OrdersRepository) OrdersService { return OrdersService{ordersRepository: ordersRepository} }
The first method is the SubmitOrder
. The steps are:
- Verify a few items in the order
- Create an address using Coinbase API
- Calculate price
- Save order
- Return the address, the price, and the order ID
The code is:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
func (o *OrdersService) SubmitOrder(body models.NewOrder) (string, float64, string, error) { if (body.Items != nil && len(body.Items) == 0) { return "", 0, "", errors.New("error: Order requires at least one item") } addressName := "api-" + uuid.New().String() address, err := o.ordersRepository.CreateAddress(addressName) if err != nil { return "", 0, "", err } // Get price price := 0.0 for _, item := range body.Items { price += item.Price // Verify price against product catalog. } // Save order order := models.Order{ Address: address, Status: "order_submitted", Items: body.Items, TotalPrice: price, AddressName: addressName, } orderID, err := o.ordersRepository.SaveOrder(order) if err != nil { return "", 0, "", err } fmt.Println("Address :: " + address) return address, price, orderID, nil }
SyncOrder
is the next method. The steps for this method:
- Get the order
- Fetch the list of transactions for an address
- Calculate the total amount sent
- Verify its equal or more
- If its more, then update the status
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
func (o *OrdersService) SyncOrder(body models.SyncOrder) (error) { if body.OrderID == "" { return errors.New("error: Order id is missing") } // Get the order order, err := o.ordersRepository.Get(body.OrderID) if err != nil { return err } // Get transactions using address transactions, err := o.ordersRepository.ListAddressTransactions(order.Address) if err != nil { return err } fmt.Printf("transactions :: %+v\n", transactions) // Get expect product details - 0.00012930 totalExpectedAmount := order.TotalPrice total := 0.0 for _, transaction := range transactions { transactionAmount, err := strconv.ParseFloat(transaction.Amount.Amount, 64) if err == nil { total += transactionAmount } } if total < totalExpectedAmount { return nil } // Update order to say retrieved err = o.ordersRepository.UpdateStatus(order.ID, "completed") if err != nil { return err } return nil }
The third method is GetOrder
. It returns the order.
1 2 3 4 5 6 7 8 9 10
func (o *OrdersService) GetOrder(orderID string) (models.Order, error) { if orderID == "" { return models.Order{}, errors.New("error: Order id is missing") } order, err := o.ordersRepository.Get(orderID) if err != nil { return models.Order{}, err } return order, nil }
Setup API Key
You will need a new API key from your Coinbase account. In the top right corner, select your profile icon. Then select Settings
. Under the Settings
page, click the API
tab. Hit + New API Key
.
You will have to select specific permissions for an API key. You can find these permissions on the API docs.
You need to select wallet:addresses:create
, and wallet:transactions:read
.
There are additional options. One security aspects is whitelisting an IP. You should add your server IP to the whilelist.
Keep track of the client API key and the client API secret.
Finding the Account ID
You will need an account ID that represents your wallet. Select Portfolio
, then Bitcoin
. The account ID can be found in the URL.
Running
You can run the API with the command below. You need to sub in proper values for <account_id>
, <api_key>
and <account_secret>
. From the root:
1
COINBASE_ACCOUNT_ID=<account_id> COINBASE_API_KEY=<api_key> COINBASE_API_SECRET=<account_secret> go run main.go
Testing
Time to test! I'm going to use Postman for testing. It doesn't matter what HTTP client you use.
1
POST - http://localhost:8080/orders/
For the body of the request, you will pass in order items. You will create:
1 2 3 4 5 6 7 8
{ "items": [ { "name": "Product Item", "price": 0.00006 } ] }
Here is my sample request:
Capture the address
, orderID
and total price. You can see the address in Profile > Settings > Crypto Addresses
.
Check your Mongo database using Mongo Compass.
You will need send Bitcoin to your new address. It will have to be up to the minimum amount.
video[/vids/accept-bitcoin-using-coinbase/sent-bitcoin-short.mov]
It will take about 20 minutes to go through. It did for me. Wait for the request to go through:
The next step is to call the sync endpoint. You will be passing the orderID
back into it. You will see the transactions.
The database will have the order. The status should have been updated.
You can also test out the get order endpoint. It takes the orderID
again. It should return the order information.
When the order status goes to completed, then it has successfully gone through.
Thanks for reading! More posts to come.
References
- https://developers.coinbase.com/docs/wallet/guides/send-receive
- https://developers.coinbase.com/api/v2