Chapter 10 · Running it
Watches and webhooks
Scenario: Your app texts Ada when her balance drops below ₦2,000. Blnk tells your service through a webhook, and your service must be sure each webhook really came from Blnk.
Step 1: watch the balance
A watch is a threshold on one account. While it holds, Blnk sends the webhook event balance.monitor after every update of the account.
low, err := hb.Watches.Create(ctx, humanblnk.Watch{
Account: humanblnk.AccountRef(ada.ID), Field: humanblnk.WatchBalance, Op: humanblnk.WatchBelow,
Threshold: money.MustParse("1000", demo.NGN), Description: "low balance alert",
})
if err != nil {
log.Fatal(err)
}
fmt.Println("watch", low.ID, "on", low.Account, low.Field, low.Op, low.Threshold)| Field | Values |
|---|---|
Field | WatchBalance (Total), WatchCredits, WatchDebits, WatchOutgoingHeld (held out of it), WatchIncomingHeld (held into it), WatchHeldNet |
Op | WatchAbove, WatchAtLeast, WatchBelow, WatchAtMost, WatchEquals, WatchNotEquals |
Threshold | a money.Money in the account's currency, at most 2^53 in its smallest unit (about ₦90 trillion, or 0.009 ETH). Larger is ErrInvalid: Blnk can't store it exactly. |
Step 2: move the line
Change returns a watch with a new id: Blnk can't change a threshold in place, so the new watch is made before the old one is deleted. The account is never unwatched in between, but both may fire once. List with "" lists every watch.
low, err = hb.Watches.Change(ctx, low.ID, humanblnk.Watch{
Account: humanblnk.AccountRef(ada.ID), Field: humanblnk.WatchBalance, Op: humanblnk.WatchBelow,
Threshold: money.MustParse("2000", demo.NGN), Description: "low balance alert",
})
if err != nil {
log.Fatal(err)
}
watches, _ := hb.Watches.List(ctx, humanblnk.AccountRef(ada.ID))
fmt.Println("watches on Ada:", len(watches), "| threshold now", watches[0].Threshold)Step 3: receive webhooks safely
Blnk signs each webhook with its server.secret_key. humanblnk.VerifyWebhook checks the signature and the age, then decodes it. Never act on a body that fails the check. This is the handler you mount at the URL Blnk sends webhooks to:
func webhook(w http.ResponseWriter, r *http.Request) {
ev, err := humanblnk.VerifyWebhook(secret, r, 5*time.Minute)
switch {
case errors.Is(err, blnk.ErrBadSignature), errors.Is(err, blnk.ErrStale):
http.Error(w, "bad signature", http.StatusUnauthorized) // never act on its body
fmt.Println(" rejected:", err)
return
case err != nil:
// Signed, but not something to act on: say so, or Blnk sends it
// again and again.
w.WriteHeader(http.StatusNoContent)
fmt.Println(" can't use it:", err)
return
}
switch {
case ev.Kind == humanblnk.EventBalanceMonitor:
fmt.Println(" watch", ev.Watch, "fired on", ev.Account, "- e.g. text the customer 'low balance'")
case ev.Entry != nil: // transaction.applied, transaction.rejected, …
fmt.Println(" ", ev.Kind, ev.Entry.Ref, ev.Entry.Amount)
}
w.WriteHeader(http.StatusOK)
}Event field | |
|---|---|
Kind | the event: humanblnk.EventBalanceMonitor, EventTransactionApplied, EventTransactionVoid, … |
Watch, Account | for balance.monitor: which watch fired, on which account |
Entry | for transaction.* events: the entry |
Data | the raw data, as Blnk sent it |
Record sends a split as a hold and captures it at once (chapter 5), and RecordAll its batch, so Blnk sends transaction.inflight for each posting and then transaction.applied for each capture. The applied ones carry the transaction's own Ref in Entry.Ref. React to transaction.applied for money that moved.
The tolerance rejects old deliveries replayed by an attacker; five minutes is sensible, and is what VerifyWebhook uses if you pass 0. Answer anything that isn't 2xx and Blnk sends the webhook again, so reject only a bad signature or a stale one, and acknowledge what you can't use.
Blnk can deliver the same event more than once, each time freshly signed. Make the handler safe to repeat: text the customer once per watch and day, say, or act on an entry once by its ID.
The example signs requests the way Blnk does and sends a genuine one and a forged one to a test server.
Step 4: stop watching
if err := hb.Watches.Delete(ctx, low.ID); err != nil {
log.Fatal(err)
}
fmt.Println("watch deleted")The whole example
go run ./docs/examples/10-watches-and-webhooks// Chapter 10: watch an account's balance, and receive Blnk's webhooks
// safely.
package main
import (
"bytes"
"errors"
"fmt"
"log"
"net/http"
"net/http/httptest"
"strconv"
"time"
"github.com/otyang/humanblnk"
"github.com/otyang/humanblnk/blnk"
"github.com/otyang/humanblnk/docs/examples/demo"
"github.com/otyang/humanblnk/money"
)
// secret is Blnk's server.secret_key, which signs its webhooks.
const secret = "change-me"
// webhook is the handler you mount at the URL Blnk sends webhooks to.
func webhook(w http.ResponseWriter, r *http.Request) {
ev, err := humanblnk.VerifyWebhook(secret, r, 5*time.Minute)
switch {
case errors.Is(err, blnk.ErrBadSignature), errors.Is(err, blnk.ErrStale):
http.Error(w, "bad signature", http.StatusUnauthorized) // never act on its body
fmt.Println(" rejected:", err)
return
case err != nil:
// Signed, but not something to act on: say so, or Blnk sends it
// again and again.
w.WriteHeader(http.StatusNoContent)
fmt.Println(" can't use it:", err)
return
}
switch {
case ev.Kind == humanblnk.EventBalanceMonitor:
fmt.Println(" watch", ev.Watch, "fired on", ev.Account, "- e.g. text the customer 'low balance'")
case ev.Entry != nil: // transaction.applied, transaction.rejected, …
fmt.Println(" ", ev.Kind, ev.Entry.Ref, ev.Entry.Amount)
}
w.WriteHeader(http.StatusOK)
}
func main() {
ctx, hb := demo.Setup()
ada := demo.Customer(ctx, hb, "ada", demo.NGN)
demo.Deposit(ctx, hb, ada, "5000")
// 1. Tell Blnk to fire "balance.monitor" while Ada's balance is under ₦1,000.
low, err := hb.Watches.Create(ctx, humanblnk.Watch{
Account: humanblnk.AccountRef(ada.ID), Field: humanblnk.WatchBalance, Op: humanblnk.WatchBelow,
Threshold: money.MustParse("1000", demo.NGN), Description: "low balance alert",
})
if err != nil {
log.Fatal(err)
}
fmt.Println("watch", low.ID, "on", low.Account, low.Field, low.Op, low.Threshold)
// Move the line: Change makes the new watch before deleting the old one.
low, err = hb.Watches.Change(ctx, low.ID, humanblnk.Watch{
Account: humanblnk.AccountRef(ada.ID), Field: humanblnk.WatchBalance, Op: humanblnk.WatchBelow,
Threshold: money.MustParse("2000", demo.NGN), Description: "low balance alert",
})
if err != nil {
log.Fatal(err)
}
watches, _ := hb.Watches.List(ctx, humanblnk.AccountRef(ada.ID))
fmt.Println("watches on Ada:", len(watches), "| threshold now", watches[0].Threshold)
// 2. Receive webhooks. Here a test server stands in for your service,
// and the requests are signed the way Blnk signs them.
srv := httptest.NewServer(http.HandlerFunc(webhook))
defer srv.Close()
send := func(sign string, body string) {
ts := strconv.FormatInt(time.Now().Unix(), 10)
req, _ := http.NewRequest(http.MethodPost, srv.URL, bytes.NewBufferString(body))
req.Header.Set(blnk.HeaderTimestamp, ts)
req.Header.Set(blnk.HeaderSignature, blnk.Sign(sign, ts, []byte(body)))
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
res.Body.Close()
fmt.Println(" ->", res.Status)
}
body := `{"event":"balance.monitor","data":{"monitor_id":"` + string(low.ID) + `","balance_id":"` + ada.ID + `"}}`
fmt.Println("a genuine webhook:")
send(secret, body)
fmt.Println("a forged one:")
send("not-the-secret", body)
// 3. Stop watching.
if err := hb.Watches.Delete(ctx, low.ID); err != nil {
log.Fatal(err)
}
fmt.Println("watch deleted")
}watch mon_7a30c4bc-caaa-4da0-a4d0-f91cd0912cdf on bln_b8469f1d-e935-424c-8b27-ce4f4eea5ba8 balance < 1000.00
watches on Ada: 1 | threshold now 2000.00
a genuine webhook:
watch mon_57366f82-1d37-4179-9434-bc1cd2481278 fired on bln_b8469f1d-e935-424c-8b27-ce4f4eea5ba8 - e.g. text the customer 'low balance'
-> 200 OK
a forged one:
rejected: blnk: webhook signature mismatch
-> 401 Unauthorized
watch deletedTo receive real webhooks, set Blnk's webhook URL to your handler, and give your service the same secret as Blnk's server.secret_key (see Blnk's configuration docs).