Skip to content

Commit bcdb81f

Browse files
authored
Merge pull request #11 from benalucorp/arwego/feat/add_webhooks
Webhooks - Add Comparator, and Stub for Create Charge
2 parents 63198ce + 6ba2057 commit bcdb81f

22 files changed

+372
-17
lines changed

README.md

+40-1
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,43 @@ func AlwaysError(ctx context.Context, client *coinbase.Client) {
126126
}
127127
```
128128

129+
For more example, check [here](main_test.go).
130+
131+
### Webhook
132+
133+
Webhooks make it easier to integrate with Coinbase Commerce by allowing you to subscribe to a set of charge events. In a sense webhook is basically callback to notify our app after a change of state in resource such as charges. Just like before, you can enable that by injecting certain value to the context.
134+
135+
```go
136+
package main
137+
138+
import (
139+
"context"
140+
"log"
141+
142+
"github.com/benalucorp/coinbase-commerce-go"
143+
"github.com/benalucorp/coinbase-commerce-go/pkg/api"
144+
"github.com/benalucorp/coinbase-commerce-go/pkg/entity"
145+
"github.com/benalucorp/coinbase-commerce-go/pkg/enum"
146+
"github.com/benalucorp/coinbase-commerce-go/pkg/api/stub"
147+
)
148+
149+
func SendWebhook(ctx context.Context, client *coinbase.Cliet) {
150+
// Enable stub that will send webhook after certain times.
151+
ctx := stub.SetWebhookReq(context.Background(), &entity.WebhookReq{
152+
URL: "http://127.0.0.1:9000/test",
153+
SharedSecretKey: "WEBHOOK_SECRET_KEY",
154+
Resource: stub.CreateWebhooksResource(),
155+
})
156+
resp, err := client.CreateCharge(ctx, &entity.CreateChargeReq{})
157+
if err != nil {
158+
log.Fatal(err)
159+
}
160+
log.Printf("%+v", resp)
161+
}
162+
```
163+
164+
For more example, check [here](example/main.go).
165+
129166
## Supported API
130167

131168
Version: 2018-03-22
@@ -148,4 +185,6 @@ Version: 2018-03-22
148185
- [Cancel an invoice](https://commerce.coinbase.com/docs/api/#cancel-an-invoice)
149186
- [Events](https://commerce.coinbase.com/docs/api/#events)
150187
- [Show an event](https://commerce.coinbase.com/docs/api/#show-a-event)
151-
- [List events](https://commerce.coinbase.com/docs/api/#list-events)
188+
- [List events](https://commerce.coinbase.com/docs/api/#list-events)
189+
- [Webhooks](https://commerce.coinbase.com/docs/api/#webhooks)
190+
- [Securing webhooks](https://commerce.coinbase.com/docs/api/#securing-webhooks)

example/main.go

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"log"
7+
"net/http"
8+
9+
"github.com/benalucorp/coinbase-commerce-go"
10+
"github.com/benalucorp/coinbase-commerce-go/pkg/api"
11+
"github.com/benalucorp/coinbase-commerce-go/pkg/entity"
12+
"github.com/benalucorp/coinbase-commerce-go/pkg/enum"
13+
"github.com/benalucorp/coinbase-commerce-go/pkg/stub"
14+
"github.com/kokizzu/gotro/L"
15+
)
16+
17+
func main() {
18+
client, err := coinbase.NewClient(api.Config{
19+
Key: "API_KEY",
20+
Debug: true,
21+
})
22+
if err != nil {
23+
log.Fatal(err)
24+
}
25+
26+
// Set stub to send webhook response
27+
ctx := stub.SetWebhookReq(context.Background(), &entity.WebhookReq{
28+
URL: "http://127.0.0.1:9000/test",
29+
SharedSecretKey: "WEBHOOK_KEY",
30+
Resource: stub.CreateWebhooksResource(),
31+
})
32+
resp, err := client.CreateCharge(ctx, &entity.CreateChargeReq{
33+
Name: "The Sovereign Individual",
34+
Description: "Mastering the Transition to the Information Age",
35+
LocalPrice: entity.CreateChargePrice{
36+
Amount: "100.00",
37+
Currency: "USD",
38+
},
39+
PricingType: enum.PricingTypeFixedPrice,
40+
Metadata: entity.CreateChargeMetadata{
41+
CustomerID: "id_1005",
42+
CustomerName: "Satoshi Nakamoto",
43+
},
44+
RedirectURL: "https://charge/completed/page",
45+
CancelURL: "https://charge/canceled/page",
46+
})
47+
if err != nil {
48+
log.Fatal(err)
49+
}
50+
L.Describe(resp.Data.ID)
51+
52+
// Create server.
53+
http.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
54+
var req entity.WebhookResource
55+
err := json.NewDecoder(r.Body).Decode(&req)
56+
if err != nil {
57+
http.Error(w, err.Error(), http.StatusBadRequest)
58+
return
59+
}
60+
receivedSignature := r.Header.Get("X-CC-Webhook-Signature")
61+
sharedSecretKey := "WEBHOOK_KEY"
62+
err = coinbase.CompareWebhookSignature(r.Context(), &req, receivedSignature, sharedSecretKey)
63+
if err != nil {
64+
http.Error(w, err.Error(), http.StatusUnauthorized)
65+
return
66+
}
67+
68+
// Success.
69+
L.Describe(r.Header)
70+
L.Describe(req)
71+
w.WriteHeader(http.StatusOK)
72+
})
73+
_ = http.ListenAndServe(":9000", nil)
74+
}

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.14
55
require (
66
github.com/go-resty/resty/v2 v2.6.0
77
github.com/kokizzu/gotro v1.817.1737
8+
github.com/robfig/cron/v3 v3.0.1
89
github.com/segmentio/ksuid v1.0.4
910
github.com/stretchr/testify v1.7.0
1011
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
146146
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
147147
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
148148
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
149+
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
150+
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
149151
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
150152
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
151153
github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=

main.go

+15-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import (
44
"context"
55

66
"github.com/benalucorp/coinbase-commerce-go/pkg/api"
7-
"github.com/benalucorp/coinbase-commerce-go/pkg/api/stub"
87
"github.com/benalucorp/coinbase-commerce-go/pkg/entity"
8+
"github.com/benalucorp/coinbase-commerce-go/pkg/stub"
99
)
1010

1111
// NewClient creates a client to interact with Coinbase Commerce API.
@@ -16,7 +16,7 @@ func NewClient(cfg api.Config) (*Client, error) {
1616

1717
return &Client{
1818
charges: api.NewCharges(cfg),
19-
chargesStub: stub.NewCharges(),
19+
chargesStub: stub.NewCharges(cfg),
2020
checkouts: api.NewCheckouts(cfg),
2121
checkoutsStub: stub.NewCheckouts(),
2222
invoices: api.NewInvoices(cfg),
@@ -257,3 +257,16 @@ func (c *Client) ShowEvent(ctx context.Context, req *entity.ShowEventReq) (*enti
257257
}
258258
return c.events.Show(ctx, req)
259259
}
260+
261+
// CompareWebhookSignature used for the handler middleware to verify webhook.
262+
// Mismatch signature will return an error.
263+
//
264+
// Payload:
265+
// req => from request body.
266+
// receivedSignature => from "X-CC-Webhook-Signature" header.
267+
// sharedSecretKey => from webhook setting page secret key.
268+
//
269+
// Reference: https://commerce.coinbase.com/docs/api/#securing-webhooks
270+
func CompareWebhookSignature(ctx context.Context, req *entity.WebhookResource, receivedSignature, sharedSecretKey string) error {
271+
return api.CompareWebhookSignature(ctx, req, receivedSignature, sharedSecretKey)
272+
}

main_test.go

+11-11
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import (
55
"testing"
66

77
"github.com/benalucorp/coinbase-commerce-go/pkg/api"
8-
"github.com/benalucorp/coinbase-commerce-go/pkg/api/stub"
98
"github.com/benalucorp/coinbase-commerce-go/pkg/entity"
9+
"github.com/benalucorp/coinbase-commerce-go/pkg/stub"
1010
"github.com/kokizzu/gotro/L"
1111
"github.com/stretchr/testify/assert"
1212
)
@@ -30,7 +30,7 @@ func TestClient_CancelCharge(t *testing.T) {
3030
name: "Stub error",
3131
fields: fields{
3232
charges: nil,
33-
chargesStub: stub.NewCharges(),
33+
chargesStub: stub.NewCharges(api.Config{}),
3434
},
3535
args: args{
3636
ctx: stub.SetErrDetailResp(context.Background(), entity.ErrDetailResp{
@@ -48,7 +48,7 @@ func TestClient_CancelCharge(t *testing.T) {
4848
name: "Stub success",
4949
fields: fields{
5050
charges: nil,
51-
chargesStub: stub.NewCharges(),
51+
chargesStub: stub.NewCharges(api.Config{}),
5252
},
5353
args: args{
5454
ctx: stub.Enable(context.Background()),
@@ -98,7 +98,7 @@ func TestClient_CreateCharge(t *testing.T) {
9898
name: "Stub error",
9999
fields: fields{
100100
charges: nil,
101-
chargesStub: stub.NewCharges(),
101+
chargesStub: stub.NewCharges(api.Config{}),
102102
},
103103
args: args{
104104
ctx: stub.SetErrDetailResp(context.Background(), entity.ErrDetailResp{
@@ -113,7 +113,7 @@ func TestClient_CreateCharge(t *testing.T) {
113113
name: "Stub success",
114114
fields: fields{
115115
charges: nil,
116-
chargesStub: stub.NewCharges(),
116+
chargesStub: stub.NewCharges(api.Config{}),
117117
},
118118
args: args{
119119
ctx: stub.Enable(context.Background()),
@@ -160,7 +160,7 @@ func TestClient_ListCharges(t *testing.T) {
160160
name: "Stub error",
161161
fields: fields{
162162
charges: nil,
163-
chargesStub: stub.NewCharges(),
163+
chargesStub: stub.NewCharges(api.Config{}),
164164
},
165165
args: args{
166166
ctx: stub.SetErrDetailResp(context.Background(), entity.ErrDetailResp{
@@ -175,7 +175,7 @@ func TestClient_ListCharges(t *testing.T) {
175175
name: "Stub success",
176176
fields: fields{
177177
charges: nil,
178-
chargesStub: stub.NewCharges(),
178+
chargesStub: stub.NewCharges(api.Config{}),
179179
},
180180
args: args{
181181
ctx: stub.Enable(context.Background()),
@@ -222,7 +222,7 @@ func TestClient_ResolveCharge(t *testing.T) {
222222
name: "Stub error",
223223
fields: fields{
224224
charges: nil,
225-
chargesStub: stub.NewCharges(),
225+
chargesStub: stub.NewCharges(api.Config{}),
226226
},
227227
args: args{
228228
ctx: stub.SetErrDetailResp(context.Background(), entity.ErrDetailResp{
@@ -240,7 +240,7 @@ func TestClient_ResolveCharge(t *testing.T) {
240240
name: "Stub success",
241241
fields: fields{
242242
charges: nil,
243-
chargesStub: stub.NewCharges(),
243+
chargesStub: stub.NewCharges(api.Config{}),
244244
},
245245
args: args{
246246
ctx: stub.Enable(context.Background()),
@@ -290,7 +290,7 @@ func TestClient_ShowCharge(t *testing.T) {
290290
name: "Stub error",
291291
fields: fields{
292292
charges: nil,
293-
chargesStub: stub.NewCharges(),
293+
chargesStub: stub.NewCharges(api.Config{}),
294294
},
295295
args: args{
296296
ctx: stub.SetErrDetailResp(context.Background(), entity.ErrDetailResp{
@@ -308,7 +308,7 @@ func TestClient_ShowCharge(t *testing.T) {
308308
name: "Stub success",
309309
fields: fields{
310310
charges: nil,
311-
chargesStub: stub.NewCharges(),
311+
chargesStub: stub.NewCharges(api.Config{}),
312312
},
313313
args: args{
314314
ctx: stub.Enable(context.Background()),

pkg/api/webhooks.go

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"crypto/hmac"
6+
"crypto/sha256"
7+
"encoding/hex"
8+
"encoding/json"
9+
"errors"
10+
11+
"github.com/benalucorp/coinbase-commerce-go/pkg/entity"
12+
)
13+
14+
func CreateWebhookSignature(_ context.Context, req *entity.WebhookResource, sharedSecretKey string) (string, error) {
15+
// Create raw request.
16+
rawReq, err := json.Marshal(req)
17+
if err != nil {
18+
return "", err
19+
}
20+
21+
// Sign raw request.
22+
h := hmac.New(sha256.New, []byte(sharedSecretKey))
23+
if _, err = h.Write(rawReq); err != nil {
24+
return "", err
25+
}
26+
signedReq := h.Sum(nil)
27+
28+
// Encode signed request.
29+
encodedReq := make([]byte, hex.EncodedLen(len(signedReq)))
30+
hex.Encode(encodedReq, signedReq)
31+
32+
// Return encoded signed request as string.
33+
return string(encodedReq), nil
34+
}
35+
36+
func CompareWebhookSignature(_ context.Context, req *entity.WebhookResource, receivedSignature, sharedSecretKey string) error {
37+
// Decode received signature.
38+
decodedSignature, err := hex.DecodeString(receivedSignature)
39+
if err != nil {
40+
return err
41+
}
42+
43+
// Create raw request.
44+
rawReq, err := json.Marshal(req)
45+
if err != nil {
46+
return err
47+
}
48+
49+
// Sign raw request.
50+
h := hmac.New(sha256.New, []byte(sharedSecretKey))
51+
if _, err = h.Write(rawReq); err != nil {
52+
return err
53+
}
54+
signedReq := h.Sum(nil)
55+
56+
// Compare created signature with the received signature.
57+
if ok := hmac.Equal(signedReq, decodedSignature); !ok {
58+
return errors.New("coinbase: no signatures found matching the expected signature for payload")
59+
}
60+
return nil
61+
}

pkg/entity/webhooks.go

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package entity
2+
3+
// Reference: https://commerce.coinbase.com/docs/api/#securing-webhooks
4+
5+
type WebhookReq struct {
6+
URL string
7+
SharedSecretKey string
8+
Resource WebhookResource
9+
}

pkg/entity/webhooks_resource.go

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package entity
2+
3+
import (
4+
"time"
5+
)
6+
7+
type WebhookResource struct {
8+
ID int `json:"id"`
9+
ScheduledFor time.Time `json:"scheduled_for"`
10+
Event EventResource `json:"event"`
11+
}

0 commit comments

Comments
 (0)