humanblnk tutorial

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.

10-watches-and-webhooks/main.go
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)
FieldValues
FieldWatchBalance (Total), WatchCredits, WatchDebits, WatchOutgoingHeld (held out of it), WatchIncomingHeld (held into it), WatchHeldNet
OpWatchAbove, WatchAtLeast, WatchBelow, WatchAtMost, WatchEquals, WatchNotEquals
Thresholda 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.

10-watches-and-webhooks/main.go
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:

10-watches-and-webhooks/main.go
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
Kindthe event: humanblnk.EventBalanceMonitor, EventTransactionApplied, EventTransactionVoid, …
Watch, Accountfor balance.monitor: which watch fired, on which account
Entryfor transaction.* events: the entry
Datathe 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

10-watches-and-webhooks/main.go
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
10-watches-and-webhooks/main.go
// 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")
}

To 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).