On this page
There is no Go SDK for Maskbreak. There are Node, Python and PHP packages, and Go is conspicuously absent from that list, which comes up often enough that it is worth answering properly.
The honest answer: for a one-endpoint JSON API, you do not want one. The wrapper is about seventy lines of standard library, it has no third-party dependencies, and writing it yourself means the timeout and the failure mode are decisions you made rather than ones you inherited. This follows the three steps the Maskbreak console lists (add the script to the page with your form, send the check from your server, submit your form once) and starts in watch mode, where every check is logged and nothing is refused.
Step 1: Add the script to the page with your form
Load the script on the page with your form and put class="monocle-enriched" on the <form> element itself (never on an input):
<!-- templates/signup.html -->
<script async src="https://maskbreak.com/assets/sentinel.js"></script>
<form method="post" action="/signup" class="monocle-enriched">
<input type="email" name="email" required>
<input type="password" name="password" required>
<button type="submit">Create account</button>
</form>
The script adds three hidden fields: monocle (the network token), sentinel_fp (the browser check id) and sentinel_tz (the time zone), so they arrive in r.FormValue. A JavaScript frontend calls await window.Sentinel.collect() instead (after checking that window.Sentinel exists; submit anyway when it does not) and posts its token, fingerprintEventId and tz as form fields alongside its own. If you set a Content-Security-Policy, add https://maskbreak.com, https://mcl.maskbreak.com, https://*.mcl.maskbreak.com, https://mesu.maskbreak.com and 'wasm-unsafe-eval' to script-src, the mcl hosts (https and wss) and mesu to connect-src, and https://mesu.maskbreak.com blob: to worker-src.
Step 2: Send the check from your server
One POST, one JSON body, one bearer token. The client sends whatever evidence arrived, including none: a check without a token or a browser check id is how the console tells you which half is missing.
package fraud
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
// "watch" (default) or "enforce".
var Mode = func() string {
if m := os.Getenv("MASKBREAK_MODE"); m != "" {
return m
}
return "watch"
}()
type Client struct {
Key string
HTTP *http.Client
}
func New(key string) *Client {
// One shared client keeps connections alive: a fresh TLS handshake
// per signup is self-inflicted latency.
return &Client{Key: key, HTTP: &http.Client{Timeout: 3 * time.Second}}
}
type Request struct {
Token string `json:"token"`
FingerprintEventID string `json:"fingerprintEventId,omitempty"`
TZ string `json:"tz,omitempty"`
Email string `json:"email,omitempty"`
AccountID string `json:"accountId,omitempty"`
}
type Verdict struct {
Decision string `json:"decision"` // allow | review | block
RiskScore int `json:"risk_score"`
Reasons []string `json:"reasons"`
DeviceEvidence string `json:"device_evidence"` // ok | not_sent | unavailable
Degraded bool `json:"degraded"`
Test bool `json:"test"`
}
// CleanAllow is a complete, production allow: what a strict action needs.
func (v *Verdict) CleanAllow() bool {
return v != nil && v.Decision == "allow" && v.DeviceEvidence == "ok" && !v.Degraded && !v.Test
}
func (c *Client) Evaluate(ctx context.Context, in Request) (*Verdict, error) {
body, err := json.Marshal(in)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://maskbreak.com/v1/evaluate", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Key)
req.Header.Set("Content-Type", "application/json")
res, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("maskbreak: status %d", res.StatusCode) // 401: the key
}
var v Verdict
if err := json.NewDecoder(res.Body).Decode(&v); err != nil {
return nil, err
}
return &v, nil
}
Wire it into your own app: a free key returns decision, risk_score and reasons for every visit, 10,000 checks a month on the Free plan, no card.
Get an API keyThe middleware
The verdict belongs in the request context so the handler decides what to do with it. The middleware never refuses a request itself, and it calls on every request it wraps, even when the form carried no token.
// guard.go, in the same package
package fraud
import (
"context"
"expvar"
"log"
"net/http"
"time"
)
// Failed calls, graphable from /debug/vars or your metrics exporter.
var evalErrors = expvar.NewInt("maskbreak_eval_errors")
type ctxKey struct{}
// Guard evaluates the visit and stashes the verdict. It never refuses a
// request itself: allow/review/block is a product decision, and the same
// middleware has to serve a signup form and a payout endpoint.
func (c *Client) Guard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Its own deadline: a slow fraud check must not consume the
// budget the handler needs to answer.
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
_ = r.ParseForm()
v, err := c.Evaluate(ctx, Request{
Token: first(r, "monocle", "token"),
FingerprintEventID: first(r, "sentinel_fp", "fingerprintEventId"),
TZ: first(r, "sentinel_tz", "tz"),
Email: r.FormValue("email"),
})
if err != nil {
evalErrors.Add(1) // fail open, and make the failure countable
log.Printf("maskbreak unavailable: %v", err)
}
// One line per check. Never log the key, the token or form data.
if v != nil {
log.Printf("maskbreak mode=%s decision=%s reasons=%v device_evidence=%s degraded=%t",
Mode, v.Decision, v.Reasons, v.DeviceEvidence, v.Degraded)
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, v)))
})
}
func first(r *http.Request, a, b string) string {
if v := r.FormValue(a); v != "" {
return v
}
return r.FormValue(b)
}
// FromContext returns the verdict, or nil when the check did not complete.
func FromContext(ctx context.Context) *Verdict {
v, _ := ctx.Value(ctxKey{}).(*Verdict)
return v
}
The nil return is the contract, and handlers have to respect it. FromContext returning nil means the check did not complete, not that the visit was clean.
Using it
mux := http.NewServeMux()
guard := fraud.New(os.Getenv("MASKBREAK_API_KEY")).Guard
// Mounted per action, not globally.
mux.Handle("/signup", guard(http.HandlerFunc(signup)))
mux.Handle("/payout", guard(http.HandlerFunc(payout)))
func signup(w http.ResponseWriter, r *http.Request) {
v := fraud.FromContext(r.Context())
if fraud.Mode == "enforce" && v != nil {
switch v.Decision {
case "block":
http.Error(w, "unable to complete signup", http.StatusForbidden)
return
case "review":
requireEmailOTP(r) // step up, do not refuse
}
}
createAccount(w, r) // watch mode: always reached
}
func payout(w http.ResponseWriter, r *http.Request) {
v := fraud.FromContext(r.Context())
if fraud.Mode == "enforce" && !v.CleanAllow() {
http.Error(w, "additional verification required", http.StatusConflict)
return
}
sendPayout(w, r)
}
Mounting per action rather than on the whole mux matters more in Go than in frameworks where middleware registration is centralised and invisible. Wrapping every route multiplies call volume, adds latency to endpoints that gain nothing, and converts a vendor slowdown into a whole-site problem instead of a checkout problem. Set MASKBREAK_API_KEY to your live key in the environment; it is never compiled in and never shipped to a browser, including via WebAssembly.
Step 3: Submit your form once
Run the app on your computer or deploy it, open the page in an ordinary browser, wait a moment, and submit the form. The first check then appears in the Maskbreak console (if half of it did not arrive, the console says which half), and your log shows one maskbreak line. degraded=true means no network token arrived (the script was blocked, or the class is missing or on an input); device_evidence=not_sent means no browser check id arrived; device_evidence=ok with degraded=false is a complete check. When the lines look right after a few days, set MASKBREAK_MODE=enforce and restart. A fixed test token such as test_clean comes back with test: true, which is why CleanAllow refuses it on a payout.
A breaker for the bad afternoon
Failing open on a single timeout is correct. Failing open on ten thousand consecutive timeouts, each costing seconds of a request handler, is a queue backing up. A few lines fix it:
// breaker.go, same package (imports "sync" and "time")
type breaker struct {
mu sync.Mutex
fails int
openTo time.Time
}
func (b *breaker) allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
return time.Now().After(b.openTo)
}
func (b *breaker) record(err error) {
b.mu.Lock()
defer b.mu.Unlock()
if err == nil {
b.fails = 0
return
}
if b.fails++; b.fails >= 5 {
b.openTo = time.Now().Add(30 * time.Second) // skip the call entirely
b.fails = 0
}
}
To wire it in, give Client a brk breaker field; in Guard, skip the Evaluate call (and leave the verdict nil) while c.brk.allow() is false, and pass the call's error to c.brk.record afterwards. Five consecutive failures and the middleware stops calling out for thirty seconds, letting traffic through with no verdict at all. You lose detection for half a minute. You do not lose the site.
Testing without an account
The sandbox key is public and returns fixed decisions for the test tokens, so integration tests need no account:
func TestVerdictShapes(t *testing.T) {
c := fraud.New("sk_test_sandbox")
for token, want := range map[string]string{
"test_clean": "allow",
"test_vpn": "review",
"test_proxy": "block",
"test_tor": "block",
} {
v, err := c.Evaluate(context.Background(), fraud.Request{Token: token})
if err != nil {
t.Fatalf("%s: %v", token, err)
}
if v.Decision != want {
t.Errorf("%s: got %q want %q", token, v.Decision, want)
}
if !v.Test {
t.Errorf("%s: a sandbox answer must say test", token)
}
}
}
Route on Decision, never on RiskScore: the score is a magnitude, the decision is the verdict, and your dashboard rules change only the decision. There is a longer argument about why.
Before it ships
- Script on the page, the class on the
<form>, andtoken,fingerprintEventIdandtzforwarded on every call. - Key read from the environment, never compiled in, never shipped to a browser.
- One shared
http.Client, not one per request, with a timeout. - Every error path continues and increments a counter you actually graph.
- Watch first. In enforce mode,
reviewsteps up; it does not route to the same branch asblock.
Questions people ask
- Is there an official Go SDK?
- Not today. The published SDKs are Node, Python and PHP. For a single-endpoint JSON API a Go wrapper is about seventy lines of net/http with no third-party dependencies, and writing it yourself means the timeout, the retry policy and the failure mode are decisions you made rather than ones you inherited.
- Should the middleware run on every route?
- No. Mount it on the handful of actions where abuse actually costs money: signup, login, checkout, payout, promo redemption. Wrapping every route multiplies your call volume, adds latency to endpoints that gain nothing from it, and makes a vendor slowdown a whole-site problem instead of a checkout problem.
- What happens if the API times out?
- The request is allowed. That is deliberate: a fraud check that fails closed turns a vendor incident into an outage of your own signup or checkout, which is nearly always the more expensive failure. The middleware in this post uses a three-second context deadline, allows on any error, and increments a counter so a silent degradation still shows up in your metrics.
- Where does the token come from in a Go application?
- From the browser script, which adds three hidden inputs to a form that carries class "monocle-enriched": monocle for the network layer, sentinel_fp for the device event id and sentinel_tz for the time zone. Your handler reads them off the parsed form and forwards them as token, fingerprintEventId and tz. Neither value is a secret and neither is trusted on its own: Maskbreak resolves both server-side before any verdict exists.
- Does the API key ever belong in client-side Go or WASM?
- No. A live key authenticates your account and belongs only in server-side code, read from the environment. Anything compiled to WebAssembly and shipped to a browser is client-side code, whatever language it started as, and a key embedded there is a key you have published.
Paste it in, then watch the verdicts
The public sk_test_sandbox key returns the documented allow, review and block shapes with no account, so the failure path is testable before you go live. SDKs for Node, Python and PHP, or plain HTTP. The API reference and the Free plan cover the rest.



