Documentation
¶
Overview ¶
Package kaidn is the server-side Go client for the Kaidn fraud scoring API.
It has no dependencies outside the standard library, deliberately: the whole API is JSON over HTTP, so a dependency here would buy nothing and cost every user a supply chain to audit.
client := kaidn.New(kaidn.Options{APIKey: os.Getenv("KAIDN_SECRET_KEY")})
res, err := client.Score(ctx, kaidn.Event{
Event: "signup",
Email: email,
IP: kaidn.ClientIP(r),
DeviceID: deviceID,
})
if err != nil {
// fail open: see the note on Score
log.Printf("kaidn unavailable: %v", err)
return allow()
}
switch res.Verdict {
case kaidn.VerdictBlock: return refuse(res)
case kaidn.VerdictReview: return holdForReview(res)
default: return allow()
}
Index ¶
- Constants
- func Bool(v bool) *bool
- func ClientIP(r *http.Request) string
- func Int(v int) *int
- type Check
- type Client
- func (c *Client) AddListEntry(ctx context.Context, e ListEntry) (map[string]any, error)
- func (c *Client) BatchCheckEmail(ctx context.Context, emails []string) (map[string]any, error)
- func (c *Client) BatchCheckIP(ctx context.Context, ips []string) (map[string]any, error)
- func (c *Client) BatchCheckPhone(ctx context.Context, phones []string) (map[string]any, error)
- func (c *Client) BatchLists(ctx context.Context, entries []ListEntry) (map[string]any, error)
- func (c *Client) BatchScore(ctx context.Context, rows []Event) (map[string]any, error)
- func (c *Client) Check(ctx context.Context, email, ip string) (map[string]any, error)
- func (c *Client) CheckEmail(ctx context.Context, email string) (*EmailCheckResponse, error)
- func (c *Client) CheckIP(ctx context.Context, ip string) (*IPCheckResponse, error)
- func (c *Client) CheckPhone(ctx context.Context, phone, country string) (*PhoneCheckResponse, error)
- func (c *Client) Config(ctx context.Context) (map[string]any, error)
- func (c *Client) DeleteListEntry(ctx context.Context, id string) error
- func (c *Client) DeviceObservations(ctx context.Context, deviceID string) (map[string]any, error)
- func (c *Client) Events(ctx context.Context, q EventsQuery) ([]EventRecord, error)
- func (c *Client) Forget(ctx context.Context, in ForgetInput) (*ForgetResponse, error)
- func (c *Client) Health(ctx context.Context) (*Health, error)
- func (c *Client) IPInfo(ctx context.Context) (map[string]any, error)
- func (c *Client) Label(ctx context.Context, in LabelInput) (*LabelResponse, error)
- func (c *Client) Lists(ctx context.Context) (map[string]any, error)
- func (c *Client) Lookups(ctx context.Context) (map[string]any, error)
- func (c *Client) Score(ctx context.Context, e Event) (*ScoreResponse, error)
- func (c *Client) SetConfig(ctx context.Context, cfg map[string]any) (map[string]any, error)
- func (c *Client) Stats(ctx context.Context, windowHours int) (*Stats, error)
- func (c *Client) Subject(ctx context.Context, q SubjectQuery) (map[string]any, error)
- func (c *Client) Suppressions(ctx context.Context, limit int) (map[string]any, error)
- type Device
- type DeviceSignals
- type EmailCheck
- type EmailCheckResponse
- type Error
- type Event
- type EventRecord
- type EventsQuery
- type ForgetInput
- type ForgetResponse
- type Health
- type IPCheckResponse
- type LabelInput
- type LabelResponse
- type ListEntry
- type Options
- type PhoneCheckResponse
- type Reputation
- type ScoreResponse
- type Stats
- type SubjectQuery
Examples ¶
Constants ¶
const ( VerdictAllow = "allow" VerdictReview = "review" VerdictBlock = "block" )
Verdict values returned by Score.
const Version = "0.2.0"
Version is reported in the User-Agent so API-side issues can be traced to a client release.
Variables ¶
This section is empty.
Functions ¶
func Bool ¶ added in v0.2.0
Bool is a helper for the pointer fields on DeviceSignals:
Device: &kaidn.DeviceSignals{IsHeadless: kaidn.Bool(true)}
func ClientIP ¶
ClientIP extracts the end user's address from a request.
This exists because getting it wrong is the most common way a Kaidn integration silently stops working. Behind a load balancer r.RemoteAddr is the balancer, so every user shares one address, every IP check correlates everyone with everyone, and the product looks broken.
Only trust the forwarded headers when a proxy YOU control sets them. A client can send X-Forwarded-For itself, so on a directly exposed server this is attacker-controlled input.
Example ¶
package main
import (
"fmt"
"net/http"
kaidn "github.com/kaidn-io/kaidn-go"
)
func main() {
// Behind a load balancer r.RemoteAddr is the balancer, so every user would
// share one address and every IP check would correlate everyone with
// everyone. Only trust the headers when a proxy you control sets them.
http.HandleFunc("/signup", func(w http.ResponseWriter, r *http.Request) {
ip := kaidn.ClientIP(r)
fmt.Fprintln(w, ip)
})
}
Output:
Types ¶
type Check ¶
type Check struct {
Check string `json:"check"`
Key string `json:"key"`
Weight int `json:"weight"`
Reason string `json:"reason"`
Message string `json:"message"`
Evidence map[string]any `json:"evidence,omitempty"`
}
Check is one signal that fired, with the weight it contributed to the score.
This is the field worth logging. "Why was this account blocked" is answered here, and having it already in your logs is the difference between a two-minute support reply and an afternoon of guessing.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is safe for concurrent use by multiple goroutines.
func New ¶
New builds a Client. It does not perform any I/O, so it cannot fail; an invalid key surfaces on the first call as an *Error with Status 401.
Example (Options) ¶
package main
import (
"os"
"time"
kaidn "github.com/kaidn-io/kaidn-go"
)
func main() {
client := kaidn.New(kaidn.Options{
APIKey: os.Getenv("KAIDN_SECRET_KEY"),
Timeout: 2 * time.Second, // tighter: this is in a checkout path
Retries: 1,
})
_ = client
}
Output:
func (*Client) AddListEntry ¶ added in v0.2.0
AddListEntry adds one allowlist or blocklist rule.
func (*Client) BatchCheckEmail ¶ added in v0.2.0
BatchCheckEmail checks many addresses in one request.
func (*Client) BatchCheckIP ¶ added in v0.2.0
BatchCheckIP checks many addresses in one request.
func (*Client) BatchCheckPhone ¶ added in v0.2.0
BatchCheckPhone checks many numbers in one request.
func (*Client) BatchLists ¶ added in v0.2.0
BatchLists adds many list entries in one request.
func (*Client) BatchScore ¶ added in v0.2.0
BatchScore scores many events in one request.
func (*Client) CheckEmail ¶ added in v0.2.0
CheckEmail inspects one address: disposable, aliased, gibberish, MX.
func (*Client) CheckIP ¶ added in v0.2.0
CheckIP inspects one address: datacenter, proxy, VPN, Tor, ASN, geolocation.
func (*Client) CheckPhone ¶ added in v0.2.0
func (c *Client) CheckPhone(ctx context.Context, phone, country string) (*PhoneCheckResponse, error)
CheckPhone inspects one number. Country is an optional ISO-3166 alpha-2 hint for parsing a number that is not in E.164.
func (*Client) Config ¶ added in v0.2.0
Config returns the tenant's weight and threshold overrides.
Untyped on purpose: the weights map is keyed by check name and the engine gains checks over time, so a struct here would silently drop any weight it did not know about and write it back as absent.
func (*Client) DeleteListEntry ¶ added in v0.2.0
DeleteListEntry removes one rule by id.
func (*Client) DeviceObservations ¶ added in v0.2.0
DeviceObservations returns the history behind one device id: what it has been seen doing and which subjects it links to.
This is the call that makes a device match interpretable. A raw fingerprint covers more than one real person on some platforms, so "this device matched" is only actionable once you can see WHAT it matched against.
func (*Client) Events ¶
func (c *Client) Events(ctx context.Context, q EventsQuery) ([]EventRecord, error)
Events returns recent scored events, most recent first.
func (*Client) Forget ¶
func (c *Client) Forget(ctx context.Context, in ForgetInput) (*ForgetResponse, error)
Forget erases everything held about a subject. For an erasure request under GDPR or CCPA, this is the call that satisfies it.
func (*Client) Health ¶
Health reports API status and intel list sizes. Useful as a readiness probe for your own deploys.
func (*Client) IPInfo ¶ added in v0.2.0
IPInfo returns intel for the caller's own IP, the same data the free tool shows. Useful as a smoke test that a key works.
func (*Client) Label ¶
func (c *Client) Label(ctx context.Context, in LabelInput) (*LabelResponse, error)
Label records the ground truth for an event you scored earlier.
func (*Client) Lists ¶ added in v0.2.0
Lists returns every allowlist and blocklist entry for the tenant.
func (*Client) Score ¶
Score judges one event and returns a verdict with the evidence behind it.
On error, fail OPEN. An outage at a fraud vendor must never become an outage in your product, so the correct handling of a non-nil error is to allow the action and flag it for later review, not to refuse the user. The client already retries transient failures, so an error reaching you means the service is genuinely unavailable.
Example ¶
package main
import (
"context"
"fmt"
"log"
"os"
kaidn "github.com/kaidn-io/kaidn-go"
)
func main() {
client := kaidn.New(kaidn.Options{APIKey: os.Getenv("KAIDN_SECRET_KEY")})
res, err := client.Score(context.Background(), kaidn.Event{
Event: "signup",
UserID: "u_1024",
Email: "new@example.com",
IP: "203.0.113.9",
DeviceID: "b3f1c9…",
})
if err != nil {
// Fail open: an outage at a fraud vendor must never become an outage
// in your product.
log.Printf("kaidn unavailable, allowing: %v", err)
return
}
switch res.Verdict {
case kaidn.VerdictBlock:
fmt.Println("refused:", res.ReasonText)
case kaidn.VerdictReview:
fmt.Println("created, trial held:", res.Score)
default:
fmt.Println("created")
}
}
Output:
Example (Checks) ¶
Reading the checks is how you answer "why was this blocked" without guessing.
package main
import (
"context"
"fmt"
"os"
kaidn "github.com/kaidn-io/kaidn-go"
)
func main() {
client := kaidn.New(kaidn.Options{APIKey: os.Getenv("KAIDN_SECRET_KEY")})
res, err := client.Score(context.Background(), kaidn.Event{
Event: "cashout",
Email: "payee@example.com",
})
if err != nil {
return
}
for _, c := range res.Checks {
fmt.Printf("%-24s %3d %s\n", c.Reason, c.Weight, c.Message)
}
}
Output:
Example (CollisionRisk) ¶
A device match on a high-collision identity is a flag, not a verdict.
package main
import (
"context"
"fmt"
"os"
kaidn "github.com/kaidn-io/kaidn-go"
)
func main() {
client := kaidn.New(kaidn.Options{APIKey: os.Getenv("KAIDN_SECRET_KEY")})
res, err := client.Score(context.Background(), kaidn.Event{
Event: "trial_start",
DeviceID: "b3f1c9…",
})
if err != nil {
return
}
if d := res.Device; d != nil && d.Resolution == "probabilistic" && d.CollisionRisk > 0.15 {
fmt.Println("device match, but the identity is shared: review rather than block")
return
}
fmt.Println(res.Verdict)
}
Output:
func (*Client) SetConfig ¶ added in v0.2.0
SetConfig replaces the tenant's overrides. Read Config first and modify what it returns, rather than constructing one from scratch, or you will clear overrides you did not mean to touch.
func (*Client) Subject ¶ added in v0.2.0
Subject resolves everything held about one person across their identifiers.
func (*Client) Suppressions ¶ added in v0.2.0
Suppressions returns subjects suppressed by an erasure request. Worth reading before a re-import: re-adding someone who asked to be forgotten is the kind of mistake that turns a process problem into a regulatory one.
type Device ¶
type Device struct {
ID string `json:"id,omitempty"`
ResolvedID string `json:"resolved_id,omitempty"`
// "deterministic" (a token you issued) or "probabilistic" (a fingerprint).
Resolution string `json:"resolution,omitempty"`
// The measured chance this identity covers more than one real person.
// Branch on it before acting on a device match alone.
CollisionRisk float64 `json:"collision_risk,omitempty"`
}
Device describes the identity the engine resolved for this event, and how much to trust it.
type DeviceSignals ¶
type DeviceSignals struct {
// ── browser ────────────────────────────────────────────────────────────
IsHeadless *bool `json:"is_headless,omitempty"`
UAConsistent *bool `json:"ua_consistent,omitempty"`
IsEmulated *bool `json:"is_emulated,omitempty"`
IsNoiseInjected *bool `json:"is_noise_injected,omitempty"`
IsTampered *bool `json:"is_tampered,omitempty"`
IsContextMismatch *bool `json:"is_context_mismatch,omitempty"`
IsEngineMismatch *bool `json:"is_engine_mismatch,omitempty"`
IsOSMismatch *bool `json:"is_os_mismatch,omitempty"`
IsFontStandardized *bool `json:"is_font_standardized,omitempty"`
// AntidetectScore is 0-100 and AntidetectConfidence is "low", "medium" or
// "high". Read the confidence before acting on the score: a competently
// configured anti-detect browser that does not lie about its operating
// system is not reliably detectable, by us or by anyone else.
AntidetectScore *int `json:"antidetect_score,omitempty"`
AntidetectConfidence string `json:"antidetect_confidence,omitempty"`
// ── native mobile ──────────────────────────────────────────────────────
IsEmulator *bool `json:"is_emulator,omitempty"`
IsRooted *bool `json:"is_rooted,omitempty"`
IsCloned *bool `json:"is_cloned,omitempty"`
IsHooked *bool `json:"is_hooked,omitempty"`
IsSideloaded *bool `json:"is_sideloaded,omitempty"`
IsADBEnabled *bool `json:"is_adb_enabled,omitempty"`
IsMockLocation *bool `json:"is_mock_location,omitempty"`
IsVPNActive *bool `json:"is_vpn_active,omitempty"`
IsDebuggable *bool `json:"is_debuggable,omitempty"`
}
DeviceSignals are the integrity flags the browser collector reports. All are optional; omitted fields are simply not scored. Pointers rather than plain bools throughout, because false and "not collected" are different facts. A plain bool would send is_rooted:false for every web visitor, asserting something the browser never checked.
type EmailCheck ¶ added in v0.2.0
type EmailCheck struct {
FraudScore float64 `json:"fraud_score"`
IsDisposable bool `json:"is_disposable"`
MXValid bool `json:"mx_valid"`
CatchAll bool `json:"catch_all"`
LooksGibberish bool `json:"looks_gibberish"`
HasPlusTag bool `json:"has_plus_tag"`
// Canonical is the address with dots and plus-tags stripped. Two accounts
// with different addresses and the same canonical are one inbox.
Canonical string `json:"canonical"`
IsAliased bool `json:"is_aliased"`
AliasTricks []string `json:"alias_tricks"`
IsMalformed bool `json:"is_malformed"`
RejectReason string `json:"reject_reason"`
RecentAbuse bool `json:"recent_abuse"`
}
EmailCheck is the intel held about one address.
type EmailCheckResponse ¶ added in v0.2.0
type EmailCheckResponse struct {
Email EmailCheck `json:"email"`
Reputation Reputation `json:"reputation"`
Summary string `json:"summary"`
}
type Error ¶
type Error struct {
// HTTP status, or 0 when the request never got a response.
Status int
// The API's error message when present, otherwise a description of the
// transport failure.
Message string
// Raw response body, kept for the cases where the message is not enough.
Body []byte
}
Error is returned for any non-2xx response, and for a network or timeout failure with Status 0.
Message is the API's own `{"error": …}` string when the response had one, rather than a generic "request failed", because the API's message is consistently the more useful of the two.
Example ¶
package main
import (
"context"
"errors"
"fmt"
kaidn "github.com/kaidn-io/kaidn-go"
)
func main() {
client := kaidn.New(kaidn.Options{APIKey: "wrong"})
_, err := client.Score(context.Background(), kaidn.Event{Event: "signup"})
var kerr *kaidn.Error
if errors.As(err, &kerr) {
switch {
case kerr.QuotaExceeded():
fmt.Println("out of events for this period")
case kerr.Retryable():
fmt.Println("transient, try again later")
default:
fmt.Println("permanent:", kerr.Message)
}
}
}
Output:
func (*Error) QuotaExceeded ¶
QuotaExceeded reports whether the tenant is out of events for the period. Worth separating from other failures because the response is a product decision (prompt an upgrade, degrade gracefully) rather than a technical one.
func (*Error) Retryable ¶
Retryable reports whether the failure is transient and worth trying again: a network error, a rate limit, or a server fault.
A 4xx other than 429 is not retryable, and retrying it wastes quota and latency. Bad input and a bad API key do not become correct on the second attempt. The client uses this internally, and it is exported so callers building their own queue or backoff can make the same distinction.
type Event ¶
type Event struct {
// What happened, in your own vocabulary: "signup", "trial_start",
// "cashout", "credit_redeem". Billing counts events, not fields.
Event string `json:"event"`
UserID string `json:"user_id,omitempty"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
// The end user's IP, not your server's. Behind a proxy this must come from
// a header your own infrastructure sets; see ClientIP in the README.
IP string `json:"ip,omitempty"`
// DeviceID is the probabilistic identifier from the browser collector.
// Treat it as evidence, never as proof: identical settings produce
// identical hashes, so unrelated people on default devices can share one.
DeviceID string `json:"device_id,omitempty"`
// DeviceToken is the deterministic first-party identifier you issued and
// stored in your own cookie. When you have it, it beats DeviceID outright.
DeviceToken string `json:"device_token,omitempty"`
// Device carries browser integrity signals collected client-side.
Device *DeviceSignals `json:"device,omitempty"`
// Timezone as reported by the browser, e.g. "Europe/London". Compared
// against the IP's location: a mismatch is a weak signal on its own and a
// useful corroborator next to a proxy tell.
Timezone string `json:"timezone,omitempty"`
// PhoneCountry is the phone's country as an ISO-3166 alpha-2 code.
PhoneCountry string `json:"phone_country,omitempty"`
// EventCountry is the country the user claims (a form field, a profile).
EventCountry string `json:"event_country,omitempty"`
// IPCountry overrides the country the engine would derive from the IP.
// Send it when you already have a geolocation you trust more than ours.
IPCountry string `json:"ip_country,omitempty"`
// ── attribution ────────────────────────────────────────────────────────
// Where the user came from. These carry the affiliate-fraud signals: the
// same identity converting under three different affiliates in a week is
// not three customers, and no single partner's data can see that. Omitting
// them does not break scoring, it just leaves that whole class of abuse
// invisible.
Source string `json:"source,omitempty"`
Site string `json:"site,omitempty"`
Campaign string `json:"campaign,omitempty"`
Affiliate string `json:"affiliate,omitempty"`
Link string `json:"link,omitempty"`
}
Event is one user action to score. Only Event is required.
Every other field is a signal, and each one you send is another check that can fire. Sending only an email still returns a verdict, it just has less to go on. There is no penalty for omitting a field you do not have, so send what you have rather than inventing placeholders: a fabricated IP is worse than no IP, because it will be scored as though it were real.
type EventRecord ¶
type EventsQuery ¶
EventsQuery filters the event history.
type ForgetInput ¶
type ForgetInput struct {
UserID string `json:"user_id,omitempty"`
Email string `json:"email,omitempty"`
DeviceID string `json:"device_id,omitempty"`
}
ForgetInput erases everything held about a subject, for a GDPR or CCPA erasure request.
type ForgetResponse ¶
type ForgetResponse struct {
Deleted int `json:"deleted"`
}
type IPCheckResponse ¶ added in v0.2.0
type IPCheckResponse struct {
IP map[string]any `json:"ip"`
Reputation Reputation `json:"reputation"`
Summary string `json:"summary"`
}
type LabelInput ¶
type LabelInput struct {
EventID string `json:"event_id"`
// "fraud" or "legit".
Label string `json:"label"`
Note string `json:"note,omitempty"`
}
LabelInput reports the ground truth for an event you scored earlier. Feeding outcomes back is what lets weights be tuned against reality rather than against intuition.
type LabelResponse ¶
type LabelResponse struct {
OK bool `json:"ok"`
}
type ListEntry ¶ added in v0.2.0
type ListEntry struct {
ID string `json:"id,omitempty"`
List string `json:"list"`
Type string `json:"type"`
Value string `json:"value"`
}
ListEntry is one allowlist or blocklist rule. List is "allow" or "block", Type is the subject kind ("email", "ip", "device_id", "domain", "asn").
Allowlist entries short-circuit scoring entirely, which is what makes them the right tool for your own QA accounts and the wrong tool for anything you are not certain about.
type Options ¶
type Options struct {
// APIKey is your SECRET key. Never ship this to a browser: it can score,
// read your event history and erase data. The browser wants the
// publishable key instead, which can only submit fingerprints.
APIKey string
// BaseURL defaults to https://api.kaidn.io. Override it to point at a
// local API in tests.
BaseURL string
// Timeout is the per-attempt timeout. Defaults to 5 seconds.
//
// Kept deliberately short because scoring sits in the critical path of a
// signup or a payout. A fraud check must never be the reason a real
// customer cannot register.
Timeout time.Duration
// Retries is the number of RETRIES after the first attempt, so 2 means at
// most 3 requests. Defaults to 2. Only transient failures are retried:
// see Error.Retryable.
Retries int
// HTTPClient injects your own client for tests, proxies or custom
// transports. When set, Timeout is ignored and yours is authoritative.
HTTPClient *http.Client
}
Options configures a Client. Only APIKey is required.
type PhoneCheckResponse ¶ added in v0.2.0
type PhoneCheckResponse struct {
Phone map[string]any `json:"phone"`
Reputation Reputation `json:"reputation"`
Summary string `json:"summary"`
}
type Reputation ¶ added in v0.2.0
type Reputation struct {
RecentAbuse bool `json:"recent_abuse"`
NetworkRisk float64 `json:"network_risk"`
// How many distinct operators have seen this subject. The whole argument
// for a shared graph is in this number.
NetworkOperators int `json:"network_operators"`
HoneypotHits int `json:"honeypot_hits"`
}
Reputation is what the network has seen about a subject, as opposed to what the subject looks like on its own.
type ScoreResponse ¶
type ScoreResponse struct {
EventID string `json:"event_id"`
// 0-100. Not a probability and not a confidence: it is the sum of the
// weights that fired, clamped. The useful information is in Checks.
Score int `json:"score"`
// One of VerdictAllow, VerdictReview, VerdictBlock.
Verdict string `json:"verdict"`
// Reason codes, stable identifiers safe to branch on.
Reasons []string `json:"reasons"`
// The same reasoning as a sentence, for a review queue or a log line.
ReasonText string `json:"reason_text"`
Checks []Check `json:"checks"`
Device *Device `json:"device,omitempty"`
}
ScoreResponse is the result of scoring one event.
func (*ScoreResponse) Allowed ¶
func (r *ScoreResponse) Allowed() bool
Allowed, NeedsReview and Blocked read better at a call site than comparing strings, and they keep the verdict values in one place.
func (*ScoreResponse) Blocked ¶
func (r *ScoreResponse) Blocked() bool
func (*ScoreResponse) NeedsReview ¶
func (r *ScoreResponse) NeedsReview() bool
type SubjectQuery ¶ added in v0.2.0
type SubjectQuery struct {
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
IP string `json:"ip,omitempty"`
DeviceID string `json:"device_id,omitempty"`
UserID string `json:"user_id,omitempty"`
Limit int `json:"limit,omitempty"`
}
SubjectQuery asks what is known about a subject across identifiers. Any combination may be given; Limit caps the events returned.